From 28f7819eaf6a87c268945fd9047c7afa529f1225 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 11 Feb 2024 19:45:52 +0100 Subject: [PATCH] get result uri from edit intent --- .../deckers/thibault/aves/MainActivity.kt | 13 +++++ .../aves/channel/calls/AppAdapterHandler.kt | 19 +------ .../streams/ActivityResultStreamHandler.kt | 57 +++++++++++++++++-- lib/services/app_service.dart | 28 +++++++-- .../viewer/action/entry_action_delegate.dart | 12 +++- 5 files changed, 100 insertions(+), 29 deletions(-) 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 cb523ad4f..380e9e2c1 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt @@ -21,6 +21,7 @@ import deckers.thibault.aves.channel.calls.* import deckers.thibault.aves.channel.calls.window.ActivityWindowHandler import deckers.thibault.aves.channel.calls.window.WindowHandler import deckers.thibault.aves.channel.streams.* +import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.utils.FlutterUtils.enableSoftwareRendering import deckers.thibault.aves.utils.FlutterUtils.isSoftwareRenderingRequired import deckers.thibault.aves.utils.LogUtils @@ -218,6 +219,7 @@ open class MainActivity : FlutterFragmentActivity() { OPEN_FILE_REQUEST -> onStorageAccessResult(requestCode, data?.data) PICK_COLLECTION_FILTERS_REQUEST -> onCollectionFiltersPickResult(resultCode, data) + EDIT_REQUEST -> onEditResult(resultCode, data) } } @@ -226,6 +228,14 @@ open class MainActivity : FlutterFragmentActivity() { pendingCollectionFilterPickHandler?.let { it(filters) } } + private fun onEditResult(resultCode: Int, intent: Intent?) { + val fields: FieldMap? = if (resultCode == RESULT_OK) hashMapOf( + "uri" to intent?.data.toString(), + "mimeType" to intent?.type, + ) else null + pendingEditIntentHandler?.let { it(fields) } + } + private fun onDocumentTreeAccessResult(requestCode: Int, resultCode: Int, intent: Intent?) { val treeUri = intent?.data if (resultCode != RESULT_OK || treeUri == null) { @@ -458,6 +468,7 @@ open class MainActivity : FlutterFragmentActivity() { const val DELETE_SINGLE_PERMISSION_REQUEST = 5 const val MEDIA_WRITE_BULK_PERMISSION_REQUEST = 6 const val PICK_COLLECTION_FILTERS_REQUEST = 7 + const val EDIT_REQUEST = 8 const val INTENT_ACTION_EDIT = "edit" const val INTENT_ACTION_PICK_ITEMS = "pick_items" @@ -493,6 +504,8 @@ open class MainActivity : FlutterFragmentActivity() { var pendingCollectionFilterPickHandler: ((filters: List?) -> Unit)? = null + var pendingEditIntentHandler: ((fields: FieldMap?) -> Unit)? = null + private fun onStorageAccessResult(requestCode: Int, uri: Uri?) { Log.i(LOG_TAG, "onStorageAccessResult with requestCode=$requestCode, uri=$uri") val handler = pendingStorageAccessResultHandlers.remove(requestCode) ?: return diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt index 2ec3c761e..350c71670 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt @@ -52,7 +52,6 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { "getPackages" -> ioScope.launch { safe(call, result, ::getPackages) } "getAppIcon" -> ioScope.launch { safeSuspend(call, result, ::getAppIcon) } "copyToClipboard" -> ioScope.launch { safe(call, result, ::copyToClipboard) } - "edit" -> safe(call, result, ::edit) "open" -> safe(call, result, ::open) "openMap" -> safe(call, result, ::openMap) "setAs" -> safe(call, result, ::setAs) @@ -207,22 +206,6 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { } } - private fun edit(call: MethodCall, result: MethodChannel.Result) { - val uri = call.argument("uri")?.let { Uri.parse(it) } - val mimeType = call.argument("mimeType") - if (uri == null) { - result.error("edit-args", "missing arguments", null) - return - } - - val intent = Intent(Intent.ACTION_EDIT) - .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) - .setDataAndType(getShareableUri(context, uri), mimeType) - val started = safeStartActivity(intent) - - result.success(started) - } - private fun open(call: MethodCall, result: MethodChannel.Result) { val title = call.argument("title") val uri = call.argument("uri")?.let { Uri.parse(it) } @@ -404,6 +387,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { // on API 25, `String[]` or `ArrayList` extras are null when using the shortcut // so we use a joined `String` as fallback .putExtra(EXTRA_KEY_FILTERS_STRING, filters.joinToString(EXTRA_STRING_ARRAY_SEPARATOR)) + else -> { result.error("pin-intent", "failed to build intent", null) return @@ -434,6 +418,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { FileProvider.getUriForFile(context, authority, File(path)) } } + else -> uri } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ActivityResultStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ActivityResultStreamHandler.kt index e9301a1f9..773ef25f2 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ActivityResultStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ActivityResultStreamHandler.kt @@ -9,6 +9,7 @@ import android.os.Looper import android.util.Log import deckers.thibault.aves.MainActivity import deckers.thibault.aves.PendingStorageAccessResultHandler +import deckers.thibault.aves.channel.calls.AppAdapterHandler import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.PermissionManager @@ -47,6 +48,7 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any "requestMediaFileAccess" -> ioScope.launch { requestMediaFileAccess() } "createFile" -> ioScope.launch { createFile() } "openFile" -> ioScope.launch { openFile() } + "edit" -> edit() "pickCollectionFilters" -> pickCollectionFilters() else -> endOfStream() } @@ -100,10 +102,13 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any endOfStream() } - private suspend fun safeStartActivityForResult(intent: Intent, requestCode: Int, onGranted: (uri: Uri) -> Unit, onDenied: () -> Unit) { + private suspend fun safeStartActivityForStorageAccessResult(intent: Intent, requestCode: Int, onGranted: (uri: Uri) -> Unit, onDenied: () -> Unit) { if (intent.resolveActivity(activity.packageManager) != null) { MainActivity.pendingStorageAccessResultHandlers[requestCode] = PendingStorageAccessResultHandler(null, onGranted, onDenied) - activity.startActivityForResult(intent, requestCode) + if (!safeStartActivityForResult(intent, requestCode)) { + MainActivity.notifyError("failed to start activity for intent=$intent extras=${intent.extras}") + onDenied() + } } else { MainActivity.notifyError("failed to resolve activity for intent=$intent extras=${intent.extras}") onDenied() @@ -144,7 +149,7 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any type = mimeType putExtra(Intent.EXTRA_TITLE, name) } - safeStartActivityForResult(intent, MainActivity.CREATE_FILE_REQUEST, ::onGranted, ::onDenied) + safeStartActivityForStorageAccessResult(intent, MainActivity.CREATE_FILE_REQUEST, ::onGranted, ::onDenied) } private suspend fun openFile() { @@ -177,7 +182,33 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any addCategory(Intent.CATEGORY_OPENABLE) setTypeAndNormalize(mimeType ?: MimeTypes.ANY) } - safeStartActivityForResult(intent, MainActivity.OPEN_FILE_REQUEST, ::onGranted, ::onDenied) + safeStartActivityForStorageAccessResult(intent, MainActivity.OPEN_FILE_REQUEST, ::onGranted, ::onDenied) + } + + private fun edit() { + val uri = args["uri"] as String? + val mimeType = args["mimeType"] as String? // optional + if (uri == null) { + error("edit-args", "missing arguments", null) + return + } + + val intent = Intent(Intent.ACTION_EDIT) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + .setDataAndType(AppAdapterHandler.getShareableUri(activity, Uri.parse(uri)), mimeType) + + if (intent.resolveActivity(activity.packageManager) == null) { + error("edit-resolve", "cannot resolve activity for this intent", null) + return + } + + MainActivity.pendingEditIntentHandler = { fields -> + success(fields) + endOfStream() + } + if (!safeStartActivityForResult(intent, MainActivity.EDIT_REQUEST)) { + error("edit-start", "cannot start activity for this intent", null) + } } private fun pickCollectionFilters() { @@ -192,6 +223,24 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any activity.startActivityForResult(intent, MainActivity.PICK_COLLECTION_FILTERS_REQUEST) } + private fun safeStartActivityForResult(intent: Intent, requestCode: Int): Boolean { + return try { + activity.startActivityForResult(intent, requestCode) + true + } catch (e: SecurityException) { + if (intent.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION != 0) { + // in some environments, providing the write flag yields a `SecurityException`: + // "UID XXXX does not have permission to content://XXXX" + // so we retry without it + Log.i(LOG_TAG, "retry intent=$intent without FLAG_GRANT_WRITE_URI_PERMISSION") + intent.flags = intent.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION.inv() + safeStartActivityForResult(intent, requestCode) + } else { + false + } + } + } + override fun onCancel(arguments: Any?) { Log.i(LOG_TAG, "onCancel arguments=$arguments") } diff --git a/lib/services/app_service.dart b/lib/services/app_service.dart index 9941df2f3..0964b105d 100644 --- a/lib/services/app_service.dart +++ b/lib/services/app_service.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:aves/model/apps.dart'; import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/extensions/props.dart'; @@ -7,6 +9,7 @@ import 'package:aves/utils/math_utils.dart'; import 'package:collection/collection.dart'; import 'package:flutter/services.dart'; import 'package:latlong2/latlong.dart'; +import 'package:streams_channel/streams_channel.dart'; abstract class AppService { Future> getPackages(); @@ -15,7 +18,7 @@ abstract class AppService { Future copyToClipboard(String uri, String? label); - Future edit(String uri, String mimeType); + Future> edit(String uri, String mimeType); Future open(String uri, String mimeType, {required bool forceChooser}); @@ -32,6 +35,7 @@ abstract class AppService { class PlatformAppService implements AppService { static const _platform = MethodChannel('deckers.thibault/aves/app'); + static final _stream = StreamsChannel('deckers.thibault/aves/activity_result_stream'); static final _knownAppDirs = { 'com.kakao.talk': {'KakaoTalkDownload'}, @@ -89,17 +93,29 @@ class PlatformAppService implements AppService { } @override - Future edit(String uri, String mimeType) async { + Future> edit(String uri, String mimeType) async { try { - final result = await _platform.invokeMethod('edit', { + final completer = Completer(); + _stream.receiveBroadcastStream({ + 'op': 'edit', 'uri': uri, 'mimeType': mimeType, - }); - if (result != null) return result as bool; + }).listen( + (data) => completer.complete(data as Map?), + onError: completer.completeError, + onDone: () { + if (!completer.isCompleted) completer.complete({'error': 'cancelled'}); + }, + cancelOnError: true, + ); + // `await` here, so that `completeError` will be caught below + final result = await completer.future; + if (result == null) return {'error': 'cancelled'}; + return result.cast(); } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); + return {'error': e.code}; } - return false; } @override diff --git a/lib/widgets/viewer/action/entry_action_delegate.dart b/lib/widgets/viewer/action/entry_action_delegate.dart index 9a86970a6..c9bb0941e 100644 --- a/lib/widgets/viewer/action/entry_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_action_delegate.dart @@ -242,8 +242,16 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix ).dispatch(context); } case EntryAction.edit: - appService.edit(targetEntry.uri, targetEntry.mimeType).then((success) { - if (!success) showNoMatchingAppDialog(context); + appService.edit(targetEntry.uri, targetEntry.mimeType).then((fields) { + final error = fields['error'] as String?; + if (error == null) { + final uri = fields['uri'] as String?; + if (uri != null) { + debugPrint('TLAD uri=$uri'); + } + } else if (error == 'edit-resolve') { + showNoMatchingAppDialog(context); + } }); case EntryAction.open: appService.open(targetEntry.uri, targetEntry.mimeTypeAnySubtype, forceChooser: true).then((success) {