Search code examples
javaandroidandroid-intentandroid-activitycapacitor

Error with sharing images on Android with Capacitor 4


I have an Ionic + Capacitor 2 project where I can share images with other applications (Instagram, Facebook, WhatsApp, etc) using the @capacitor/share plugin and passing a fileUri as a value for the url property. Everything works normally for both Android and iOS.

However, when upgrading the project to Capacitor 4 (I didn't do the migration, but created a blank Ionic project already with Capacitor 4 and then adapted my codes where necessary) the exact same code logic doesn't work on my Android devices (tested on physical devices with Android 8.0.0 and Android 9.0.0 and both with the same behavior). For iOS, image sharing works correctly (tested on an iPhone 11 with iOS 16.1.2).

The sharing dialog opens normally and shows all available apps, including Instagram Stories and Facebook Stories, that is, the plugin is recognizing that I am sharing an image and otherwise these options do not appear in the list of apps. Some examples of results obtained when selecting an app are:

  • Instagram Stories/Facebook Stories: nothing happens.
  • Instagram Feed: Instagram opens and a toast is shown with the message Unable to load image, then Instagram closes and focus returns to my app.
  • WhatsApp/WhatsApp Business: A toast is shown with the message Share failed. Try again.

In all cases the Error: Share cancelled message is also shown in the Android Studio console.

In AndroidManifest.xml I already have the android.permission.READ_EXTERNAL_STORAGE and android.permission.WRITE_EXTERNAL_STORAGE permissions and I'm also saving the image files in the cache directory, so I don't know why this problem is happening. I tested it and the app is saving the images in the cache directory normally, so the problem is just with the sharing itself.

As I could already confirm that the error is something that comes from the native part and not from my TS code, I'll post it here so that people who have knowledge in Android development (and not necessarily know Capacitor either) can help.

SharePlugin.java:

package com.capacitorjs.plugins.share;

import android.app.Activity;
import android.app.PendingIntent;
import android.content.*;
import android.net.Uri;
import android.os.Build;
import android.webkit.MimeTypeMap;
import androidx.activity.result.ActivityResult;
import androidx.core.content.FileProvider;
import com.getcapacitor.JSArray;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.ActivityCallback;
import com.getcapacitor.annotation.CapacitorPlugin;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import org.json.JSONException;

@CapacitorPlugin(name = "Share")
public class SharePlugin extends Plugin {

    private BroadcastReceiver broadcastReceiver;
    private boolean stopped = false;
    private boolean isPresenting = false;
    private ComponentName chosenComponent;

    @Override
    public void load() {
        broadcastReceiver =
            new BroadcastReceiver() {
                @Override
                public void onReceive(Context context, Intent intent) {
                    chosenComponent = intent.getParcelableExtra(Intent.EXTRA_CHOSEN_COMPONENT);
                }
            };
        getActivity().registerReceiver(broadcastReceiver, new IntentFilter(Intent.EXTRA_CHOSEN_COMPONENT));
    }

    @ActivityCallback
    private void activityResult(PluginCall call, ActivityResult result) {
        if (result.getResultCode() == Activity.RESULT_CANCELED && !stopped) {
            call.reject("Share canceled");
        } else {
            JSObject callResult = new JSObject();
            callResult.put("activityType", chosenComponent != null ? chosenComponent.getPackageName() : "");
            call.resolve(callResult);
        }
        isPresenting = false;
    }

    @PluginMethod
    public void canShare(PluginCall call) {
        JSObject callResult = new JSObject();
        callResult.put("value", true);
        call.resolve(callResult);
    }

    @PluginMethod
    public void share(PluginCall call) {
        if (!isPresenting) {
            String title = call.getString("title", "");
            String text = call.getString("text");
            String url = call.getString("url");
            JSArray files = call.getArray("files");
            String dialogTitle = call.getString("dialogTitle", "Share");

            if (text == null && url == null && (files == null || files.length() == 0)) {
                call.reject("Must provide a URL or Message or files");
                return;
            }

            if (url != null && !isFileUrl(url) && !isHttpUrl(url)) {
                call.reject("Unsupported url");
                return;
            }

            Intent intent = new Intent(files != null && files.length() > 1 ? Intent.ACTION_SEND_MULTIPLE : Intent.ACTION_SEND);

            if (text != null) {
                // If they supplied both fields, concat them
                if (url != null && isHttpUrl(url)) text = text + " " + url;
                intent.putExtra(Intent.EXTRA_TEXT, text);
                intent.setTypeAndNormalize("text/plain");
            }

            if (url != null && isHttpUrl(url) && text == null) {
                intent.putExtra(Intent.EXTRA_TEXT, url);
                intent.setTypeAndNormalize("text/plain");
            } else if (url != null && isFileUrl(url)) {
                JSArray filesArray = new JSArray();
                filesArray.put(url);
                shareFiles(filesArray, intent, call);
            }

            if (title != null) {
                intent.putExtra(Intent.EXTRA_SUBJECT, title);
            }

            if (files != null && files.length() != 0) {
                shareFiles(files, intent, call);
            }
            int flags = PendingIntent.FLAG_UPDATE_CURRENT;
            if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
                flags = flags | PendingIntent.FLAG_MUTABLE;
            }

            // requestCode parameter is not used. Providing 0
            PendingIntent pi = PendingIntent.getBroadcast(getContext(), 0, new Intent(Intent.EXTRA_CHOSEN_COMPONENT), flags);
            Intent chooser = Intent.createChooser(intent, dialogTitle, pi.getIntentSender());
            chosenComponent = null;
            chooser.addCategory(Intent.CATEGORY_DEFAULT);
            stopped = false;
            isPresenting = true;
            startActivityForResult(call, chooser, "activityResult");
        } else {
            call.reject("Can't share while sharing is in progress");
        }
    }

    private void shareFiles(JSArray files, Intent intent, PluginCall call) {
        List<Object> filesList;
        ArrayList<Uri> fileUris = new ArrayList<>();
        try {
            filesList = files.toList();
            for (int i = 0; i < filesList.size(); i++) {
                String file = (String) filesList.get(i);
                if (isFileUrl(file)) {
                    String type = getMimeType(file);
                    if (type == null || filesList.size() > 1) {
                        type = "*/*";
                    }
                    intent.setType(type);

                    Uri fileUrl = FileProvider.getUriForFile(
                        getActivity(),
                        getContext().getPackageName() + ".fileprovider",
                        new File(Uri.parse(file).getPath())
                    );
                    fileUris.add(fileUrl);

                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && filesList.size() == 1) {
                        intent.setDataAndType(fileUrl, type);
                        intent.putExtra(Intent.EXTRA_STREAM, fileUrl);
                    }
                } else {
                    call.reject("only file urls are supported");
                    return;
                }
            }
            if (fileUris.size() > 1) {
                intent.putExtra(Intent.EXTRA_STREAM, fileUris);
            }
            intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
        } catch (Exception ex) {
            call.reject(ex.getLocalizedMessage());
            return;
        }
    }

    @Override
    protected void handleOnDestroy() {
        if (broadcastReceiver != null) {
            getActivity().unregisterReceiver(broadcastReceiver);
        }
    }

    @Override
    protected void handleOnStop() {
        super.handleOnStop();
        stopped = true;
    }

    private String getMimeType(String url) {
        String type = null;
        String extension = MimeTypeMap.getFileExtensionFromUrl(url);
        if (extension != null) {
            type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
        }
        return type;
    }

    private boolean isFileUrl(String url) {
        return url.startsWith("file:");
    }

    private boolean isHttpUrl(String url) {
        return url.startsWith("http");
    }
}

As you can see, it's on the line after the if (result.getResultCode() == Activity.RESULT_CANCELED && !stopped) where the value Share canceled is returned.

How can I further investigate this issue and get more information about why it is occurring?


Solution

  • Sharing a single file has one questionable condition:

    Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
    

    This code must run, else it will send an invalid Intent for single files on lower API levels:

    intent.putExtra(Intent.EXTRA_STREAM, fileUrl);