Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to send a large file or multiple files to other apps, and know when to delete them?

Background

I have an App-Manager app, which allows to send APK files to other apps.

Up until Android 4.4 (including), all I had to do for this task is to send the paths to the original APK files (all were under "/data/app/..." which is accessible even without root).

This is the code for sending the files (docs available here) :

intent=new Intent(Intent.ACTION_SEND_MULTIPLE);
intent.setType("*/*");
final ArrayList<Uri> uris=new ArrayList<>();
for(...)
   uris.add(Uri.fromFile(new File(...)));
intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM,uris);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK|Intent.FLAG_ACTIVITY_NO_HISTORY|Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET|Intent.FLAG_ACTIVITY_MULTIPLE_TASK);

The problem

What I did worked since all apps' APK files had a unique name (which was their package name).

Ever since Lollipop (5.0), all apps' APK files are simply named "base.APK" , which make other apps unable to comprehend attaching them.

This means I have some options to send the APK files. This is what I was thinking about:

  1. copy them all to a folder, rename them all to unique names and then send them.

  2. compress them all to a single file and then send it. The compression level could be minimal, as APK files are already compressed anyway.

The problem is that I would have to send the files as quickly as possible, and if I really have to have those temporary files (unless there is another solution), to also dispose them as quickly as possible.

Thing is, I don't get notified when third party apps have finished handling the temporary file, and I also think that choosing multiple files would take quite some time to prepare no matter what I choose.

Another issue is that some apps (like Gmail) actually forbid sending APK files.

The question

Is there an alternative to the solutions I've thought of? Is there maybe a way to solve this problem with all the advantages I had before (quick and without junk files left behind) ?

Maybe some sort of way to monitor the file? or create a stream instead of a real file?

Will putting the temporary file inside a cache folder help in any way?

like image 506
android developer Avatar asked Apr 16 '15 16:04

android developer


1 Answers

Any app registered for that Intent should be able to process files with the same file name but different paths. To be able to cope with the fact that access to files provided by other apps can only be accessed while the receiving Activity is running (see Security Exception when trying to access a Picasa image on device running 4.2 or SecurityException when downloading Images with the Universal-Image-Downloader) receiving apps need to copy the files to a directory they have permanently access to. My guess is that some apps haven't implemented that copy process to deal with identical file names (when copied the file path would likely be the same for all files).

I'd suggest to serve the files through a ContentProvider instead of directly from the file system. That way you can create a unique file name for each file you want to send.

Receiving apps "should" receive files more or less like this:

ContentResolver contentResolver = context.getContentResolver();
Cursor cursor = contentResolver.query(uri, new String[] { OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE }, null, null, null);
// retrieve name and size columns from the cursor...

InputStream in = contentResolver.openInputStream(uri);
// copy file from the InputStream

Since apps should open the file using contentResolver.openInputStream() a ContentProvider should/will work instead of just passing a file uri in the Intent. Of course there might be apps that misbehave and this needs to be tested thoroughly but in case some apps won't handle ContentProvider served files you could add two different share options (one legacy and the regular one).

For the ContentProvider part there's this: https://developer.android.com/reference/android/support/v4/content/FileProvider.html

Unfortunately there's also this:

A FileProvider can only generate a content URI for files in directories that you specify beforehand

If you can define all directories you want to share files from when the app is built, the FileProvider would be your best option. I'm assuming your app would want to share files from any directory, so you'll need your own ContentProvider implementation.

The problems to solve are:

  1. How do you include the file path in the Uri in order to extract the very same path at a later stage (in the ContentProvider)?
  2. How do you create a unique file name that you can return in the ContentProvider to the receiving app? This unique file name needs to be the same for multiple calls to the ContentProvider meaning you can't create a unique id whenever the ContentProvider is called or you'd get a different one with each call.

Problem 1

