export: fixed using a restricted directory as destination
This commit is contained in:
parent
e548134d30
commit
20b1d3a15b
2 changed files with 82 additions and 86 deletions
|
@ -13,6 +13,7 @@ import androidx.exifinterface.media.ExifInterface
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.DecodeFormat
|
import com.bumptech.glide.load.DecodeFormat
|
||||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||||
|
import com.bumptech.glide.request.FutureTarget
|
||||||
import com.bumptech.glide.request.RequestOptions
|
import com.bumptech.glide.request.RequestOptions
|
||||||
import com.commonsware.cwac.document.DocumentFileCompat
|
import com.commonsware.cwac.document.DocumentFileCompat
|
||||||
import deckers.thibault.aves.decoder.MultiTrackImage
|
import deckers.thibault.aves.decoder.MultiTrackImage
|
||||||
|
@ -36,6 +37,7 @@ import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import java.io.OutputStream
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.collections.HashMap
|
import kotlin.collections.HashMap
|
||||||
|
|
||||||
|
@ -98,12 +100,6 @@ abstract class ImageProvider {
|
||||||
return
|
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) {
|
for (entry in entries) {
|
||||||
val sourceUri = entry.uri
|
val sourceUri = entry.uri
|
||||||
val sourcePath = entry.path
|
val sourcePath = entry.path
|
||||||
|
@ -118,7 +114,7 @@ abstract class ImageProvider {
|
||||||
val sourceMimeType = entry.mimeType
|
val sourceMimeType = entry.mimeType
|
||||||
val exportMimeType = if (isVideo(sourceMimeType)) sourceMimeType else imageExportMimeType
|
val exportMimeType = if (isVideo(sourceMimeType)) sourceMimeType else imageExportMimeType
|
||||||
try {
|
try {
|
||||||
val newFields = exportSingleByTreeDocAndScan(
|
val newFields = exportSingle(
|
||||||
activity = activity,
|
activity = activity,
|
||||||
sourceEntry = entry,
|
sourceEntry = entry,
|
||||||
targetDir = targetDir,
|
targetDir = targetDir,
|
||||||
|
@ -138,11 +134,11 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
private suspend fun exportSingleByTreeDocAndScan(
|
private suspend fun exportSingle(
|
||||||
activity: Activity,
|
activity: Activity,
|
||||||
sourceEntry: AvesEntry,
|
sourceEntry: AvesEntry,
|
||||||
targetDir: String,
|
targetDir: String,
|
||||||
targetDirDocFile: DocumentFileCompat,
|
targetDirDocFile: DocumentFileCompat?,
|
||||||
width: Int,
|
width: Int,
|
||||||
height: Int,
|
height: Int,
|
||||||
nameConflictStrategy: NameConflictStrategy,
|
nameConflictStrategy: NameConflictStrategy,
|
||||||
|
@ -170,46 +166,46 @@ abstract class ImageProvider {
|
||||||
conflictStrategy = nameConflictStrategy,
|
conflictStrategy = nameConflictStrategy,
|
||||||
) ?: return skippedFieldMap
|
) ?: return skippedFieldMap
|
||||||
|
|
||||||
// the file created from a `TreeDocumentFile` is also a `TreeDocumentFile`
|
val targetMimeType: String
|
||||||
// but in order to open an output stream to it, we need to use a `SingleDocumentFile`
|
val write: (OutputStream) -> Unit
|
||||||
// through a document URI, not a tree URI
|
var target: FutureTarget<Bitmap>? = null
|
||||||
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
|
try {
|
||||||
val targetTreeFile = targetDirDocFile.createFile(exportMimeType, targetNameWithoutExtension)
|
if (isVideo(sourceMimeType)) {
|
||||||
val targetDocFile = DocumentFileCompat.fromSingleUri(activity, targetTreeFile.uri)
|
targetMimeType = sourceMimeType
|
||||||
|
write = { output ->
|
||||||
if (isVideo(sourceMimeType)) {
|
val sourceDocFile = DocumentFileCompat.fromSingleUri(activity, sourceUri)
|
||||||
val sourceDocFile = DocumentFileCompat.fromSingleUri(activity, sourceUri)
|
sourceDocFile.copyTo(output)
|
||||||
sourceDocFile.copyTo(targetDocFile)
|
}
|
||||||
} else {
|
|
||||||
val model: Any = if (MimeTypes.isHeic(sourceMimeType) && pageId != null) {
|
|
||||||
MultiTrackImage(activity, sourceUri, pageId)
|
|
||||||
} else if (sourceMimeType == MimeTypes.TIFF) {
|
|
||||||
TiffImage(activity, sourceUri, pageId)
|
|
||||||
} else if (sourceMimeType == MimeTypes.SVG) {
|
|
||||||
SvgImage(activity, sourceUri)
|
|
||||||
} else {
|
} else {
|
||||||
StorageUtils.getGlideSafeUri(sourceUri, sourceMimeType)
|
val model: Any = if (MimeTypes.isHeic(sourceMimeType) && pageId != null) {
|
||||||
}
|
MultiTrackImage(activity, sourceUri, pageId)
|
||||||
|
} else if (sourceMimeType == MimeTypes.TIFF) {
|
||||||
|
TiffImage(activity, sourceUri, pageId)
|
||||||
|
} else if (sourceMimeType == MimeTypes.SVG) {
|
||||||
|
SvgImage(activity, sourceUri)
|
||||||
|
} else {
|
||||||
|
StorageUtils.getGlideSafeUri(sourceUri, sourceMimeType)
|
||||||
|
}
|
||||||
|
|
||||||
// request a fresh image with the highest quality format
|
// request a fresh image with the highest quality format
|
||||||
val glideOptions = RequestOptions()
|
val glideOptions = RequestOptions()
|
||||||
.format(DecodeFormat.PREFER_ARGB_8888)
|
.format(DecodeFormat.PREFER_ARGB_8888)
|
||||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||||
.skipMemoryCache(true)
|
.skipMemoryCache(true)
|
||||||
|
|
||||||
val target = Glide.with(activity)
|
target = Glide.with(activity)
|
||||||
.asBitmap()
|
.asBitmap()
|
||||||
.apply(glideOptions)
|
.apply(glideOptions)
|
||||||
.load(model)
|
.load(model)
|
||||||
.submit(width, height)
|
.submit(width, height)
|
||||||
try {
|
|
||||||
var bitmap = target.get()
|
var bitmap = target.get()
|
||||||
if (MimeTypes.needRotationAfterGlide(sourceMimeType)) {
|
if (MimeTypes.needRotationAfterGlide(sourceMimeType)) {
|
||||||
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped)
|
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped)
|
||||||
}
|
}
|
||||||
bitmap ?: throw Exception("failed to get image for mimeType=$sourceMimeType uri=$sourceUri page=$pageId")
|
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) {
|
if (exportMimeType == MimeTypes.BMP) {
|
||||||
BmpWriter.writeRGB24(bitmap, output)
|
BmpWriter.writeRGB24(bitmap, output)
|
||||||
} else {
|
} else {
|
||||||
|
@ -232,21 +228,23 @@ abstract class ImageProvider {
|
||||||
bitmap.compress(format, quality, output)
|
bitmap.compress(format, quality, output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
// remove empty file
|
|
||||||
if (targetDocFile.exists()) {
|
|
||||||
targetDocFile.delete()
|
|
||||||
}
|
|
||||||
throw e
|
|
||||||
} finally {
|
|
||||||
Glide.with(activity).clear(target)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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")
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
|
|
|
@ -28,6 +28,7 @@ import deckers.thibault.aves.utils.StorageUtils
|
||||||
import deckers.thibault.aves.utils.StorageUtils.PathSegments
|
import deckers.thibault.aves.utils.StorageUtils.PathSegments
|
||||||
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.io.OutputStream
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import java.util.concurrent.CompletableFuture
|
import java.util.concurrent.CompletableFuture
|
||||||
import kotlin.collections.ArrayList
|
import kotlin.collections.ArrayList
|
||||||
|
@ -414,34 +415,39 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
conflictStrategy = nameConflictStrategy,
|
conflictStrategy = nameConflictStrategy,
|
||||||
) ?: return skippedFieldMap
|
) ?: return skippedFieldMap
|
||||||
|
|
||||||
return moveSingleByTreeDoc(
|
val sourceDocFile = DocumentFileCompat.fromSingleUri(activity, sourceUri)
|
||||||
|
val targetPath = createSingle(
|
||||||
activity = activity,
|
activity = activity,
|
||||||
mimeType = mimeType,
|
mimeType = mimeType,
|
||||||
sourceUri = sourceUri,
|
|
||||||
sourcePath = sourcePath,
|
|
||||||
targetDir = targetDir,
|
targetDir = targetDir,
|
||||||
targetDirDocFile = targetDirDocFile,
|
targetDirDocFile = targetDirDocFile,
|
||||||
targetNameWithoutExtension = targetNameWithoutExtension,
|
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(
|
// `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`
|
||||||
|
fun createSingle(
|
||||||
activity: Activity,
|
activity: Activity,
|
||||||
mimeType: String,
|
mimeType: String,
|
||||||
sourceUri: Uri,
|
|
||||||
sourcePath: String,
|
|
||||||
targetDir: String,
|
targetDir: String,
|
||||||
targetDirDocFile: DocumentFileCompat?,
|
targetDirDocFile: DocumentFileCompat?,
|
||||||
targetNameWithoutExtension: String,
|
targetNameWithoutExtension: String,
|
||||||
copy: Boolean
|
write: (OutputStream) -> Unit,
|
||||||
): FieldMap {
|
): String {
|
||||||
// `DocumentsContract.moveDocument()` needs `sourceParentDocumentUri`, which could be different for each entry
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && isDownloadDir(activity, targetDir)) {
|
||||||
// `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)) {
|
|
||||||
val targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}"
|
val targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}"
|
||||||
val values = ContentValues().apply {
|
val values = ContentValues().apply {
|
||||||
put(MediaStore.MediaColumns.DISPLAY_NAME, targetFileName)
|
put(MediaStore.MediaColumns.DISPLAY_NAME, targetFileName)
|
||||||
|
@ -451,10 +457,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
|
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
|
||||||
|
|
||||||
uri?.let {
|
uri?.let {
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
resolver.openOutputStream(uri)?.use(write)
|
||||||
resolver.openOutputStream(uri)?.use { output ->
|
|
||||||
source.copyTo(output)
|
|
||||||
}
|
|
||||||
values.clear()
|
values.clear()
|
||||||
values.put(MediaStore.MediaColumns.IS_PENDING, 0)
|
values.put(MediaStore.MediaColumns.IS_PENDING, 0)
|
||||||
resolver.update(uri, values, null, null)
|
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`
|
// but in order to open an output stream to it, we need to use a `SingleDocumentFile`
|
||||||
// through a document URI, not a tree URI
|
// through a document URI, not a tree URI
|
||||||
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
|
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
val targetTreeFile = targetDirDocFile.createFile(mimeType, targetNameWithoutExtension)
|
val targetTreeFile = targetDirDocFile.createFile(mimeType, targetNameWithoutExtension)
|
||||||
val targetDocFile = DocumentFileCompat.fromSingleUri(activity, targetTreeFile.uri)
|
val targetDocFile = DocumentFileCompat.fromSingleUri(activity, targetTreeFile.uri)
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
try {
|
||||||
source.copyTo(targetDocFile)
|
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:
|
// 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*
|
// - 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
|
val fileName = targetDocFile.name
|
||||||
targetDir + fileName
|
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 {
|
private fun isDownloadDir(context: Context, dirPath: String): Boolean {
|
||||||
|
|
Loading…
Reference in a new issue