diff --git a/android/app/build.gradle b/android/app/build.gradle index e7ab475f7..65971d9d9 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -100,7 +100,6 @@ dependencies { implementation 'com.commonsware.cwac:document:0.4.1' implementation 'com.drewnoakes:metadata-extractor:2.15.0' implementation 'com.github.bumptech.glide:glide:4.11.0' - implementation 'com.google.guava:guava:30.0-android' kapt 'androidx.annotation:annotation:1.1.0' kapt 'com.github.bumptech.glide:compiler:4.11.0' diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt index 56cec51f5..dd0238167 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt @@ -11,6 +11,7 @@ import deckers.thibault.aves.model.provider.MediaStoreImageProvider import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlin.math.roundToInt @@ -27,9 +28,9 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { GlobalScope.launch { Glide.get(activity).clearDiskCache() } result.success(null) } - "rename" -> GlobalScope.launch { rename(call, Coresult(result)) } - "rotate" -> GlobalScope.launch { rotate(call, Coresult(result)) } - "flip" -> GlobalScope.launch { flip(call, Coresult(result)) } + "rename" -> GlobalScope.launch(Dispatchers.IO) { rename(call, Coresult(result)) } + "rotate" -> GlobalScope.launch(Dispatchers.IO) { rotate(call, Coresult(result)) } + "flip" -> GlobalScope.launch(Dispatchers.IO) { flip(call, Coresult(result)) } else -> result.notImplemented() } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index 0c3026253..e916639b0 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -16,7 +16,6 @@ import com.adobe.internal.xmp.XMPUtils import com.adobe.internal.xmp.properties.XMPPropertyInfo import com.bumptech.glide.load.resource.bitmap.TransformationUtils import com.drew.imaging.ImageMetadataReader -import com.drew.imaging.ImageProcessingException import com.drew.lang.Rational import com.drew.metadata.exif.ExifDirectoryBase import com.drew.metadata.exif.ExifIFD0Directory @@ -59,7 +58,6 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import java.io.ByteArrayOutputStream -import java.io.IOException import java.util.* import kotlin.math.roundToLong @@ -568,9 +566,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } } } - } catch (e: IOException) { - Log.w(LOG_TAG, "failed to extract xmp thumbnail", e) - } catch (e: ImageProcessingException) { + } catch (e: Exception) { Log.w(LOG_TAG, "failed to extract xmp thumbnail", e) } catch (e: NoClassDefFoundError) { Log.w(LOG_TAG, "failed to extract xmp thumbnail", e) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt index 5f473165a..ead0d7ade 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt @@ -13,10 +13,10 @@ import deckers.thibault.aves.utils.LogUtils.createTag import deckers.thibault.aves.utils.StorageUtils import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel.EventSink +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import java.util.* -import java.util.concurrent.ExecutionException class ImageOpStreamHandler(private val context: Context, private val arguments: Any?) : EventChannel.StreamHandler { private lateinit var eventSink: EventSink @@ -41,8 +41,8 @@ class ImageOpStreamHandler(private val context: Context, private val arguments: handler = Handler(Looper.getMainLooper()) when (op) { - "delete" -> GlobalScope.launch { delete() } - "move" -> GlobalScope.launch { move() } + "delete" -> GlobalScope.launch(Dispatchers.IO) { delete() } + "move" -> GlobalScope.launch(Dispatchers.IO) { move() } else -> endOfStream() } } @@ -92,7 +92,7 @@ class ImageOpStreamHandler(private val context: Context, private val arguments: endOfStream() } - private fun delete() { + private suspend fun delete() { if (entryMapList.isEmpty()) { endOfStream() return @@ -114,12 +114,9 @@ class ImageOpStreamHandler(private val context: Context, private val arguments: "uri" to uri.toString(), ) try { - provider.delete(context, uri, path).get() + provider.delete(context, uri, path) result["success"] = true - } catch (e: ExecutionException) { - Log.w(LOG_TAG, "failed to delete entry with path=$path", e) - result["success"] = false - } catch (e: InterruptedException) { + } catch (e: Exception) { Log.w(LOG_TAG, "failed to delete entry with path=$path", e) result["success"] = false } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index 38d8e5fc8..cbf6d5e2b 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -8,8 +8,6 @@ import android.provider.MediaStore import android.util.Log import androidx.exifinterface.media.ExifInterface import com.commonsware.cwac.document.DocumentFileCompat -import com.google.common.util.concurrent.Futures -import com.google.common.util.concurrent.ListenableFuture import deckers.thibault.aves.model.AvesImageEntry import deckers.thibault.aves.model.ExifOrientationOp import deckers.thibault.aves.utils.LogUtils.createTag @@ -30,8 +28,8 @@ abstract class ImageProvider { callback.onFailure(UnsupportedOperationException()) } - open fun delete(context: Context, uri: Uri, path: String?): ListenableFuture { - return Futures.immediateFailedFuture(UnsupportedOperationException()) + open suspend fun delete(context: Context, uri: Uri, path: String?) { + throw UnsupportedOperationException() } open suspend fun moveMultiple(context: Context, copy: Boolean, destinationDir: String, entries: List, callback: ImageOpCallback) { @@ -49,6 +47,7 @@ abstract class ImageProvider { val df = getDocumentFile(context, oldPath, oldMediaUri) try { + @Suppress("BlockingMethodInNonBlockingContext") val renamed = df != null && df.renameTo(newFilename) if (!renamed) { callback.onFailure(Exception("failed to rename entry at path=$oldPath")) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt index 2a92a0e26..68ca8c974 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt @@ -8,8 +8,6 @@ import android.os.Build import android.provider.MediaStore import android.util.Log import com.commonsware.cwac.document.DocumentFileCompat -import com.google.common.util.concurrent.ListenableFuture -import com.google.common.util.concurrent.SettableFuture import deckers.thibault.aves.model.AvesImageEntry import deckers.thibault.aves.model.SourceImageEntry import deckers.thibault.aves.utils.LogUtils.createTag @@ -22,9 +20,7 @@ import deckers.thibault.aves.utils.StorageUtils.getDocumentFile import deckers.thibault.aves.utils.StorageUtils.requireAccessPermission import kotlinx.coroutines.delay import java.io.File -import java.io.FileNotFoundException import java.util.* -import java.util.concurrent.ExecutionException class MediaStoreImageProvider : ImageProvider() { suspend fun fetchAll(context: Context, knownEntries: Map, handleNewEntry: NewEntryHandler) { @@ -176,41 +172,21 @@ class MediaStoreImageProvider : ImageProvider() { private fun needSize(mimeType: String) = MimeTypes.SVG != mimeType // `uri` is a media URI, not a document URI - override fun delete(context: Context, uri: Uri, path: String?): ListenableFuture { - val future = SettableFuture.create() - - if (path == null) { - future.setException(Exception("failed to delete file because path is null")) - return future - } + override suspend fun delete(context: Context, uri: Uri, path: String?) { + path ?: throw Exception("failed to delete file because path is null") if (requireAccessPermission(context, path)) { // if the file is on SD card, calling the content resolver `delete()` removes the entry from the Media Store // but it doesn't delete the file, even if the app has the permission - try { - val df = getDocumentFile(context, path, uri) - if (df != null && df.delete()) { - future.set(null) - } else { - future.setException(Exception("failed to delete file with df=$df")) - } - } catch (e: FileNotFoundException) { - future.setException(e) - } - return future + val df = getDocumentFile(context, path, uri) + + @Suppress("BlockingMethodInNonBlockingContext") + if (df != null && df.delete()) return + throw Exception("failed to delete file with df=$df") } - try { - if (context.contentResolver.delete(uri, null, null) > 0) { - future.set(null) - } else { - future.setException(Exception("failed to delete row from content provider")) - } - } catch (e: Exception) { - Log.e(LOG_TAG, "failed to delete entry", e) - future.setException(e) - } - return future + if (context.contentResolver.delete(uri, null, null) > 0) return + throw Exception("failed to delete row from content provider") } override suspend fun moveMultiple( @@ -252,14 +228,12 @@ class MediaStoreImageProvider : ImageProvider() { // - there is no documentation regarding support for usage with removable storage // - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage try { - val newFieldsFuture = moveSingleByTreeDocAndScan( + val newFields = moveSingleByTreeDocAndScan( context, sourcePath, sourceUri, destinationDir, destinationDirDocFile, mimeType, copy, ) - result["newFields"] = newFieldsFuture.get() + result["newFields"] = newFields result["success"] = true - } catch (e: ExecutionException) { - Log.w(LOG_TAG, "failed to move to destinationDir=$destinationDir entry with sourcePath=$sourcePath", e) - } catch (e: InterruptedException) { + } catch (e: Exception) { Log.w(LOG_TAG, "failed to move to destinationDir=$destinationDir entry with sourcePath=$sourcePath", e) } } @@ -275,68 +249,56 @@ class MediaStoreImageProvider : ImageProvider() { destinationDirDocFile: DocumentFileCompat, mimeType: String, copy: Boolean, - ): ListenableFuture { - val future = SettableFuture.create() - - try { - val sourceFile = File(sourcePath) - val sourceDir = sourceFile.parent?.let { ensureTrailingSeparator(it) } - if (sourceDir == destinationDir) { - if (copy) { - future.setException(Exception("file at path=$sourcePath is already in destination directory")) - } else { - future.set(HashMap()) - } - } else { - val sourceFileName = sourceFile.name - val desiredNameWithoutExtension = sourceFileName.replaceFirst("[.][^.]+$".toRegex(), "") - - // 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 destinationTreeFile = destinationDirDocFile.createFile(mimeType, desiredNameWithoutExtension) - val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri) - - // `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 destinationDirDocFile URI as `targetParentDocumentUri` - val source = DocumentFileCompat.fromSingleUri(context, sourceUri) - source.copyTo(destinationDocFile) - - // the source file name and the created document file name can be different when: - // - a file with the same name already exists, so the name gets a suffix like ` (1)` - // - the original extension does not match the extension added by the underlying provider - val fileName = destinationDocFile.name - val destinationFullPath = destinationDir + fileName - - var deletedSource = false - if (!copy) { - // delete original entry - try { - delete(context, sourceUri, sourcePath).get() - deletedSource = true - } catch (e: ExecutionException) { - Log.w(LOG_TAG, "failed to delete entry with path=$sourcePath", e) - } catch (e: InterruptedException) { - Log.w(LOG_TAG, "failed to delete entry with path=$sourcePath", e) - } - } - - try { - val fields = scanNewPath(context, destinationFullPath, mimeType) - fields["deletedSource"] = deletedSource - future.set(fields) - } catch (e: Exception) { - future.setException(e) - } - } - } catch (e: Exception) { - Log.e(LOG_TAG, "failed to ${(if (copy) "copy" else "move")} entry", e) - future.setException(e) + ): FieldMap { + val sourceFile = File(sourcePath) + val sourceDir = sourceFile.parent?.let { ensureTrailingSeparator(it) } + if (sourceDir == destinationDir) { + if (copy) throw Exception("file at path=$sourcePath is already in destination directory") + return HashMap() } - return future + val sourceFileName = sourceFile.name + val desiredNameWithoutExtension = sourceFileName.replaceFirst("[.][^.]+$".toRegex(), "") + + if (File(destinationDir, sourceFileName).exists()) { + throw Exception("file with name=$sourceFileName already exists in destination directory") + } + + // 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 + @Suppress("BlockingMethodInNonBlockingContext") + val destinationTreeFile = destinationDirDocFile.createFile(mimeType, desiredNameWithoutExtension) + val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri) + + // `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 destinationDirDocFile URI as `targetParentDocumentUri` + val source = DocumentFileCompat.fromSingleUri(context, sourceUri) + @Suppress("BlockingMethodInNonBlockingContext") + source.copyTo(destinationDocFile) + + // 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* + // - the original extension does not match the extension added by the underlying provider + val fileName = destinationDocFile.name + val destinationFullPath = destinationDir + fileName + + var deletedSource = false + if (!copy) { + // delete original entry + try { + delete(context, sourceUri, sourcePath) + deletedSource = true + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to delete entry with path=$sourcePath", e) + } + } + + return scanNewPath(context, destinationFullPath, mimeType).apply { + put("deletedSource", deletedSource) + } } companion object { diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 8585dde8a..a96334340 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -37,12 +37,6 @@ class Constants { licenseUrl: 'https://github.com/bumptech/glide/blob/master/LICENSE', sourceUrl: 'https://github.com/bumptech/glide', ), - Dependency( - name: 'Guava', - license: 'Apache 2.0', - licenseUrl: 'https://github.com/google/guava/blob/master/COPYING', - sourceUrl: 'https://github.com/google/guava', - ), Dependency( name: 'Metadata Extractor', license: 'Apache 2.0',