vault: fixed export to vault

This commit is contained in:
Thibault Deckers 2023-02-22 12:39:48 +01:00
parent cbf555a31f
commit d96a067f18
6 changed files with 157 additions and 86 deletions

View file

@ -92,19 +92,6 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
}
private suspend fun delete() {
if (entryMapList.isEmpty()) {
endOfStream()
return
}
// assume same provider for all entries
val firstEntry = entryMapList.first()
val provider = (firstEntry["uri"] as String?)?.let { Uri.parse(it) }?.let { getProvider(it) }
if (provider == null) {
error("delete-provider", "failed to find provider for entry=$firstEntry", null)
return
}
val entries = entryMapList.map(::AvesEntry)
for (entry in entries) {
val mimeType = entry.mimeType
@ -119,12 +106,14 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
if (isCancelledOp()) {
result["skipped"] = true
} else {
result["success"] = false
getProvider(uri)?.let { provider ->
try {
provider.delete(activity, uri, path, mimeType)
result["success"] = true
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to delete entry with path=$path", e)
result["success"] = false
}
}
}
success(result)

View file

@ -5,6 +5,7 @@ import android.content.Context
import android.content.ContextWrapper
import android.net.Uri
import android.util.Log
import android.webkit.MimeTypeMap
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.SourceEntry
import deckers.thibault.aves.utils.LogUtils
@ -12,12 +13,19 @@ import java.io.File
internal class FileImageProvider : ImageProvider() {
override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) {
if (sourceMimeType == null) {
callback.onFailure(Exception("MIME type is null for uri=$uri"))
val mimeType = if (sourceMimeType != null) {
sourceMimeType
} else {
val fromExtension = MimeTypeMap.getFileExtensionFromUrl(uri.toString())
if (fromExtension != null) {
fromExtension
} else {
callback.onFailure(Exception("MIME type was not provided and cannot be guessed from extension of uri=$uri"))
return
}
}
val entry = SourceEntry(SourceEntry.ORIGIN_FILE, uri, sourceMimeType)
val entry = SourceEntry(SourceEntry.ORIGIN_FILE, uri, mimeType)
val path = uri.path
if (path != null) {

View file

@ -10,6 +10,7 @@ import android.net.Uri
import android.os.Binder
import android.os.Build
import android.util.Log
import androidx.core.net.toUri
import androidx.exifinterface.media.ExifInterface
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat
@ -31,10 +32,7 @@ import deckers.thibault.aves.metadata.Mp4ParserHelper.updateRotation
import deckers.thibault.aves.metadata.Mp4ParserHelper.updateXmp
import deckers.thibault.aves.metadata.PixyMetaHelper.extendedXmpDocString
import deckers.thibault.aves.metadata.PixyMetaHelper.xmpDocString
import deckers.thibault.aves.model.AvesEntry
import deckers.thibault.aves.model.ExifOrientationOp
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.NameConflictStrategy
import deckers.thibault.aves.model.*
import deckers.thibault.aves.utils.*
import deckers.thibault.aves.utils.FileUtils.transferFrom
import deckers.thibault.aves.utils.FileUtils.transferTo
@ -53,6 +51,19 @@ abstract class ImageProvider {
callback.onFailure(UnsupportedOperationException("`fetchSingle` is not supported by this image provider"))
}
suspend fun scanNewPath(context: Context, path: String, mimeType: String): FieldMap {
return if (StorageUtils.isInVault(context, path)) {
hashMapOf(
"origin" to SourceEntry.ORIGIN_VAULT,
"uri" to File(path).toUri().toString(),
"contentId" to null,
"path" to path,
)
} else {
MediaStoreImageProvider().scanNewPathByMediaStore(context, path, mimeType)
}
}
open suspend fun delete(contextWrapper: ContextWrapper, uri: Uri, path: String?, mimeType: String) {
throw UnsupportedOperationException("`delete` is not supported by this image provider")
}
@ -294,8 +305,7 @@ abstract class ImageProvider {
}
}
val mediaStoreImageProvider = MediaStoreImageProvider()
val targetPath = mediaStoreImageProvider.createSingle(
val targetPath = MediaStoreImageProvider().createSingle(
activity = activity,
mimeType = targetMimeType,
targetDir = targetDir,
@ -303,7 +313,7 @@ abstract class ImageProvider {
targetNameWithoutExtension = targetNameWithoutExtension,
write = write,
)
return mediaStoreImageProvider.scanNewPath(activity, targetPath, exportMimeType)
return scanNewPath(activity, targetPath, exportMimeType)
} finally {
// clearing Glide target should happen after effectively writing the bitmap
Glide.with(activity).clear(target)
@ -422,7 +432,7 @@ abstract class ImageProvider {
val fileName = targetDocFile.name
val targetFullPath = targetDir + fileName
val newFields = MediaStoreImageProvider().scanNewPath(contextWrapper, targetFullPath, captureMimeType)
val newFields = scanNewPath(contextWrapper, targetFullPath, captureMimeType)
callback.onSuccess(newFields)
} catch (e: Exception) {
callback.onFailure(e)

View file

@ -9,7 +9,7 @@ import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.util.Log
import androidx.core.net.toUri
import androidx.annotation.RequiresApi
import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.MainActivity
import deckers.thibault.aves.MainActivity.Companion.DELETE_SINGLE_PERMISSION_REQUEST
@ -30,6 +30,7 @@ import deckers.thibault.aves.utils.UriUtils.tryParseId
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import java.io.File
import java.io.FileOutputStream
import java.io.OutputStream
import java.io.SyncFailedException
import java.util.*
@ -474,7 +475,6 @@ class MediaStoreImageProvider : ImageProvider() {
mimeType = mimeType,
copy = copy,
toBin = toBin,
toVault = toVault,
)
}
}
@ -501,7 +501,6 @@ class MediaStoreImageProvider : ImageProvider() {
mimeType: String,
copy: Boolean,
toBin: Boolean,
toVault: Boolean,
): FieldMap {
val sourcePath = sourceFile.path
val sourceDir = sourceFile.parent?.let { ensureTrailingSeparator(it) }
@ -550,21 +549,11 @@ class MediaStoreImageProvider : ImageProvider() {
"trashed" to true,
"trashPath" to targetPath,
)
} else if (toVault) {
hashMapOf(
"origin" to SourceEntry.ORIGIN_VAULT,
"uri" to File(targetPath).toUri().toString(),
"contentId" to null,
"path" to targetPath,
)
} else {
scanNewPath(activity, targetPath, mimeType)
}
}
// `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,
mimeType: String,
@ -573,14 +562,57 @@ class MediaStoreImageProvider : ImageProvider() {
targetNameWithoutExtension: String,
write: (OutputStream) -> Unit,
): String {
if (StorageUtils.isInVault(activity, targetDir)) {
return insertByFile(
targetDir = targetDir,
targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}",
write = write,
)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val downloadDirPath = StorageUtils.getDownloadDirPath(activity, targetDir)
val isDownloadSubdir = downloadDirPath != null && targetDir.startsWith(downloadDirPath)
if (isDownloadSubdir) {
return insertByMediaStore(
activity = activity,
targetDir = targetDir,
targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}",
write = write,
)
}
}
return insertByTreeDoc(
activity = activity,
mimeType = mimeType,
targetDir = targetDir,
targetDirDocFile = targetDirDocFile,
targetNameWithoutExtension = targetNameWithoutExtension,
write = write,
)
}
private fun insertByFile(
targetDir: String,
targetFileName: String,
write: (OutputStream) -> Unit,
): String {
val file = File(targetDir, targetFileName)
FileOutputStream(file).use(write)
return file.path
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun insertByMediaStore(
activity: Activity,
targetDir: String,
targetFileName: String,
write: (OutputStream) -> Unit,
): String {
val volumePath = StorageUtils.getVolumePath(activity, targetDir)
val relativePath = targetDir.substring(volumePath?.length ?: 0)
val targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}"
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, targetFileName)
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
@ -598,8 +630,18 @@ class MediaStoreImageProvider : ImageProvider() {
return File(targetDir, targetFileName).path
}
}
// `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`
private fun insertByTreeDoc(
activity: Activity,
mimeType: String,
targetDir: String,
targetDirDocFile: DocumentFileCompat?,
targetNameWithoutExtension: String,
write: (OutputStream) -> Unit,
): String {
targetDirDocFile ?: throw Exception("failed to get tree doc for directory at path=$targetDir")
// the file created from a `TreeDocumentFile` is also a `TreeDocumentFile`
@ -670,7 +712,7 @@ class MediaStoreImageProvider : ImageProvider() {
}
// URI should not change
return scanNewPath(activity, newFile.path, mimeType)
return scanNewPathByMediaStore(activity, newFile.path, mimeType)
}
private suspend fun renameSingleByTreeDoc(
@ -690,7 +732,7 @@ class MediaStoreImageProvider : ImageProvider() {
throw Exception("failed to rename document at path=$oldPath")
}
scanObsoletePath(activity, oldMediaUri, oldPath, mimeType)
return scanNewPath(activity, newFile.path, mimeType)
return scanNewPathByMediaStore(activity, newFile.path, mimeType)
}
private suspend fun renameSingleByFile(
@ -706,7 +748,7 @@ class MediaStoreImageProvider : ImageProvider() {
throw Exception("failed to rename file at path=$oldPath")
}
scanObsoletePath(activity, oldMediaUri, oldPath, mimeType)
return scanNewPath(activity, newFile.path, mimeType)
return scanNewPathByMediaStore(activity, newFile.path, mimeType)
}
override fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: FieldMap, callback: ImageOpCallback) {
@ -757,10 +799,23 @@ class MediaStoreImageProvider : ImageProvider() {
}
}
suspend fun scanNewPath(context: Context, path: String, mimeType: String): FieldMap =
suspendCoroutine { cont -> tryScanNewPath(context, path = path, mimeType = mimeType, cont) }
suspend fun scanNewPathByMediaStore(context: Context, path: String, mimeType: String): FieldMap =
suspendCoroutine { cont ->
tryScanNewPathByMediaStore(
context = context,
path = path,
mimeType = mimeType,
cont = cont,
)
}
private fun tryScanNewPath(context: Context, path: String, mimeType: String, cont: Continuation<FieldMap>, iteration: Int = 0) {
private fun tryScanNewPathByMediaStore(
context: Context,
path: String,
mimeType: String,
cont: Continuation<FieldMap>,
iteration: Int = 0,
) {
// `scanFile` may (e.g. when copying to SD card on Android 10 (API 29)):
// 1) yield no URI,
// 2) yield a temporary URI that fails when queried,
@ -832,7 +887,7 @@ class MediaStoreImageProvider : ImageProvider() {
}
}
tryScanNewPath(context, path = path, mimeType = mimeType, cont, iteration + 1)
tryScanNewPathByMediaStore(context, path = path, mimeType = mimeType, cont, iteration + 1)
}
}