A ContentProvider Uri consists of a scheme (content://), an authority and the path segment(s), e.g.:

content://lb.com.myapplication2.fileprovider/123/base.apk

There are many solutions to the first problem. What I suggest is to base64 encode the file path and use it as the last segment in the Uri:

Uri uri = Uri.parse("content://lb.com.myapplication2.fileprovider/" + new String(Base64.encode(filename.getBytes(), Base64.DEFAULT));

If the file path is e.g.:

/data/data/com.google.android.gm/base.apk

then the resulting Uri would be:

content://lb.com.myapplication2.fileprovider/L2RhdGEvZGF0YS9jb20uZ29vZ2xlLmFuZHJvaWQuZ20vYmFzZS5hcGs=

To retrieve the file path in the ContentProvider simply do:

String lastSegment = uri.getLastPathSegment();
String filePath = new String(Base64.decode(lastSegment, Base64.DEFAULT) );

Problem 2

The solution is pretty simple. We include a unique identifier in the Uri generated when we create the Intent. This identifier is part of the Uri and can be extracted by the ContentProvider:

String encodedFileName = new String(Base64.encode(filename.getBytes(), Base64.DEFAULT));
String uniqueId = UUID.randomUUID().toString();
Uri uri = Uri.parse("content://lb.com.myapplication2.fileprovider/" + uniqueId + "/" + encodedFileName );

If the file path is e.g.:

/data/data/com.google.android.gm/base.apk

then the resulting Uri would be:

content://lb.com.myapplication2.fileprovider/d2788038-53da-4e84-b10a-8d4ef95e8f5f/L2RhdGEvZGF0YS9jb20uZ29vZ2xlLmFuZHJvaWQuZ20vYmFzZS5hcGs=

To retrieve the unique identifier in the ContentProvider simply do:

List<String> segments = uri.getPathSegments();
String uniqueId = segments.size() > 0 ? segments.get(0) : "";

The unique file name the ContentProvider returns would be the original file name (base.apk) plus the unique identifier inserted after the base file name. E.g. base.apk becomes base<unique id>.apk.

While this might all sound very abstract, it should become clear with the full code:

Intent

intent=new Intent(Intent.ACTION_SEND_MULTIPLE);
intent.setType("*/*");
final ArrayList<Uri> uris=new ArrayList<>();
for(...)
    String encodedFileName = new String(Base64.encode(filename.getBytes(), Base64.DEFAULT));
    String uniqueId = UUID.randomUUID().toString();
    Uri uri = Uri.parse("content://lb.com.myapplication2.fileprovider/" + uniqueId + "/" + encodedFileName );
    uris.add(uri);
}
intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM,uris);

ContentProvider

public class FileProvider extends ContentProvider {

    private static final String[] DEFAULT_PROJECTION = new String[] {
        MediaColumns.DATA,
        MediaColumns.DISPLAY_NAME,
        MediaColumns.SIZE,
    };

    @Override
    public boolean onCreate() {
        return true;
    }

    @Override
    public String getType(Uri uri) {
        String fileName = getFileName(uri);
        if (fileName == null) return null;
        return MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileName);
    }

    @Override
    public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
        String fileName = getFileName(uri);
        if (fileName == null) return null;
        File file = new File(fileName);
        return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
    }

    @Override
    public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
        String fileName = getFileName(uri);
        if (fileName == null) return null;

        String[] columnNames = (projection == null) ? DEFAULT_PROJECTION : projection;
        MatrixCursor ret = new MatrixCursor(columnNames);
        Object[] values = new Object[columnNames.length];
        for (int i = 0, count = columnNames.length; i < count; i++) {
            String column = columnNames[i];
            if (MediaColumns.DATA.equals(column)) {
                values[i] = uri.toString();
            }
            else if (MediaColumns.DISPLAY_NAME.equals(column)) {
                values[i] = getUniqueName(uri);
            }
            else if (MediaColumns.SIZE.equals(column)) {
                File file = new File(fileName);
                values[i] = file.length();
            }
        }
        ret.addRow(values);
        return ret;
    }

    private String getFileName(Uri uri) {
        String path = uri.getLastPathSegment();
        return path != null ? new String(Base64.decode(path, Base64.DEFAULT)) : null;
    }

    private String getUniqueName(Uri uri) {
        String path = getFileName(uri);
        List<String> segments = uri.getPathSegments();
        if (segments.size() > 0 && path != null) {
            String baseName = FilenameUtils.getBaseName(path);
            String extension = FilenameUtils.getExtension(path);
            String uniqueId = segments.get(0);
            return baseName + uniqueId + "." + extension;
        }

        return null;
    }

    @Override
    public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
        return 0;       // not supported
    }

    @Override
    public int delete(Uri uri, String arg1, String[] arg2) {
        return 0;       // not supported
    }

    @Override
    public Uri insert(Uri uri, ContentValues values) {
        return null;    // not supported
    }

}

Note:

  • My sample code uses the org.apache.commons library for the file name manipulations (FilenameUtils.getXYZ)
  • using base64 encoding for the file path is a valid approach because all character used in base64 ([a-zA-Z0-9_-=] according to this https://stackoverflow.com/a/6102233/534471) are valid in an Uri path (0-9, a-z, A-Z, _-!.~'()*,;:$&+=/@ --> see https://developer.android.com/reference/java/net/URI.html)

Your manifest would have to define the ContentProvider like so:

<provider
    android:name="lb.com.myapplication2.fileprovider.FileProvider"
    android:authorities="lb.com.myapplication2.fileprovider"
    android:exported="true"
    android:grantUriPermissions="true"
    android:multiprocess="true"/>

It won't work without android:grantUriPermissions="true" and android:exported="true" because the other app wouldn't have permission to access the ContentProvider (see also http://developer.android.com/guide/topics/manifest/provider-element.html#exported) . android:multiprocess="true" on the other hand is optional but should make it more efficient.

like image 58
Emanuel Moecklin Avatar answered Nov 15 '22 20:11

Emanuel Moecklin