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() { 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) val entries = entryMapList.map(::AvesEntry)
for (entry in entries) { for (entry in entries) {
val mimeType = entry.mimeType val mimeType = entry.mimeType
@ -119,12 +106,14 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
if (isCancelledOp()) { if (isCancelledOp()) {
result["skipped"] = true result["skipped"] = true
} else { } else {
result["success"] = false
getProvider(uri)?.let { provider ->
try { try {
provider.delete(activity, uri, path, mimeType) provider.delete(activity, uri, path, mimeType)
result["success"] = true result["success"] = true
} catch (e: Exception) { } catch (e: Exception) {
Log.w(LOG_TAG, "failed to delete entry with path=$path", e) Log.w(LOG_TAG, "failed to delete entry with path=$path", e)
result["success"] = false }
} }
} }
success(result) success(result)

View file

@ -5,6 +5,7 @@ import android.content.Context
import android.content.ContextWrapper import android.content.ContextWrapper
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import android.webkit.MimeTypeMap
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.SourceEntry import deckers.thibault.aves.model.SourceEntry
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
@ -12,12 +13,19 @@ import java.io.File
internal class FileImageProvider : ImageProvider() { internal class FileImageProvider : ImageProvider() {
override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) { override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) {
if (sourceMimeType == null) { val mimeType = if (sourceMimeType != null) {
callback.onFailure(Exception("MIME type is null for uri=$uri")) 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 return
} }
}
val entry = SourceEntry(SourceEntry.ORIGIN_FILE, uri, sourceMimeType) val entry = SourceEntry(SourceEntry.ORIGIN_FILE, uri, mimeType)
val path = uri.path val path = uri.path
if (path != null) { if (path != null) {

View file

@ -10,6 +10,7 @@ import android.net.Uri
import android.os.Binder import android.os.Binder
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import androidx.core.net.toUri
import androidx.exifinterface.media.ExifInterface 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
@ -31,10 +32,7 @@ import deckers.thibault.aves.metadata.Mp4ParserHelper.updateRotation
import deckers.thibault.aves.metadata.Mp4ParserHelper.updateXmp import deckers.thibault.aves.metadata.Mp4ParserHelper.updateXmp
import deckers.thibault.aves.metadata.PixyMetaHelper.extendedXmpDocString import deckers.thibault.aves.metadata.PixyMetaHelper.extendedXmpDocString
import deckers.thibault.aves.metadata.PixyMetaHelper.xmpDocString import deckers.thibault.aves.metadata.PixyMetaHelper.xmpDocString
import deckers.thibault.aves.model.AvesEntry import deckers.thibault.aves.model.*
import deckers.thibault.aves.model.ExifOrientationOp
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.NameConflictStrategy
import deckers.thibault.aves.utils.* import deckers.thibault.aves.utils.*
import deckers.thibault.aves.utils.FileUtils.transferFrom import deckers.thibault.aves.utils.FileUtils.transferFrom
import deckers.thibault.aves.utils.FileUtils.transferTo 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")) 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) { open suspend fun delete(contextWrapper: ContextWrapper, uri: Uri, path: String?, mimeType: String) {
throw UnsupportedOperationException("`delete` is not supported by this image provider") 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, activity = activity,
mimeType = targetMimeType, mimeType = targetMimeType,
targetDir = targetDir, targetDir = targetDir,
@ -303,7 +313,7 @@ abstract class ImageProvider {
targetNameWithoutExtension = targetNameWithoutExtension, targetNameWithoutExtension = targetNameWithoutExtension,
write = write, write = write,
) )
return mediaStoreImageProvider.scanNewPath(activity, targetPath, exportMimeType) return scanNewPath(activity, targetPath, exportMimeType)
} finally { } finally {
// clearing Glide target should happen after effectively writing the bitmap // clearing Glide target should happen after effectively writing the bitmap
Glide.with(activity).clear(target) Glide.with(activity).clear(target)
@ -422,7 +432,7 @@ abstract class ImageProvider {
val fileName = targetDocFile.name val fileName = targetDocFile.name
val targetFullPath = targetDir + fileName val targetFullPath = targetDir + fileName
val newFields = MediaStoreImageProvider().scanNewPath(contextWrapper, targetFullPath, captureMimeType) val newFields = scanNewPath(contextWrapper, targetFullPath, captureMimeType)
callback.onSuccess(newFields) callback.onSuccess(newFields)
} catch (e: Exception) { } catch (e: Exception) {
callback.onFailure(e) callback.onFailure(e)

View file

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

View file

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