Pages

Wednesday, 30 November 2011

Android e-mail intents with multiple attachments and conversion between path URIs

A recent Android app I was working on led me to spend sometime researching how to send multiple image attachments via e-mail. It turned out not to be quite as easy as I expected. For a simple single attachment e-mail you would have form an intent with code that looks like this:

String externalStoragePathStr = Environment.getExternalStorageDirectory().toString() + File.separatorChar;
String filenameStr = "MyPhoto.jpg";
String absPath = "file://" + externalStoragePathStr + filenameStr;
Intent emailIntent = new Intent(android.content.Intent.ACTION_SEND); 
emailIntent.setType("text/plain");
emailIntent.putExtra(android.content.Intent.EXTRA_EMAIL, new String[] {"wocks@gmail.com"}); 
emailIntent.putExtra(android.content.Intent.EXTRA_SUBJECT, "Test Subject"); 
emailIntent.putExtra(android.content.Intent.EXTRA_TEXT, "Here's your stuff"); 
Uri retUri = Uri.parse(absPath);
emailIntent.putExtra(Intent.EXTRA_STREAM, retUri);
startActivity(Intent.createChooser(emailIntent, "Send attachment with what app?"));

and to send multiple attachments you use the ACTION_SEND_MULTIPLE action intent with the multiple filenames passed in as an ArrayList of Parcelable Uris:

String externalStoragePathStr = Environment.getExternalStorageDirectory().toString() + File.separatorChar;
String filePaths[] = {
 externalStoragePathStr + "MyPhoto.jpg", //  /mnt/sdcard/MyPhoto.jpg
 externalStoragePathStr + "MyPhoto-2.jpg", //  /mnt/sdcard/MyPhoto-2.jpg
 externalStoragePathStr + "MyPhoto-3.jpg"}; //  /mnt/sdcard/MyPhoto-3.jpg
Intent emailIntent = new Intent(android.content.Intent.ACTION_SEND_MULTIPLE); 
emailIntent.setType("text/plain");
emailIntent.putExtra(android.content.Intent.EXTRA_EMAIL, new String[] {"wocks@gmail.com"}); 
emailIntent.putExtra(android.content.Intent.EXTRA_SUBJECT, "Test Subject"); 
emailIntent.putExtra(android.content.Intent.EXTRA_TEXT, "Here's your stuff"); 
ArrayList<Uri> uris = new ArrayList<Uri>();
for (String file : filePaths) {
 File fileIn = new File(file);
 Uri u = Uri.fromFile(fileIn);
 uris.add(u);
}
emailIntent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris);
startActivity(Intent.createChooser(emailIntent, "Send attachment with what app?"));

But I ran into a bunch of various problems using different variations of the snippets of code out there on the web, namely many related posts you will find over at stackoverflow.com.

  • Attachment filenames would appear in the compose e-mail window but when they arrived at the destination they were corrupt (Thunderbird wasn't recognizing them as images)
  • Attachment filenames wouldn't even attach to the new e-mail (i.e. weren't displayed in the compose window)
Conclusion:

From what I can recall my major hurdle was the E-mail application was not attaching multiple filenames to the compose window but the G-mail application was. After many trials and errors, what I found was that the E-mail application didn't seem to like being passed an ArrayList of Uris that were made from absolute paths (e.g. /mnt/sdcard/DCIM/Camera/photo.jpg, file:///mnt/sdcard/MyPhoto.jpg etc.). What it did like was Uris in the form like content://media/external/images/media/1075.

This brought me onto my next big research and trial/error phase - converting the absolute path of my images which I know into content:// ones. Vice versa is an easy process but the way I needed was a bit more complicated.

I've put everything together into one activity with the code below. Check the source code comments for more detail. Basically, you define your filenames, run the activity and it will then prompt you to send a single or multiple attachments. It covers:

1) e-mail intents with single/multiple attachments that work with both E-mail and G-mail applications
2) converting absolute paths -> content URIs
3) converting content URIs to absolute paths

Click the Java filename below to expand the (very long) code. Alternatively, download the full Eclipse project here.

package ws.aroha.android.pilcrowpipe;

import java.io.File;
import java.io.FileNotFoundException;
import java.util.ArrayList;
import java.util.Stack;

import ws.aroha.android.pilcrowpipe.R;

