#30 improved obsolete file handling
This commit is contained in:
parent
1533707aa6
commit
4612d2f4fd
8 changed files with 132 additions and 55 deletions
|
@ -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<MainActivity>()
|
||||
const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer"
|
||||
const val VOLUME_ACCESS_REQUEST = 1
|
||||
const val DELETE_PERMISSION_REQUEST = 2
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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) {
|
||||
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<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: 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
|
|
Loading…
Reference in a new issue