#30 improved obsolete file handling

This commit is contained in:
Thibault Deckers 2021-04-23 11:14:38 +09:00
parent 1533707aa6
commit 4612d2f4fd
8 changed files with 132 additions and 55 deletions

View file

@ -13,6 +13,7 @@ import androidx.core.graphics.drawable.IconCompat
import app.loup.streams_channel.StreamsChannel import app.loup.streams_channel.StreamsChannel
import deckers.thibault.aves.channel.calls.* import deckers.thibault.aves.channel.calls.*
import deckers.thibault.aves.channel.streams.* import deckers.thibault.aves.channel.streams.*
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.PermissionManager import deckers.thibault.aves.utils.PermissionManager
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
@ -84,7 +85,8 @@ class MainActivity : FlutterActivity() {
} }
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == PermissionManager.VOLUME_ACCESS_REQUEST_CODE) { when (requestCode) {
VOLUME_ACCESS_REQUEST -> {
val treeUri = data?.data val treeUri = data?.data
if (resultCode != RESULT_OK || treeUri == null) { if (resultCode != RESULT_OK || treeUri == null) {
PermissionManager.onPermissionResult(requestCode, null) PermissionManager.onPermissionResult(requestCode, null)
@ -100,6 +102,13 @@ class MainActivity : FlutterActivity() {
// resume pending action // resume pending action
PermissionManager.onPermissionResult(requestCode, treeUri) 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)
}
}
}
} }
private fun extractIntentData(intent: Intent?): MutableMap<String, Any?> { private fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
@ -174,5 +183,7 @@ class MainActivity : FlutterActivity() {
companion object { companion object {
private val LOG_TAG = LogUtils.createTag<MainActivity>() private val LOG_TAG = LogUtils.createTag<MainActivity>()
const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer" const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer"
const val VOLUME_ACCESS_REQUEST = 1
const val DELETE_PERMISSION_REQUEST = 2
} }
} }

View file

@ -1,6 +1,6 @@
package deckers.thibault.aves.channel.streams package deckers.thibault.aves.channel.streams
import android.content.Context import android.app.Activity
import android.net.Uri import android.net.Uri
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
@ -18,7 +18,7 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.* 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 eventSink: EventSink
private lateinit var handler: Handler private lateinit var handler: Handler
@ -103,7 +103,7 @@ class ImageOpStreamHandler(private val context: Context, private val arguments:
"uri" to uri.toString(), "uri" to uri.toString(),
) )
try { try {
provider.delete(context, uri, path) provider.delete(activity, uri, path)
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)
@ -138,7 +138,7 @@ class ImageOpStreamHandler(private val context: Context, private val arguments:
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir) destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
val entries = entryMapList.map(::AvesEntry) 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 onSuccess(fields: FieldMap) = success(fields)
override fun onFailure(throwable: Throwable) = error("export-failure", "failed to export entries", throwable) 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) destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
val entries = entryMapList.map(::AvesEntry) 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 onSuccess(fields: FieldMap) = success(fields)
override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable) override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable)
}) })

View file

@ -1,5 +1,6 @@
package deckers.thibault.aves.model.provider package deckers.thibault.aves.model.provider
import android.app.Activity
import android.content.ContentUris import android.content.ContentUris
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
@ -40,11 +41,11 @@ abstract class ImageProvider {
callback.onFailure(UnsupportedOperationException()) 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() throw UnsupportedOperationException()
} }
open suspend fun moveMultiple(context: Context, copy: Boolean, destinationDir: String, entries: List<AvesEntry>, callback: ImageOpCallback) { open suspend fun moveMultiple(activity: Activity, copy: Boolean, destinationDir: String, entries: List<AvesEntry>, callback: ImageOpCallback) {
callback.onFailure(UnsupportedOperationException()) callback.onFailure(UnsupportedOperationException())
} }

View file

