superlistapp / super_native_extensions

Native drag & drop, clipboard access and context menu.
MIT License
446 stars 78 forks source link

[super_clipboard] App crashes on Android after copying an image and restart the app #435

Open EchoEllet opened 1 week ago

EchoEllet commented 1 week ago

This is an issue when using super_clipbaord however the native platform code is in super_native_extensions.

Steps to reproduce:

  1. Run the app on Android, copy an image from another app while the app is running.
  2. Paste it into the app, it should succeed without any issues.
  3. Refrain from copying anything to the clipboard after that.
  4. Close the app completely and then start it again (using flutter run), you might get a crash.
  5. If you didn't get any crashes, try to paste the same image from step 1.
Crash Log ```console Shutting down VM E/flutter ( 7331): [ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled Exception: PlatformException(super_native_extensions_error, "JNI: Java exception was thrown", otherError, null) E/flutter ( 7331): #0 NativeMethodChannel.invokeMethod (package:irondash_message_channel/src/method_channel.dart:45:7) E/flutter ( 7331): E/flutter ( 7331): #1 ReaderManagerImpl.getItemInfo (package:super_native_extensions/src/native/reader_manager.dart:242:18) E/flutter ( 7331): E/flutter ( 7331): #2 SystemClipboard.read (package:super_clipboard/src/system_clipboard.dart:38:22) E/flutter ( 7331): E/flutter ( 7331): #3 SuperClipboardService.hasClipboardContent (package:flutter_quill_extensions/src/editor_toolbar_controller_shared/clipboard/super_clipboard_service.dart:137:20) E/flutter ( 7331): E/flutter ( 7331): #4 ClipboardMonitor._update (package:flutter_quill/src/toolbar/buttons/clipboard_button.dart:32:9) E/flutter ( 7331): E/flutter ( 7331): E/AndroidRuntime( 7331): FATAL EXCEPTION: main E/AndroidRuntime( 7331): Process: com.example.example, PID: 7331 E/AndroidRuntime( 7331): java.lang.SecurityException: Permission Denial: opening provider org.chromium.chrome.browser.util.ChromeFileProvider from ProcessRecord{2832f48 7331:com.example.example/u0a191} (pid=7331, uid=10191) that is not exported from UID 10134 E/AndroidRuntime( 7331): at android.os.Parcel.createExceptionOrNull(Parcel.java:3057) E/AndroidRuntime( 7331): at android.os.Parcel.createException(Parcel.java:3041) E/AndroidRuntime( 7331): at android.os.Parcel.readException(Parcel.java:3024) E/AndroidRuntime( 7331): at android.os.Parcel.readException(Parcel.java:2966) E/AndroidRuntime( 7331): at android.app.IActivityManager$Stub$Proxy.getContentProvider(IActivityManager.java:5906) E/AndroidRuntime( 7331): at android.app.ActivityThread.acquireProvider(ActivityThread.java:7310) E/AndroidRuntime( 7331): at android.app.ContextImpl$ApplicationContentResolver.acquireProvider(ContextImpl.java:3649) E/AndroidRuntime( 7331): at android.content.ContentResolver.acquireProvider(ContentResolver.java:2493) E/AndroidRuntime( 7331): at android.content.ContentResolver.getStreamTypes(ContentResolver.java:1066) E/AndroidRuntime( 7331): at com.superlist.super_native_extensions.ClipDataHelper.getFormats(ClipDataHelper.java:92) E/AndroidRuntime( 7331): at com.superlist.super_native_extensions.ClipDataHelper.getFormats(ClipDataHelper.java:41) E/AndroidRuntime( 7331): at android.os.MessageQueue.nativePollOnce(Native Method) E/AndroidRuntime( 7331): at android.os.MessageQueue.next(MessageQueue.java:335) E/AndroidRuntime( 7331): at android.os.Looper.loopOnce(Looper.java:162) E/AndroidRuntime( 7331): at android.os.Looper.loop(Looper.java:294) E/AndroidRuntime( 7331): at android.app.ActivityThread.main(ActivityThread.java:8177) E/AndroidRuntime( 7331): at java.lang.reflect.Method.invoke(Native Method) E/AndroidRuntime( 7331): at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:552) E/AndroidRuntime( 7331): at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:971) E/AndroidRuntime( 7331): Caused by: android.os.RemoteException: Remote stack trace: E/AndroidRuntime( 7331): at com.android.server.am.ContentProviderHelper.checkAssociationAndPermissionLocked(ContentProviderHelper.java:691) E/AndroidRuntime( 7331): at com.android.server.am.ContentProviderHelper.getContentProviderImpl(ContentProviderHelper.java:287) E/AndroidRuntime( 7331): at com.android.server.am.ContentProviderHelper.getContentProvider(ContentProviderHelper.java:144) E/AndroidRuntime( 7331): at com.android.server.am.ActivityManagerService.getContentProvider(ActivityManagerService.java:6713) E/AndroidRuntime( 7331): at android.app.IActivityManager$Stub.onTransact(IActivityManager.java:2761) ```
Video Android Emulator API 34. https://github.com/user-attachments/assets/788bde36-d9d7-40ec-919f-48887f269100
Similar but not related issue This issue is not related and doesn't use `super_clipbaord` however a similar issue will occur with the same steps to reproduce. It uses our custom implementation in [Flutter Quill #2230](https://github.com/singerdmx/flutter-quill/pull/2230/) using Kotlin (for Android) directly using the Flutter method channel without Rust. https://github.com/user-attachments/assets/37e0c69c-968a-4dfe-abb0-0e7d5750e797 The difference is that the app doesn't crash: ```console Unhandled Exception: PlatformException(COULD_NOT_DECODE_IMAGE, Could not decode bitmap from Uri: Permission Denial: opening provider org.chromium.chrome.browser.util.ChromeFileProvider from ProcessRecord{b3d4d8e 14685:dev.flutterquill.quill_native_bridge_example/u0a191} (pid=14685, uid=10191) that is not exported from UID 10146, java.lang.SecurityException: Permission Denial: opening provider org.chromium.chrome.browser.util.ChromeFileProvider from ProcessRecord{b3d4d8e 14685:dev.flutterquill.quill_native_bridge_example/u0a191} (pid=14685, uid=10191) that is not exported from UID 10146, null) E/flutter (14685): #0 StandardMethodCodec.decodeEnvelope (package:flutter/src/services/message_codecs.dart:648:7) E/flutter (14685): #1 MethodChannel._invokeMethod (package:flutter/src/services/platform_channel.dart:334:18) E/flutter (14685): E/flutter (14685): #2 MethodChannelQuillNativeBridge.getClipboardImage (package:quill_native_bridge/src/quill_native_bridge_method_channel.dart:87:24) E/flutter (14685): E/flutter (14685): #3 Buttons.build. (package:quill_native_bridge_example/main.dart:150:32) E/flutter (14685): E/flutter (14685): ``` It's handled in a way so it returns null and throws an exception instead though I'm not entirely sure about the exact issue with `super_clipbaord` implementation. You might noticed that both `quill_native_bridge` ([`QuillNativeBridgePlugin`](https://github.com/singerdmx/flutter-quill/pull/2230/files#diff-55a5ef7816f6e2fd7e9875c5df4d3a7a6bb8ae6218d25b40def6cd7ad4b5ed8b)) and `super_clipbaord` have `java.lang.SecurityException: Permission Denial` in the console, which confirms that it's a security issue related to the lifecycle of the app. Sometimes it can be: ```console java.io.FileNotFoundException: No content provider: content://com.android.chrome.FileProvider/images/screenshot/17266705501256549663602733807552.gif W/System.err(17433): at android.content.ContentResolver.openTypedAssetFileDescriptor(ContentResolver.java:2029) W/System.err(17433): at android.content.ContentResolver.openAssetFileDescriptor(ContentResolver.java:1858) W/System.err(17433): at android.content.ContentResolver.openInputStream(ContentResolver.java:1528) ``` Or: ```console java.lang.SecurityException: Permission Denial: opening provider org.chromium.chrome.browser.util.ChromeFileProvider from ProcessRecord{d1d20d7 17742:dev.flutterquill.quill_native_bridge_example/u0a191} (pid=17742, uid=10191) that is not exported from UID 10146 W/System.err(17742): at android.os.Parcel.createExceptionOrNull(Parcel.java:3057) W/System.err(17742): at android.os.Parcel.createException(Parcel.java:3041) W/System.err(17742): at android.os.Parcel.readException(Parcel.java:3024) W/System.err(17742): at android.os.Parcel.readException(Parcel.java:2966) W/System.err(17742): at android.app.IActivityManager$Stub$Proxy.getContentProvider(IActivityManager.java:5906) W/System.err(17742): at android.app.ActivityThread.acquireProvider(ActivityThread.java:7310) W/System.err(17742): at android.app.ContextImpl$ApplicationContentResolver.acquireUnstableProvider(ContextImpl.java:3668) W/System.err(17742): at android.content.ContentResolver.acquireUnstableProvider(ContentResolver.java:2542) W/System.err(17742): at android.content.ContentResolver.openTypedAssetFileDescriptor(ContentResolver.java:2027) W/System.err(17742): at android.content.ContentResolver.openAssetFileDescriptor(ContentResolver.java:1858) W/System.err(17742): at android.content.ContentResolver.openInputStream(ContentResolver.java:1528) W/System.err(17742): at dev.flutterquill.quill_native_bridge.clipboard.ClipboardImageHandler.canRead(ClipboardImageHandler.kt:86) W/System.err(17742): at dev.flutterquill.quill_native_bridge.clipboard.ClipboardImageHandler.getClipboardImage(ClipboardImageHandler.kt:114) W/System.err(17742): at dev.flutterquill.quill_native_bridge.QuillNativeBridgePlugin.onMethodCall(QuillNativeBridgePlugin.kt:169) W/System.err(17742): at io.flutter.plugin.common.MethodChannel$IncomingMethodCallHandler.onMessage(MethodChannel.java:267) W/System.err(17742): at io.flutter.embedding.engine.dart.DartMessenger.invokeHandler(DartMessenger.java:292) W/System.err(17742): at io.flutter.embedding.engine.dart.DartMessenger.lambda$dispatchMessageToQueue$0$io-flutter-embedding-engine-dart-DartMessenger(DartMessenger.java:319) W/System.err(17742): at io.flutter.embedding.engine.dart.DartMessenger$$ExternalSyntheticLambda0.run(Unknown Source:12) W/System.err(17742): at android.os.Handler.handleCallback(Handler.java:958) W/System.err(17742): at android.os.Handler.dispatchMessage(Handler.java:99) W/System.err(17742): at android.os.Looper.loopOnce(Looper.java:205) W/System.err(17742): at android.os.Looper.loop(Looper.java:294) W/System.err(17742): at android.app.ActivityThread.main(ActivityThread.java:8177) W/System.err(17742): at java.lang.reflect.Method.invoke(Native Method) W/System.err(17742): at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:552) W/System.err(17742): at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:971) W/System.err(17742): Caused by: android.os.RemoteException: Remote stack trace: W/System.err(17742): at com.android.server.am.ContentProviderHelper.checkAssociationAndPermissionLocked(ContentProviderHelper.java:691) W/System.err(17742): at com.android.server.am.ContentProviderHelper.getContentProviderImpl(ContentProviderHelper.java:287) W/System.err(17742): at com.android.server.am.ContentProviderHelper.getContentProvider(ContentProviderHelper.java:144) W/System.err(17742): at com.android.server.am.ActivityManagerService.getContentProvider(ActivityManagerService.java:6713) W/System.err(17742): at android.app.IActivityManager$Stub.onTransact(IActivityManager.java:2761) ``` The only option I had was to write this image to a temporary location or internal storage, then once paste it again on app restart (when not having access to it), then get access to the already saved image, which seems like a workaround to me, there are similar issues on Android that need to solved manually and can be error prone such as process death. So I tried `super_clipboard` to see how it's handled, and the impl we have doesn't have the exact same exception details as `super_clipboard`, it seems like a similar unrelated issue.

Hint: The image URI that is given by the android.content.ClipboardManager gives the app limited access, and can be restricted to its lifecycle, once the lifecycle is destroyed, the app no longer has access to the android.net.Uri.

The super_clipboard plugin could check if it has access before processing further, avoid resulting in a crash, and return null (false for the canProvide or throw a dart platform exception) or save the image to somewhere and get access to the image later on app restart and return the image.

let me know If more details are needed or unable to reproduce the issue.

EchoEllet commented 1 week ago
A quick workaround

Same solution in Kotlin/JVM can be used in Java. ```kotlin // Check if you have access to the URI and the file exists before processing the URI: /** * A method to see if any exceptions can occur * before start reading the file. * * The app can lose access to the [Uri] due to lifecycle changes. * * @throws SecurityException When the app loses access to the [Uri] due to app lifecycle changes * or app restart. * @throws FileNotFoundException Could be thrown when the [Uri] is no longer on the clipboard. * */ @Throws(Exception::class) private fun Uri.readOrThrow( context: Context, ) = try { context.contentResolver.openInputStream(this)?.close() } catch (e: Exception) { throw e } try { imageUri.readOrThrow(context) } catch (e: Exception) { when (e) { is SecurityException -> result.error( "FILE_READ_PERMISSION_DENIED", "An image exists on the clipboard, but the app no longer " + "has permission to access it. This may be due to the app's " + "lifecycle or a recent app restart: ${e.message}", e.toString(), ) is FileNotFoundException -> result.error( "FILE_NOT_FOUND", "The image file can't be found, it might not be on the system clipboard anymore: ${e.message}", e.toString() ) else -> result.error( "UNKNOWN_ERROR_READING_FILE", "An unknown occurred while reading the image file URI: ${e.message}", e.toString() ) } return } // Process further with the URI... ``` Which doesn't fix the issue completely but at least no longer app crashes, since if you try to open the app and you still have that image on your clipboard without copying something else, the app will continue to crash and the system will recommend the user to uninstall the app or report it to the developer, the user will probably delete the app before reaching this state. So it's probably better to return `false` to `canProvide` the image file return it `null`, or have unhandled exception from dart side instead of the Android side as a quick solution. Saving an image to a temporary location is not guaranteed as it can be erased, somewhere in the app document directory is not ideal if we have to save all images on the clipboard to retain access to them on app restart, the plugin might not have full access to the app lifecycle to delete them later or the user don't want to save such images on the user device.

EchoEllet commented 1 week ago

I'm still uncertain why sometimes I get FileNotFoundException instead of SecurityException by slightly changing the steps to reproduce each time.

What I'm sure about this issue doesn't happen when not pasting that image into the app (step 2 removed)