diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt index f1fc537dd..b3d63d1bc 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt @@ -13,6 +13,7 @@ import androidx.core.graphics.drawable.IconCompat import app.loup.streams_channel.StreamsChannel import deckers.thibault.aves.channel.calls.* import deckers.thibault.aves.channel.streams.* +import deckers.thibault.aves.model.provider.MediaStoreImageProvider import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.PermissionManager import io.flutter.embedding.android.FlutterActivity @@ -84,21 +85,29 @@ class MainActivity : FlutterActivity() { } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - if (requestCode == PermissionManager.VOLUME_ACCESS_REQUEST_CODE) { - val treeUri = data?.data - if (resultCode != RESULT_OK || treeUri == null) { - PermissionManager.onPermissionResult(requestCode, null) - return + when (requestCode) { + VOLUME_ACCESS_REQUEST -> { + val treeUri = data?.data + if (resultCode != RESULT_OK || treeUri == null) { + PermissionManager.onPermissionResult(requestCode, null) + return + } + + // save access permissions across reboots + val takeFlags = (data.flags + and (Intent.FLAG_GRANT_READ_URI_PERMISSION + or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)) + contentResolver.takePersistableUriPermission(treeUri, takeFlags) + + // resume pending action + PermissionManager.onPermissionResult(requestCode, treeUri) + } + DELETE_PERMISSION_REQUEST -> { + // delete permission may be requested on Android 10+ only + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + MediaStoreImageProvider.pendingDeleteCompleter?.complete(resultCode == RESULT_OK) + } } - - // save access permissions across reboots - val takeFlags = (data.flags - and (Intent.FLAG_GRANT_READ_URI_PERMISSION - or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)) - contentResolver.takePersistableUriPermission(treeUri, takeFlags) - - // resume pending action - PermissionManager.onPermissionResult(requestCode, treeUri) } } @@ -174,5 +183,7 @@ class MainActivity : FlutterActivity() { companion object { private val LOG_TAG = LogUtils.createTag() const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer" + const val VOLUME_ACCESS_REQUEST = 1 + const val DELETE_PERMISSION_REQUEST = 2 } } \ No newline at end of file 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 bc58d75d4..25f872da4 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 @@ -1,6 +1,6 @@ package deckers.thibault.aves.channel.streams -import android.content.Context +import android.app.Activity import android.net.Uri import android.os.Handler import android.os.Looper @@ -18,7 +18,7 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import java.util.* -class ImageOpStreamHandler(private val context: Context, private val arguments: Any?) : EventChannel.StreamHandler { +class ImageOpStreamHandler(private val activity: Activity, private val arguments: Any?) : EventChannel.StreamHandler { private lateinit var eventSink: EventSink private lateinit var handler: Handler @@ -103,7 +103,7 @@ class ImageOpStreamHandler(private val context: Context, private val arguments: "uri" to uri.toString(), ) try { - provider.delete(context, uri, path) + provider.delete(activity, uri, path) result["success"] = true } catch (e: Exception) { Log.w(LOG_TAG, "failed to delete entry with path=$path", e) @@ -138,7 +138,7 @@ class ImageOpStreamHandler(private val context: Context, private val arguments: destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir) val entries = entryMapList.map(::AvesEntry) - provider.exportMultiple(context, mimeType, destinationDir, entries, object : ImageOpCallback { + provider.exportMultiple(activity, mimeType, destinationDir, entries, object : ImageOpCallback { override fun onSuccess(fields: FieldMap) = success(fields) override fun onFailure(throwable: Throwable) = error("export-failure", "failed to export entries", throwable) }) @@ -168,7 +168,7 @@ class ImageOpStreamHandler(private val context: Context, private val arguments: destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir) val entries = entryMapList.map(::AvesEntry) - provider.moveMultiple(context, copy, destinationDir, entries, object : ImageOpCallback { + provider.moveMultiple(activity, copy, destinationDir, entries, object : ImageOpCallback { override fun onSuccess(fields: FieldMap) = success(fields) override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable) }) 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 539c47dd6..5a6494bd7 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 @@ -1,5 +1,6 @@ package deckers.thibault.aves.model.provider +import android.app.Activity import android.content.ContentUris import android.content.Context import android.graphics.Bitmap @@ -40,11 +41,11 @@ abstract class ImageProvider { callback.onFailure(UnsupportedOperationException()) } - open suspend fun delete(context: Context, uri: Uri, path: String?) { + open suspend fun delete(activity: Activity, uri: Uri, path: String?) { throw UnsupportedOperationException() } - open suspend fun moveMultiple(context: Context, copy: Boolean, destinationDir: String, entries: List, callback: ImageOpCallback) { + open suspend fun moveMultiple(activity: Activity, copy: Boolean, destinationDir: String, entries: List, callback: ImageOpCallback) { callback.onFailure(UnsupportedOperationException()) } 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 13a093ae2..6f386f116 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 @@ -1,6 +1,8 @@ package deckers.thibault.aves.model.provider import android.annotation.SuppressLint +import android.app.Activity +import android.app.RecoverableSecurityException import android.content.ContentUris import android.content.Context import android.net.Uri @@ -8,6 +10,7 @@ import android.os.Build import android.provider.MediaStore import android.util.Log import com.commonsware.cwac.document.DocumentFileCompat +import deckers.thibault.aves.MainActivity.Companion.DELETE_PERMISSION_REQUEST import deckers.thibault.aves.model.AvesEntry import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.SourceEntry @@ -22,6 +25,7 @@ import deckers.thibault.aves.utils.StorageUtils.requireAccessPermission import deckers.thibault.aves.utils.UriUtils.tryParseId import java.io.File import java.util.* +import java.util.concurrent.CompletableFuture import kotlin.collections.ArrayList class MediaStoreImageProvider : ImageProvider() { @@ -205,31 +209,55 @@ class MediaStoreImageProvider : ImageProvider() { private fun needSize(mimeType: String) = MimeTypes.SVG != mimeType // `uri` is a media URI, not a document URI - override suspend fun delete(context: Context, uri: Uri, path: String?) { + override suspend fun delete(activity: Activity, uri: Uri, path: String?) { path ?: throw Exception("failed to delete file because path is null") - if (File(path).exists() && requireAccessPermission(context, path)) { + if (File(path).exists() && requireAccessPermission(activity, 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 - val df = getDocumentFile(context, path, uri) + val df = getDocumentFile(activity, path, uri) @Suppress("BlockingMethodInNonBlockingContext") if (df != null && df.delete()) return throw Exception("failed to delete file with df=$df") } - if (context.contentResolver.delete(uri, null, null) > 0) return + try { + if (activity.contentResolver.delete(uri, null, null) > 0) return + } catch (securityException: SecurityException) { + // even if the app has access permission granted on the containing directory, + // the delete request may yield a `RecoverableSecurityException` on Android 10+ + // when the underlying file no longer exists and this is an orphaned entry in the Media Store + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val rse = securityException as? RecoverableSecurityException ?: throw securityException + val intentSender = rse.userAction.actionIntent.intentSender + + // request user permission for this item + pendingDeleteCompleter = CompletableFuture() + activity.startIntentSenderForResult(intentSender, DELETE_PERMISSION_REQUEST, null, 0, 0, 0, null) + val granted = pendingDeleteCompleter!!.join() + + pendingDeleteCompleter = null + if (granted) { + delete(activity, uri, path) + } else { + throw Exception("failed to get delete permission") + } + } else { + throw securityException + } + } throw Exception("failed to delete row from content provider") } override suspend fun moveMultiple( - context: Context, + activity: Activity, copy: Boolean, destinationDir: String, entries: List, callback: ImageOpCallback, ) { - val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir) + val destinationDirDocFile = createDirectoryIfAbsent(activity, destinationDir) if (destinationDirDocFile == null) { callback.onFailure(Exception("failed to create directory at path=$destinationDir")) return @@ -262,7 +290,7 @@ class MediaStoreImageProvider : ImageProvider() { // - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage try { val newFields = moveSingleByTreeDocAndScan( - context, sourcePath, sourceUri, destinationDir, destinationDirDocFile, mimeType, copy, + activity, sourcePath, sourceUri, destinationDir, destinationDirDocFile, mimeType, copy, ) result["newFields"] = newFields result["success"] = true @@ -275,7 +303,7 @@ class MediaStoreImageProvider : ImageProvider() { } private suspend fun moveSingleByTreeDocAndScan( - context: Context, + activity: Activity, sourcePath: String, sourceUri: Uri, destinationDir: String, @@ -303,12 +331,12 @@ class MediaStoreImageProvider : ImageProvider() { // 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) + val destinationDocFile = DocumentFileCompat.fromSingleUri(activity, 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) + val source = DocumentFileCompat.fromSingleUri(activity, sourceUri) @Suppress("BlockingMethodInNonBlockingContext") source.copyTo(destinationDocFile) @@ -322,14 +350,14 @@ class MediaStoreImageProvider : ImageProvider() { if (!copy) { // delete original entry try { - delete(context, sourceUri, sourcePath) + delete(activity, 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 { + return scanNewPath(activity, destinationFullPath, mimeType).apply { put("deletedSource", deletedSource) } } @@ -366,6 +394,8 @@ class MediaStoreImageProvider : ImageProvider() { MediaStore.MediaColumns.ORIENTATION, ) else emptyArray() ) + + var pendingDeleteCompleter: CompletableFuture? = null } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt index 7f55c79af..83c9692ab 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt @@ -9,6 +9,7 @@ import android.os.Environment import android.os.storage.StorageManager import android.util.Log import androidx.annotation.RequiresApi +import deckers.thibault.aves.MainActivity.Companion.VOLUME_ACCESS_REQUEST import deckers.thibault.aves.utils.StorageUtils.PathSegments import java.io.File import java.util.* @@ -18,8 +19,6 @@ import kotlin.collections.ArrayList object PermissionManager { private val LOG_TAG = LogUtils.createTag() - const val VOLUME_ACCESS_REQUEST_CODE = 1 - // permission request code to pending runnable private val pendingPermissionMap = ConcurrentHashMap() @@ -39,8 +38,8 @@ object PermissionManager { } if (intent.resolveActivity(activity.packageManager) != null) { - pendingPermissionMap[VOLUME_ACCESS_REQUEST_CODE] = PendingPermissionHandler(path, onGranted, onDenied) - activity.startActivityForResult(intent, VOLUME_ACCESS_REQUEST_CODE, null) + pendingPermissionMap[VOLUME_ACCESS_REQUEST] = PendingPermissionHandler(path, onGranted, onDenied) + activity.startActivityForResult(intent, VOLUME_ACCESS_REQUEST, null) } else { Log.e(LOG_TAG, "failed to resolve activity for intent=$intent") onDenied() diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index b4a14c7cf..d939e66a6 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -8,6 +8,7 @@ class AIcons { static const IconData vector = Icons.code_outlined; static const IconData android = Icons.android; + static const IconData broken = Icons.broken_image_outlined; static const IconData checked = Icons.done_outlined; static const IconData date = Icons.calendar_today_outlined; static const IconData disc = Icons.fiber_manual_record; diff --git a/lib/widgets/collection/thumbnail/error.dart b/lib/widgets/collection/thumbnail/error.dart index 6b531adeb..b28e8fef9 100644 --- a/lib/widgets/collection/thumbnail/error.dart +++ b/lib/widgets/collection/thumbnail/error.dart @@ -1,8 +1,13 @@ +import 'dart:io'; + import 'package:aves/model/entry.dart'; +import 'package:aves/theme/icons.dart'; import 'package:aves/utils/mime_utils.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -class ErrorThumbnail extends StatelessWidget { +class ErrorThumbnail extends StatefulWidget { final AvesEntry entry; final double extent; final String tooltip; @@ -13,23 +18,53 @@ class ErrorThumbnail extends StatelessWidget { @required this.tooltip, }); + @override + _ErrorThumbnailState createState() => _ErrorThumbnailState(); +} + +class _ErrorThumbnailState extends State { + Future _exists; + + AvesEntry get entry => widget.entry; + + double get extent => widget.extent; + + @override + void initState() { + super.initState(); + _exists = entry.path != null ? File(entry.path).exists() : SynchronousFuture(true); + } + @override Widget build(BuildContext context) { - return Container( - alignment: Alignment.center, - color: Colors.black, - child: Tooltip( - message: tooltip, - preferBelow: false, - child: Text( - MimeUtils.displayType(entry.mimeType), - style: TextStyle( - color: Colors.blueGrey, - fontSize: extent / 5, - ), - textAlign: TextAlign.center, - ), - ), - ); + final color = Colors.blueGrey; + return FutureBuilder( + future: _exists, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) return SizedBox(); + final exists = snapshot.data; + return Container( + alignment: Alignment.center, + color: Colors.black, + child: Tooltip( + message: exists ? widget.tooltip : context.l10n.viewerErrorDoesNotExist, + preferBelow: false, + child: exists + ? Text( + MimeUtils.displayType(entry.mimeType), + style: TextStyle( + color: color, + fontSize: extent / 5, + ), + textAlign: TextAlign.center, + ) + : Icon( + AIcons.broken, + size: extent / 2, + color: color, + ), + ), + ); + }); } } diff --git a/lib/widgets/viewer/visual/error.dart b/lib/widgets/viewer/visual/error.dart index 34d3cf97b..1cbcc81ce 100644 --- a/lib/widgets/viewer/visual/error.dart +++ b/lib/widgets/viewer/visual/error.dart @@ -45,7 +45,7 @@ class _ErrorViewState extends State { if (snapshot.connectionState != ConnectionState.done) return SizedBox(); final exists = snapshot.data; return EmptyContent( - icon: AIcons.error, + icon: exists ? AIcons.error : AIcons.broken, text: exists ? context.l10n.viewerErrorUnknown : context.l10n.viewerErrorDoesNotExist, alignment: Alignment.center, );