@ -1,6 +1,8 @@
package deckers.thibault.aves.model.provider package deckers.thibault.aves.model.provider
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity
import android.app.RecoverableSecurityException
import android.content.ContentUris import android.content.ContentUris
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
@ -8,6 +10,7 @@ import android.os.Build
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Log import android.util.Log
import com.commonsware.cwac.document.DocumentFileCompat 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.AvesEntry
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.SourceEntry 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 deckers.thibault.aves.utils.UriUtils.tryParseId
import java.io.File import java.io.File
import java.util.* import java.util.*
import java.util.concurrent.CompletableFuture
import kotlin.collections.ArrayList import kotlin.collections.ArrayList
class MediaStoreImageProvider : ImageProvider() { class MediaStoreImageProvider : ImageProvider() {
@ -205,31 +209,55 @@ class MediaStoreImageProvider : ImageProvider() {
private fun needSize(mimeType: String) = MimeTypes.SVG != mimeType private fun needSize(mimeType: String) = MimeTypes.SVG != mimeType
// `uri` is a media URI, not a document URI // `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") 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 // 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 // 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") @Suppress("BlockingMethodInNonBlockingContext")
if (df != null && df.delete()) return if (df != null && df.delete()) return
throw Exception("failed to delete file with df=$df") 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<Boolean>()
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") throw Exception("failed to delete row from content provider")
} }
override suspend fun moveMultiple( override suspend fun moveMultiple(
context: Context, activity: Activity,
copy: Boolean, copy: Boolean,
destinationDir: String, destinationDir: String,
entries: List<AvesEntry>, entries: List<AvesEntry>,
callback: ImageOpCallback, callback: ImageOpCallback,
) { ) {
val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir) val destinationDirDocFile = createDirectoryIfAbsent(activity, destinationDir)
if (destinationDirDocFile == null) { if (destinationDirDocFile == null) {
callback.onFailure(Exception("failed to create directory at path=$destinationDir")) callback.onFailure(Exception("failed to create directory at path=$destinationDir"))
return return
@ -262,7 +290,7 @@ class MediaStoreImageProvider : ImageProvider() {
// - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage // - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage
try { try {
val newFields = moveSingleByTreeDocAndScan( val newFields = moveSingleByTreeDocAndScan(
context, sourcePath, sourceUri, destinationDir, destinationDirDocFile, mimeType, copy, activity, sourcePath, sourceUri, destinationDir, destinationDirDocFile, mimeType, copy,
) )
result["newFields"] = newFields result["newFields"] = newFields
result["success"] = true result["success"] = true
@ -275,7 +303,7 @@ class MediaStoreImageProvider : ImageProvider() {
} }
private suspend fun moveSingleByTreeDocAndScan( private suspend fun moveSingleByTreeDocAndScan(
context: Context, activity: Activity,
sourcePath: String, sourcePath: String,
sourceUri: Uri, sourceUri: Uri,
destinationDir: String, destinationDir: String,
@ -303,12 +331,12 @@ class MediaStoreImageProvider : ImageProvider() {
// 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") @Suppress("BlockingMethodInNonBlockingContext")
val destinationTreeFile = destinationDirDocFile.createFile(mimeType, desiredNameWithoutExtension) 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.moveDocument()` needs `sourceParentDocumentUri`, which could be different for each entry
// `DocumentsContract.copyDocument()` yields "Unsupported call: android:copyDocument" // `DocumentsContract.copyDocument()` yields "Unsupported call: android:copyDocument"
// when used with entry URI as `sourceDocumentUri`, and destinationDirDocFile URI as `targetParentDocumentUri` // 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") @Suppress("BlockingMethodInNonBlockingContext")
source.copyTo(destinationDocFile) source.copyTo(destinationDocFile)
@ -322,14 +350,14 @@ class MediaStoreImageProvider : ImageProvider() {
if (!copy) { if (!copy) {
// delete original entry // delete original entry
try { try {
delete(context, sourceUri, sourcePath) delete(activity, sourceUri, sourcePath)
deletedSource = true deletedSource = true
} catch (e: Exception) { } catch (e: Exception) {
Log.w(LOG_TAG, "failed to delete entry with path=$sourcePath", e) 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) put("deletedSource", deletedSource)
} }
} }
@ -366,6 +394,8 @@ class MediaStoreImageProvider : ImageProvider() {
MediaStore.MediaColumns.ORIENTATION, MediaStore.MediaColumns.ORIENTATION,
) else emptyArray() ) else emptyArray()
) )
var pendingDeleteCompleter: CompletableFuture<Boolean>? = null
} }
} }

View file

@ -9,6 +9,7 @@ import android.os.Environment
import android.os.storage.StorageManager import android.os.storage.StorageManager
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import deckers.thibault.aves.MainActivity.Companion.VOLUME_ACCESS_REQUEST
import deckers.thibault.aves.utils.StorageUtils.PathSegments import deckers.thibault.aves.utils.StorageUtils.PathSegments
import java.io.File import java.io.File
import java.util.* import java.util.*
@ -18,8 +19,6 @@ import kotlin.collections.ArrayList
object PermissionManager { object PermissionManager {
private val LOG_TAG = LogUtils.createTag<PermissionManager>() private val LOG_TAG = LogUtils.createTag<PermissionManager>()
const val VOLUME_ACCESS_REQUEST_CODE = 1
// permission request code to pending runnable // permission request code to pending runnable
private val pendingPermissionMap = ConcurrentHashMap<Int, PendingPermissionHandler>() private val pendingPermissionMap = ConcurrentHashMap<Int, PendingPermissionHandler>()
@ -39,8 +38,8 @@ object PermissionManager {
} }
if (intent.resolveActivity(activity.packageManager) != null) { if (intent.resolveActivity(activity.packageManager) != null) {
pendingPermissionMap[VOLUME_ACCESS_REQUEST_CODE] = PendingPermissionHandler(path, onGranted, onDenied) pendingPermissionMap[VOLUME_ACCESS_REQUEST] = PendingPermissionHandler(path, onGranted, onDenied)
activity.startActivityForResult(intent, VOLUME_ACCESS_REQUEST_CODE, null) activity.startActivityForResult(intent, VOLUME_ACCESS_REQUEST, null)
} else { } else {
Log.e(LOG_TAG, "failed to resolve activity for intent=$intent") Log.e(LOG_TAG, "failed to resolve activity for intent=$intent")
onDenied() onDenied()

View file

@ -8,6 +8,7 @@ class AIcons {
static const IconData vector = Icons.code_outlined; static const IconData vector = Icons.code_outlined;
static const IconData android = Icons.android; static const IconData android = Icons.android;
static const IconData broken = Icons.broken_image_outlined;
static const IconData checked = Icons.done_outlined; static const IconData checked = Icons.done_outlined;
static const IconData date = Icons.calendar_today_outlined; static const IconData date = Icons.calendar_today_outlined;
static const IconData disc = Icons.fiber_manual_record; static const IconData disc = Icons.fiber_manual_record;

View file

@ -1,8 +1,13 @@
import 'dart:io';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/mime_utils.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'; import 'package:flutter/material.dart';
class ErrorThumbnail extends StatelessWidget { class ErrorThumbnail extends StatefulWidget {
final AvesEntry entry; final AvesEntry entry;
final double extent; final double extent;
final String tooltip; final String tooltip;
@ -13,23 +18,53 @@ class ErrorThumbnail extends StatelessWidget {
@required this.tooltip, @required this.tooltip,
}); });
@override
_ErrorThumbnailState createState() => _ErrorThumbnailState();
}
class _ErrorThumbnailState extends State<ErrorThumbnail> {
Future<bool> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final color = Colors.blueGrey;
return FutureBuilder<bool>(
future: _exists,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) return SizedBox();
final exists = snapshot.data;
return Container( return Container(
alignment: Alignment.center, alignment: Alignment.center,
color: Colors.black, color: Colors.black,
child: Tooltip( child: Tooltip(
message: tooltip, message: exists ? widget.tooltip : context.l10n.viewerErrorDoesNotExist,
preferBelow: false, preferBelow: false,
child: Text( child: exists
? Text(
MimeUtils.displayType(entry.mimeType), MimeUtils.displayType(entry.mimeType),
style: TextStyle( style: TextStyle(
color: Colors.blueGrey, color: color,
fontSize: extent / 5, fontSize: extent / 5,
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
)
: Icon(
AIcons.broken,
size: extent / 2,
color: color,
), ),
), ),
); );
});
} }
} }

View file

@ -45,7 +45,7 @@ class _ErrorViewState extends State<ErrorView> {
if (snapshot.connectionState != ConnectionState.done) return SizedBox(); if (snapshot.connectionState != ConnectionState.done) return SizedBox();
final exists = snapshot.data; final exists = snapshot.data;
return EmptyContent( return EmptyContent(
icon: AIcons.error, icon: exists ? AIcons.error : AIcons.broken,
text: exists ? context.l10n.viewerErrorUnknown : context.l10n.viewerErrorDoesNotExist, text: exists ? context.l10n.viewerErrorUnknown : context.l10n.viewerErrorDoesNotExist,
alignment: Alignment.center, alignment: Alignment.center,
); );