#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 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,7 +85,8 @@ class MainActivity : FlutterActivity() {
}
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
if (resultCode != RESULT_OK || treeUri == null) {
PermissionManager.onPermissionResult(requestCode, null)
@ -100,6 +102,13 @@ class MainActivity : FlutterActivity() {
// 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)
}
}
}
}
private fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
@ -174,5 +183,7 @@ class MainActivity : FlutterActivity() {
companion object {
private val LOG_TAG = LogUtils.createTag<MainActivity>()
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
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)
})

View file

@ -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<AvesEntry>, callback: ImageOpCallback) {
open suspend fun moveMultiple(activity: Activity, copy: Boolean, destinationDir: String, entries: List<AvesEntry>, callback: ImageOpCallback) {
callback.onFailure(UnsupportedOperationException())
}

View file

@ -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<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")
}
override suspend fun moveMultiple(
context: Context,
activity: Activity,
copy: Boolean,
destinationDir: String,
entries: List<AvesEntry>,
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<Boolean>? = null
}
}

View file

@ -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<PermissionManager>()
const val VOLUME_ACCESS_REQUEST_CODE = 1
// permission request code to pending runnable
private val pendingPermissionMap = ConcurrentHashMap<Int, PendingPermissionHandler>()
@ -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()

View file

@ -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;

View file

@ -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<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
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(
alignment: Alignment.center,
color: Colors.black,
child: Tooltip(
message: tooltip,
message: exists ? widget.tooltip : context.l10n.viewerErrorDoesNotExist,
preferBelow: false,
child: Text(
child: exists
? Text(
MimeUtils.displayType(entry.mimeType),
style: TextStyle(
color: Colors.blueGrey,
color: color,
fontSize: extent / 5,
),
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();
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,
);