View file

@ -70,7 +70,7 @@ object MimeTypes {
fun isRaw(mimeType: String): Boolean {
return when (mimeType) {
ARW, CR2, DNG, NEF, NRW, ORF, PEF, RAF, RW2, SRW -> true
ARW, CR2, CRW, DCR, DNG, ERF, K25, KDC, MRW, NEF, NRW, ORF, PEF, RAF, RAW, RW2, SR2, SRF, SRW, X3F -> true
else -> false
}
}

View file

@ -105,7 +105,7 @@ class MediaStoreSource extends CollectionSource {
// with items that may be hidden right away because of their metadata
addEntries(knownEntries, notify: false);
await _addVaultEntries(directory);
await _loadVaultEntries(directory);
debugPrint('$runtimeType refresh ${stopwatch.elapsed} load metadata');
if (directory != null) {
@ -266,6 +266,13 @@ class MediaStoreSource extends CollectionSource {
}
}
await _refreshVaultEntries(
changedUris: changedUris.where(vaults.isVaultEntryUri).toSet(),
newEntries: newEntries,
entriesToRefresh: entriesToRefresh,
existingDirectories: existingDirectories,
);
invalidateAlbumFilterSummary(directories: existingDirectories);
if (newEntries.isNotEmpty) {
@ -278,21 +285,21 @@ class MediaStoreSource extends CollectionSource {
await refreshEntries(entriesToRefresh, EntryDataType.values.toSet());
}
await _refreshVaultEntries(changedUris.where(vaults.isVaultEntryUri).toSet());
return tempUris;
}
// vault
Future<void> _addVaultEntries(String? directory) async {
Future<void> _loadVaultEntries(String? directory) async {
addEntries(await metadataDb.loadEntries(origin: EntryOrigins.vault, directory: directory));
}
Future<void> _refreshVaultEntries(Set<String> changedUris) async {
final entriesToRefresh = <AvesEntry>{};
final existingDirectories = <String>{};
Future<void> _refreshVaultEntries({
required Set<String> changedUris,
required Set<AvesEntry> newEntries,
required Set<AvesEntry> entriesToRefresh,
required Set<String> existingDirectories,
}) async {
for (final uri in changedUris) {
final existingEntry = allEntries.firstWhereOrNull((entry) => entry.uri == uri);
if (existingEntry != null) {
@ -301,13 +308,15 @@ class MediaStoreSource extends CollectionSource {
if (existingDirectory != null) {
existingDirectories.add(existingDirectory);
}
} else {
final sourceEntry = await mediaFetchService.getEntry(uri, null);
if (sourceEntry != null) {
newEntries.add(sourceEntry.copyWith(
id: metadataDb.nextId,
origin: EntryOrigins.vault,
));
}
}
invalidateAlbumFilterSummary(directories: existingDirectories);
if (entriesToRefresh.isNotEmpty) {
await refreshEntries(entriesToRefresh, EntryDataType.values.toSet());
}
}
}