import android.app.Activity;
import android.app.AlertDialog;
import android.app.ProgressDialog;
import android.content.ContentResolver;
import android.content.DialogInterface;
import android.content.Intent;
import android.database.Cursor;
import android.media.MediaScannerConnection;
import android.media.MediaScannerConnection.MediaScannerConnectionClient;
import android.net.Uri;
import android.os.Bundle;
import android.os.Environment;
import android.os.Handler;
import android.provider.MediaStore;
import android.widget.Toast;

/**
 * SendMultipleAttachments
 * 
 * @author Simon McCorkindale
 * 
 * Sample code to illustrate 2 concepts on Android:
 * 
 * 1. Opening a new e-mail intent with single or multiple image attachments using {@link Intent.ACTION_SEND} and {@link Intent.ACTION_SEND_MULTIPLE} 
 * 2. Converting between absolute paths and "content://..." type URIs
 * 
 * The reason for this sample code is for the situation where you only know the real (absolute) path of
 * the file(s) you want to attach. Unfortunately it seems E-mail/G-mail apps require the paths be a URI
 * in the format of "content://..." in order to attach them so we do the conversion here. Conversion
 * from "content://..." URI to an absolute path string is easy but not the other way around, which
 * requires a little a little multi-threading work with the Media Scanner API.
 * 
 * See more detail on my blog at:
 * {@link http://pilcrowpipe.blogspot.com}
 *
 * Disclaimer: Use this code however you see fit with no restrictions. Kudos to those
 * people who wrote code on the sites I've referenced in my comments.
 *
 */
public class SendMultipleAttachments extends Activity implements MediaScannerConnectionClient {
 /**
  * Path to external storage media (SD card in most cases).
  */
 private final static String EXTERNAL_MEDIA_PATH =
  Environment.getExternalStorageDirectory().toString() +
  File.separatorChar;

 /**
  * Alert dialog item IDs.
  */
 private final int SINGLE_ITEM = 0x10;    // Single file attachment
 private final int MULTIPLE_ITEMS = 0x1a;   // Multiple file attachments
 private int mSendTypeSelection = SINGLE_ITEM;  // Send single attachment by default

 /**
  * Define the absolute path to your multiple attachment files.
  * MODIFY TO MATCH FILES ON YOUR PHONE.
  */
 private final String mMultipleFilesPaths[] = {
   EXTERNAL_MEDIA_PATH + "MyPhoto.jpg",   // -> /mnt/sdcard/MyPhoto.jpg on my Android
   EXTERNAL_MEDIA_PATH + "MyPhoto-2.jpg", // -> /mnt/sdcard/MyPhoto-2.jpg on my Android
   EXTERNAL_MEDIA_PATH + "MyPhoto-3.jpg", // -> /mnt/sdcard/MyPhoto-3.jpg on my Android
   EXTERNAL_MEDIA_PATH + "MyPhoto-4.jpg" // -> /mnt/sdcard/MyPhoto-4.jpg on my Android
 };

 /**
  * Define the absolute path to your single attachment file.
  * MODIFY TO MATCH FILE ON YOUR PHONE.
  */
 private final String mSingleFilePath = 
  EXTERNAL_MEDIA_PATH + "MyPhoto.jpg";  // -> /mnt/sdcard/MyPhoto.jpg on my Android

 /**
  * The send e-mail {@link android.content.Intent}.
  */
 private Intent mIntent = null;

 /**
  * The number of URIs we expect to be converted to content:// type. This figure
  * is used so we can tell whether the conversion for all files has been complete or not.
  */
 private int mNumberExpectedConvertedUris = 0;

 /**
  * Handler to accept communiqué from the Media Scanner Client worker thread. "IPC" made easy!
  */
 private Handler mHandler = new Handler();
 
 /**
  * Progress bar dialog to display when converting URIs and waiting for the worker thread.
  */
 private ProgressDialog mProgressDialog = null;

 /** Called when the activity is first created. */
 @Override
 public void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  setContentView(R.layout.main);
  
  // Check external media state
  String mediaState = Environment.getExternalStorageState();
  if (mediaState == null ||
    !(mediaState.equals(Environment.MEDIA_MOUNTED) ||
      mediaState.equals(Environment.MEDIA_MOUNTED_READ_ONLY))) {
   // Can't access external media so quit graciously notifying the user
   AlertDialog.Builder builder = new AlertDialog.Builder(this);
      builder.setMessage("External media is unavailable (not mounted?). Please check.");
      AlertDialog dialog = builder.create();
      dialog.setButton("OK",
                 new DialogInterface.OnClickListener() {
                     public void onClick(DialogInterface dialog, int which) {
                      dialog.dismiss();
                      finish();
                     }
                 }
             );
      dialog.setTitle("Tiene un problema"); // Houston, we have a problem
      dialog.show();
      return;
  }

