export: fixed using a restricted directory as destination

This commit is contained in:
Thibault Deckers 2022-01-21 11:58:38 +09:00
parent e548134d30
commit 20b1d3a15b
2 changed files with 82 additions and 86 deletions

View file

@ -13,6 +13,7 @@ import androidx.exifinterface.media.ExifInterface
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.FutureTarget
import com.bumptech.glide.request.RequestOptions
import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.decoder.MultiTrackImage
@ -36,6 +37,7 @@ import deckers.thibault.aves.utils.MimeTypes.isVideo
import java.io.ByteArrayInputStream
import java.io.File
import java.io.IOException
import java.io.OutputStream
import java.util.*
import kotlin.collections.HashMap
@ -98,12 +100,6 @@ abstract class ImageProvider {
return
}
// TODO TLAD [storage] allow inserting by Media Store
if (targetDirDocFile == null) {
callback.onFailure(Exception("failed to get tree doc for directory at path=$targetDir"))
return
}
for (entry in entries) {
val sourceUri = entry.uri
val sourcePath = entry.path
@ -118,7 +114,7 @@ abstract class ImageProvider {
val sourceMimeType = entry.mimeType
val exportMimeType = if (isVideo(sourceMimeType)) sourceMimeType else imageExportMimeType
try {
val newFields = exportSingleByTreeDocAndScan(
val newFields = exportSingle(
activity = activity,
sourceEntry = entry,
targetDir = targetDir,
@ -138,11 +134,11 @@ abstract class ImageProvider {
}
@Suppress("BlockingMethodInNonBlockingContext")
private suspend fun exportSingleByTreeDocAndScan(
private suspend fun exportSingle(
activity: Activity,
sourceEntry: AvesEntry,
targetDir: String,
targetDirDocFile: DocumentFileCompat,
targetDirDocFile: DocumentFileCompat?,
width: Int,
height: Int,
nameConflictStrategy: NameConflictStrategy,
@ -170,16 +166,16 @@ abstract class ImageProvider {
conflictStrategy = nameConflictStrategy,
) ?: return skippedFieldMap
// the file created from a `TreeDocumentFile` is also a `TreeDocumentFile`
// but in order to open an output stream to it, we need to use a `SingleDocumentFile`
// through a document URI, not a tree URI
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
val targetTreeFile = targetDirDocFile.createFile(exportMimeType, targetNameWithoutExtension)
val targetDocFile = DocumentFileCompat.fromSingleUri(activity, targetTreeFile.uri)
val targetMimeType: String
val write: (OutputStream) -> Unit
var target: FutureTarget<Bitmap>? = null
try {
if (isVideo(sourceMimeType)) {
targetMimeType = sourceMimeType
write = { output ->
val sourceDocFile = DocumentFileCompat.fromSingleUri(activity, sourceUri)
sourceDocFile.copyTo(targetDocFile)
sourceDocFile.copyTo(output)
}
} else {
val model: Any = if (MimeTypes.isHeic(sourceMimeType) && pageId != null) {
MultiTrackImage(activity, sourceUri, pageId)
@ -197,19 +193,19 @@ abstract class ImageProvider {
.diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true)
val target = Glide.with(activity)
target = Glide.with(activity)
.asBitmap()
.apply(glideOptions)
.load(model)
.submit(width, height)
try {
var bitmap = target.get()
if (MimeTypes.needRotationAfterGlide(sourceMimeType)) {
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped)
}
bitmap ?: throw Exception("failed to get image for mimeType=$sourceMimeType uri=$sourceUri page=$pageId")
targetDocFile.openOutputStream().use { output ->
targetMimeType = exportMimeType
write = { output ->
if (exportMimeType == MimeTypes.BMP) {
BmpWriter.writeRGB24(bitmap, output)
} else {
@ -232,21 +228,23 @@ abstract class ImageProvider {
bitmap.compress(format, quality, output)
}
}
} catch (e: Exception) {
// remove empty file
if (targetDocFile.exists()) {
targetDocFile.delete()
}
throw e
val mediaStoreImageProvider = MediaStoreImageProvider()
val targetPath = mediaStoreImageProvider.createSingle(
activity = activity,
mimeType = targetMimeType,
targetDir = targetDir,
targetDirDocFile = targetDirDocFile,
targetNameWithoutExtension = targetNameWithoutExtension,
write = write,
)
return mediaStoreImageProvider.scanNewPath(activity, targetPath, exportMimeType)
} finally {
// clearing Glide target should happen after effectively writing the bitmap
Glide.with(activity).clear(target)
}
}
val fileName = targetDocFile.name
val targetFullPath = targetDir + fileName
return MediaStoreImageProvider().scanNewPath(activity, targetFullPath, exportMimeType)
}
@Suppress("BlockingMethodInNonBlockingContext")

View file

@ -28,6 +28,7 @@ import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.StorageUtils.PathSegments
import deckers.thibault.aves.utils.UriUtils.tryParseId
import java.io.File
import java.io.OutputStream
import java.util.*
import java.util.concurrent.CompletableFuture
import kotlin.collections.ArrayList
@ -414,34 +415,39 @@ class MediaStoreImageProvider : ImageProvider() {
conflictStrategy = nameConflictStrategy,
) ?: return skippedFieldMap
return moveSingleByTreeDoc(
val sourceDocFile = DocumentFileCompat.fromSingleUri(activity, sourceUri)
val targetPath = createSingle(
activity = activity,
mimeType = mimeType,
sourceUri = sourceUri,
sourcePath = sourcePath,
targetDir = targetDir,
targetDirDocFile = targetDirDocFile,
targetNameWithoutExtension = targetNameWithoutExtension,
copy = copy
)
) { output: OutputStream -> sourceDocFile.copyTo(output) }
if (!copy) {
// delete original entry
try {
delete(activity, sourceUri, sourcePath, mimeType)
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to delete entry with path=$sourcePath", e)
}
}
return scanNewPath(activity, targetPath, mimeType)
}
private suspend fun moveSingleByTreeDoc(
activity: Activity,
mimeType: String,
sourceUri: Uri,
sourcePath: String,
targetDir: String,
targetDirDocFile: DocumentFileCompat?,
targetNameWithoutExtension: String,
copy: Boolean
): FieldMap {
// `DocumentsContract.moveDocument()` needs `sourceParentDocumentUri`, which could be different for each entry
// `DocumentsContract.copyDocument()` yields "Unsupported call: android:copyDocument"
// when used with entry URI as `sourceDocumentUri`, and targetDirDocFile URI as `targetParentDocumentUri`
val source = DocumentFileCompat.fromSingleUri(activity, sourceUri)
val targetPath = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && isDownloadDir(activity, targetDir)) {
fun createSingle(
activity: Activity,
mimeType: String,
targetDir: String,
targetDirDocFile: DocumentFileCompat?,
targetNameWithoutExtension: String,
write: (OutputStream) -> Unit,
): String {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && isDownloadDir(activity, targetDir)) {
val targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}"
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, targetFileName)
@ -451,10 +457,7 @@ class MediaStoreImageProvider : ImageProvider() {
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
uri?.let {
@Suppress("BlockingMethodInNonBlockingContext")
resolver.openOutputStream(uri)?.use { output ->
source.copyTo(output)
}
resolver.openOutputStream(uri)?.use(write)
values.clear()
values.put(MediaStore.MediaColumns.IS_PENDING, 0)
resolver.update(uri, values, null, null)
@ -468,12 +471,18 @@ class MediaStoreImageProvider : ImageProvider() {
// but in order to open an output stream to it, we need to use a `SingleDocumentFile`
// through a document URI, not a tree URI
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
@Suppress("BlockingMethodInNonBlockingContext")
val targetTreeFile = targetDirDocFile.createFile(mimeType, targetNameWithoutExtension)
val targetDocFile = DocumentFileCompat.fromSingleUri(activity, targetTreeFile.uri)
@Suppress("BlockingMethodInNonBlockingContext")
source.copyTo(targetDocFile)
try {
targetDocFile.openOutputStream().use(write)
} catch (e: Exception) {
// remove empty file
if (targetDocFile.exists()) {
targetDocFile.delete()
}
throw e
}
// the source file name and the created document file name can be different when:
// - a file with the same name already exists, some implementations give a suffix like ` (1)`, some *do not*
@ -481,17 +490,6 @@ class MediaStoreImageProvider : ImageProvider() {
val fileName = targetDocFile.name
targetDir + fileName
}
if (!copy) {
// delete original entry
try {
delete(activity, sourceUri, sourcePath, mimeType)
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to delete entry with path=$sourcePath", e)
}
}
return scanNewPath(activity, targetPath, mimeType)
}
private fun isDownloadDir(context: Context, dirPath: String): Boolean {