  // Prompt the user to send either the one or multiple files registerd in
  // fields at the top of this class
  AlertDialog.Builder builder = new AlertDialog.Builder(this);
  builder.setTitle("Send one or multiple attachments?");
  CharSequence[] items = {"Single", "Multiple"};
  builder.setItems(items, new DialogInterface.OnClickListener() {

   @Override
   public void onClick(DialogInterface dialog, int which) {
    // Determine if user clicked to send single or multiple files
    ContentResolver contentResolver = getContentResolver();
    switch (which) {
    case 0: // Single selected
     // Check file exists
     try {
         contentResolver.openFileDescriptor(Uri.parse("file://" + mSingleFilePath), "r");
        } catch (FileNotFoundException e) {
         Toast.makeText(getApplicationContext(), "File " + mSingleFilePath +
        " doesn't exist",
        Toast.LENGTH_SHORT).show();
        }
        mSendTypeSelection = SINGLE_ITEM;
     break;

    case 1: // Multiple selected
     // Check files exist
     for (String filename : mMultipleFilesPaths) {
      try {
          contentResolver.openFileDescriptor(Uri.parse("file://" + filename), "r");
         } catch (FileNotFoundException e) {
          Toast.makeText(getApplicationContext(), "File " + filename +
         " doesn't exist",
         Toast.LENGTH_SHORT).show();
         }
     }
        mSendTypeSelection = MULTIPLE_ITEMS;
     break;
    }

    requestSendAttachments();
   }
  });
  builder.create().show();
 }

 /**
  * Request to send the attachments. This method invokes the thread that will
  * convert the attachments' URIs from absolute path strings to "content://..."
  * type URIs needed for attaching attachments. This Media Scanner client thread
  * will then initiate the actual sending process (i.e. opening of the application
  * to send the attachments by firing our intention).
  */
 private void requestSendAttachments() {
  mIntent = new Intent();

  // Set some basic e-mail stuff
  mIntent.setType("text/plain");
  mIntent.putExtra(Intent.EXTRA_TEXT, "Test e-mail with attachment(s)");
  mIntent.putExtra(Intent.EXTRA_SUBJECT, "Your file(s)");
  mIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
  
  // Because we wait on a thread show we're doing something meanwhile
  mProgressDialog = new ProgressDialog(this);
  mProgressDialog.setTitle("Converting URI(s)...");
  mProgressDialog.setMessage("Espere un minuto"); // Please wait a minute
  mProgressDialog.show();
  
  // Intent logic differs slightly depending on whether we are to
  // send single or multiple attachments.
  //
  // The stock standard E-mail / G-mail mail client applications
  // require the URIs to the attachments be in the form of
  // "content://...", not the absolute path type such as
  // /mnt/sdcard/MyPhoto.jpg. It requires some work to 
  // be done in a separate thread (see the Media Scanner code below).
  //
  // I've only tried this with images and it may not work with
  // other types of files for which the underlying Android system
  // doesn't generate content type URIs for.

  switch (mSendTypeSelection) {
  case SINGLE_ITEM: // Send single attachment file
   mIntent.setAction(Intent.ACTION_SEND);
   mNumberExpectedConvertedUris = 1;
   convertAbsPathToContentUri(mSingleFilePath);
   break;

  case MULTIPLE_ITEMS: // Send multiple attachment files
   mIntent.setAction(Intent.ACTION_SEND_MULTIPLE);
   mNumberExpectedConvertedUris = mMultipleFilesPaths.length;
   convertAbsPathToContentUri(mMultipleFilesPaths);
   break;
  }
 }

 /***************************** BEGIN URI CONVERSION CODE ***************************
  * Code snippet referenced from http://stackoverflow.com/questions/3004713/get-content-uri-from-file-path-in-android
  *
  * Logic is something like:
  * 1. UI thread invokes convertFileToContentUri() which causes a new Media Scanner Connection thread
  *    to be spawned.
  * 2. Once the thread has done it's stuff onScanCompleted() is called with the converted Uri
  * 3. My custom hack is to finally send a Runnable back to the UI thread via Handler to add
  *    the new URI to the list of attachments, and if all URIs have been processed fire the
  *    activity to open a new e-mail with attachments.
  */
 
 /** List of content URIs of files to attach */
 private ArrayList<Uri> mContentUris = new ArrayList<Uri>();
 
 /** Instance of our {@link android.media.MediaScannerConnection} client */
 private MediaScannerConnection mMsc = null;

 /** The path of the current URI being queried (scanned) */
 private String mAbsPath = null;

 /**
  * Convert absolute paths to content:// type URIs and store in mContentUris array list.
  * E.g. /mnt/sdcard/DCIM/Camera/photo.jpg -> content://media/external/images/media/1075
  * 
  * @param absPaths[] array of absolute path strings to convert
  */
 private Stack<String> mContentUrisStack = new Stack<String>();
 private void convertAbsPathToContentUri(final String absPaths[]) {
  // In order to achieve sequential processing of each path
  // we utilise a stack, and upon completion of converting
  // one URI it will be popped and the next processed. If we
  // don't process sequential then we get illegal state exceptions
  // from the Media Scanner client saying it isn't connected and
  // some of the paths don't get processed properly.
  for (String absPath : absPaths) {
   mContentUrisStack.push(absPath);
  }
  
  // Pop and convert
  if (!mContentUrisStack.empty()) {
   convertAbsPathToContentUri(mContentUrisStack.pop());
  }
 }

 /**
  * Convert absolute paths to content:// type URIs and store in mContentUris array list.
  * E.g. /mnt/sdcard/DCIM/Camera/photo.jpg -> content://media/external/images/media/1075
  *  
  * @param absPath absolute path string to convert
  */
 private void convertAbsPathToContentUri(String absPath) {
  mAbsPath = absPath;

  mMsc = new MediaScannerConnection(getApplicationContext(), this);
  mMsc.connect();
 }

 @Override
 public void onMediaScannerConnected() {
  mMsc.scanFile(mAbsPath, null);
 }

 @Override
 public void onScanCompleted(final String path, final Uri uri) {
  mHandler.post(new Runnable() {

   @Override
   public void run() {
    // Add converted content URI to list of content URIs to be attached to the e-mail
    synchronized (mContentUris) {
     mContentUris.add(uri);
    }

    // Just for sake of completeness here's demonstrating the reverse;
    // proving what the conversion is correct
    mHandler.post(new Runnable() {
     
     @Override
     public void run() {
      Toast.makeText(getApplicationContext(), "Original path:\n" +
       path + "\n\n" +
       "Converted content URI:\n" +
       uri.toString() + "\n\n" +
       "Reverse converted path:\n " +
       convertContentToAbsolutePath(uri),
       Toast.LENGTH_LONG).show();
      
      // Pop and convert
      if (mSendTypeSelection == MULTIPLE_ITEMS &&
       !mContentUrisStack.empty()) {
        convertAbsPathToContentUri(mContentUrisStack.pop());
      }
     }
    });
    
    // Conversion is complete, start our intent
    if (mContentUris.size() == mNumberExpectedConvertedUris) {
     mProgressDialog.dismiss();
     
     // Add the list of converted URIs to the e-mail intent so the
     // e-mail app can know their location
     switch (mSendTypeSelection) {
     case SINGLE_ITEM:
      // Single URI, add Parcelable Uri object only
      mIntent.putExtra(Intent.EXTRA_STREAM, mContentUris.get(0));
      break;
     case MULTIPLE_ITEMS:
      // Multiple URIs, add ArrayList
      mIntent.putExtra(Intent.EXTRA_STREAM, mContentUris);
      break;
     }
     
     // Open a new compose e-mail window
     startActivity(Intent.createChooser(mIntent, "Send with what application?"));
    }
   }
  });

  mMsc.disconnect();
 }

 /**
  * Convert "content://..." style URI to an absolute path string.
  * E.g. content://media/external/images/media/1075 -> /mnt/sdcard/DCIM/Camera/photo.jpg
  * 
  * Code snippet referenced from http://stackoverflow.com/questions/3401579/get-filename-and-path-from-uri-from-mediastore
  * 
  * @param contentUri {@link contentUri} object containing the "content://..." URI
  * @return string containing the absolute path (including filename)
  */
 public String convertContentToAbsolutePath(Uri contentUri) {
  String[] proj = { MediaStore.Images.Media.DATA };
  Cursor cursor = managedQuery(contentUri, proj, null, null, null);
  int columnIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA);
  cursor.moveToFirst();
  return cursor.getString(columnIndex);
 }
 /*************************** END URI CONVERSION CODE ***************************/
}
// EOF

1 comment: