14

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?

android developer
  • 114,585
  • 152
  • 739
  • 1,270
  • They still have unique full pathnames names, you merely have to include the path in the part of the name you are checking for uniqueness. – Chris Stratton Apr 16 '15 at 18:31
  • It won't work. Most apps that get those paths consider them as the files names, so they will usually override the other files, which is useless. You can try it for yourself if you wish. Try my app and try to share multiple apps as APK files (assuming you have Lollipop) – android developer Apr 16 '15 at 21:07
  • 1
    Those would be very poor apps, if they ignore the path (without which they can't find the file anyway). Maybe you can make unique symlinks though (in a public folder of your Internal Storage). – Chris Stratton Apr 16 '15 at 22:34
  • @ChrisStratton Most apps won't be able to handle the same file name. The path is used, of course, but the file name is the dominant one as the attachment. If I add APK file named "base.apk" from one path, and another one with the same name from another path, most apps will take just one of them. How do I make the unique symlinks? Would it make the files look like they have a different name? can you please try and show it in an answer ? I've provided the intent that is needed... – android developer Apr 17 '15 at 06:50
  • @Chris Stratton: that sounds really interesting; and using the code from http://stackoverflow.com/questions/27726428/symbolic-link-creation-in-android-within-an-applications-asset-directory it's quickly tested, if everything else it set up – Trinimon Apr 19 '15 at 20:09
  • 1
    @Trinimon Seems to work. Is it safe to use? Will it work on any Android version/device out there? Is there no other solution? What should I do with the symlink file ? Should I delete it too ? if so, when? – android developer Apr 19 '15 at 20:47
  • @android developer: I'm afraid you are now our biggest expert on this topic ;) I just saw this link and thought: _"Hey, that could be quickly tested!"_. The QA team of the company which I work for tests usually with about 40 devices and we are sometimes really surprised about the bugs that happen :D so I don't dare to say anything ;) may be you can add it as an experimental feature and analyze the successes/failures by using statistics. – Trinimon Apr 19 '15 at 21:00
  • @Trinimon too bad. Maybe I should use this, but warn that it might not work. – android developer Apr 19 '15 at 21:05
  • @android developer: I could also think of an internal test routine, that writes a could of files with the same name to different folders, creates symbolic links and forwards its file URLs to an activity of your app. Depending on the result ... – Trinimon Apr 19 '15 at 21:10
  • @Trinimon I've already tested it. It worked fine, but for some reason it insist that the symlinks will be created in "getCacheDir" or "getFilesDir" . It doesn't allow to be via external storage (even on Lollipop, and even with the WRITE_EXTERNAL_STORAGE permission that's not needed anymore anyway). Odd. – android developer Apr 19 '15 at 21:49
  • I'm a developer of an app that is targeted by the intent and just tested it. Indeed the file names are base.apk but my app (an email client) would still consider it different files and process all of them. IMO it's the receiving app's fault if they can't process files with different path but same file name. Obviously that doesn't help you much but still. – Emanuel Moecklin Apr 21 '15 at 18:10
  • One of my alternative ideas would be to use a ContentProvider that serves the files. Every receiving app should be able to pull the files from a ContentProvider (yours) that would translate unique file identifiers to the absolute file path. – Emanuel Moecklin Apr 21 '15 at 18:11
  • @EmanuelMoecklin Sounds like a good idea. I had the feeling there is something like that. Do you know how to make it? Is there a tutorial about it? Would it really work using the intent I've created? – android developer Apr 21 '15 at 19:12
  • android:exported="false" needs to be android:exported="true" or the other app has no access to the ContentProvider. See: http://developer.android.com/guide/topics/manifest/provider-element.html#exported – Emanuel Moecklin Apr 25 '15 at 20:34

2 Answers2

7

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:

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.

Community
  • 1
  • 1
Emanuel Moecklin
  • 28,488
  • 11
  • 69
  • 85
  • I don't think I understand this. Where is the part where I send the intent I've made? Suppose I have a list of paths to files I want to share, what should I do with them? Where does the intent of sending files take place here? – android developer Apr 22 '15 at 22:10
  • You do exactly what you do now but instead of sending a file Uri you send your ContentProvider Uri for that specific file. You might have to read up on ContentProviders: https://developer.android.com/guide/topics/providers/content-providers.html after which the whole "thing" gets fairly easy to understand. – Emanuel Moecklin Apr 22 '15 at 23:39
  • I added a paragraph about how to create the Uri for the Intent – Emanuel Moecklin Apr 23 '15 at 14:23
  • But doesn't it require anything from the receiving apps? – android developer Apr 23 '15 at 16:09
  • Not if they use contentResolver.openInputStream(uri) to retrieve the file which they should. As I suggested you need to test this and in case some apps are misbehaving offer a legacy way to share apks. My guess is you're hesitating because you're not yet familiar with ContentProviders. All I suggested is standard stuff, nothing out of the ordinary and unless the receiving app is doing undocumented stuff, there's nothing to worry about. – Emanuel Moecklin Apr 23 '15 at 16:24
  • I either didn't do something well, or it didn't work. I will now update my answer to contain what I thought I was supposed to do. Please check it out. – android developer Apr 25 '15 at 18:39
  • I tested it with my own app and Total Commander and it worked like a charm. One of the reasons your implementation doesn't work, is that you don't export the ContentProvider in your manifest. android:exported="true" is a must or the other apps won't be able to access the ContentProvider. I added a section about the manifest declaration to my answer. – Emanuel Moecklin Apr 25 '15 at 20:31
  • Was it the only change needed? I've made the change and "Total Commander" still doesn't create a file on the path I chose for it. It said it can't read the file. – android developer Apr 25 '15 at 20:56
  • OK, it's not as simple as it sounds and without an understanding how it's supposed to work, you won't be able to implement this properly. I therefore changed my answer to include a fully working example. There are two main issues you need to address with the ContentProvider approach and I included a possible solution in my answer. – Emanuel Moecklin Apr 26 '15 at 04:56
  • Thank you . Have you tried it? I will try it at home when I get a chance. BTW, some of the characters you wrote about aren't supported for file names. – android developer Apr 26 '15 at 07:51
  • I tested it and it works like a charm (with Total Commander). It's not about file name characters but Uri characters, two different things. – Emanuel Moecklin Apr 26 '15 at 08:53
  • Both Uri and files names have sets of special characters that shouldn't be used. Does the "base64" method creates a unique string for each path? – android developer Apr 26 '15 at 09:27
  • There's no issue with special characters here, all base64 characters are legal in a uri. Base64 creates a unique string or there would be no way to decode the original string. Google it. – Emanuel Moecklin Apr 26 '15 at 14:28
  • But doesn't Uri have any rules? – android developer Apr 26 '15 at 14:35
  • OK, for the filename and extension, I just implemented it myself. I've made it work, but the output has a lot of characters compared to the solution of symlinks, where I could know which file is linked with which shortcut. I assume it is needed so that for each URI it will give the same file path, but is there a way to customize it somehow? Let's say I would add some info about the file, and only then concat " - " and the extra unique id string? The good thing is that even Gmail accepts this type of transfer. – android developer Apr 26 '15 at 20:26
  • or maybe I can do what I want with "getUniqueName" ? Can I just put a counter there as the additional ID, instead of the Base64 part? BTW, I think that you've confused "fileName" with "filePath" on some methods/variables. – android developer Apr 26 '15 at 20:35
  • Another weird thing is that using the method you've shown, the dialog that shows which apps can handle it is shorter than the list of symlinks, yet I think it's more focused on apps that can really handle it. Maybe that's the correct way to do it. – android developer Apr 26 '15 at 20:58
  • I think it is possible to avoid the output file name to have "junk" characters, by making sure from the URI creation that the file names that are going to be created will have unique names. This is important since they all go in a batch to a single app, so the limitation is that only they need to be unique in names, and not all future ones too. However, I don't get why the list of supported apps is so much smaller (though I think a higher percentage of it makes sense than the attachment method). Can you please take a look? – android developer Apr 26 '15 at 21:03
  • The list of apps is because of the "setType". Your way is more correct than what I've used, as I used a general one of "*", while you use "apk" instead. – android developer Apr 26 '15 at 22:23
  • 1
    1) Yes you can use a simple counter instead of the UUID generated id because each Intent is a separate entity unrelated to any other Intent you might send. 2) The list would be shorter because the scheme is content:// instead of file:// and if an app filters for files only then it wouldn't receive the Intent with content:// data. 3) You can avoid the UUID characters but the file path needs to be encoded somehow. You could use Url encoding instead of Base64 or any other scheme that uses the characters allowed in an Uri. 4) My fileName variable is the full file path including file name. – Emanuel Moecklin Apr 27 '15 at 01:40
  • 1) Yes, thank you. 2) so it's not even possible to use it, but I think it's better to use what you wrote, at least according to what I see. 3) but the file name that the receiving app needs to handle can be without those stuff, right? so I don't think it's an issue. 4) well, I think it's confusing, and I'd instead call it filePath/absolutePath/fullFilePath, like the docs use , as the file-name is just the name of the current file, and not of its parents : http://developer.android.com/reference/java/io/File.html#getName() – android developer Apr 27 '15 at 17:17
  • I've managed to do it all thanks to your help. I will now tick your answer (even though I still think the variables names are confusing :) ). The new version of my app will use this method. Thank you ! – android developer Apr 27 '15 at 21:29
  • It seems that using this "intent.setType(MimeTypeMap.getSingleton().getMimeTypeFromExtension("apk")); " , it misses some apps that can actually handle the APK sending, like the BT app on Motorola devices. Sadly, this means that this is better, even though it shows a lot of "junk" items: intent.setType("*/*"); – android developer May 02 '15 at 13:45
  • Can you please show a snippet of what should be on the receiving side (including manifest) ? previously I didn't need it, but now I'm thinking about adding it. – android developer Sep 22 '15 at 12:02
  • The first code snippet shows the receiving end but there's nothing to set up in the manifest. It's not a broadcast receiver or something but retrieving from a ContentProvider. – Emanuel Moecklin Sep 22 '15 at 12:21
  • ok, tried again, and I think it works fine. However, the cursor columns you've chosen are different than what Google shows:http://developer.android.com/training/sharing/receive.html . How come? – android developer Sep 22 '15 at 13:50
  • The link shows how to process in incoming Intent not how to retrieve from a ContentProvider, not sure what you are referring to? – Emanuel Moecklin Sep 22 '15 at 16:29
  • Oops. This is the correct link: http://developer.android.com/training/secure-file-sharing/retrieve-info.html , and it seems they use the same columns. Weird. I remember I've found something else, with different columns names , like MediaStore.MediaColumns.DISPLAY_NAME – android developer Sep 23 '15 at 20:27
  • Say, what does this API used for : https://developer.android.com/reference/android/support/v4/content/FileProvider.html ? Is it similar to what you did? Is it better? Is it for something else? – android developer Oct 10 '15 at 08:51
  • For FileProvider please see my answer – Emanuel Moecklin Oct 10 '15 at 13:53
  • So it needs the paths somehow? It's quite a bad restriction. But does it provide anything? What is really shown on the SAF window, for example? Is it also FileProvider stuff? – android developer Oct 10 '15 at 21:08
0

Here's a working solution for using SymLinks. Disadvantages:

  1. works from API 14, and not on API 10 , not sure about in between.
  2. uses reflection, so might not work in the future, and on some devices.
  3. must create the symlinks in the path of "getFilesDir", so you have to manage them by yourself, and create unique files names as needed.

The sample shares the APK of the current app.

Code:

public class SymLinkActivity extends Activity{
  @Override
  protected void onCreate(Bundle savedInstanceState)
    {
    super.onCreate(savedInstanceState);
    setContentView(lb.com.myapplication2.R.layout.activity_main);
    final Intent intent=new Intent(Intent.ACTION_SEND_MULTIPLE);
    intent.setType(MimeTypeMap.getSingleton().getMimeTypeFromExtension("apk"));
    final String filePath;
    try
      {
      final android.content.pm.ApplicationInfo applicationInfo=getPackageManager().getApplicationInfo(getPackageName(),0);
      filePath=applicationInfo.sourceDir;
      }
    catch(NameNotFoundException e)
      {
      e.printStackTrace();
      finish();
      return;
      }
    final File file=new File(filePath);
    final String symcLinksFolderPath=getFilesDir().getAbsolutePath();
    findViewById(R.id.button).setOnClickListener(new android.view.View.OnClickListener(){
      @Override
      public void onClick(final android.view.View v)
        {
        final File symlink=new File(symcLinksFolderPath,"CustomizedNameOfApkFile-"+System.currentTimeMillis()+".apk");
        symlink.getParentFile().mkdirs();
        File[] oldSymLinks=new File(symcLinksFolderPath).listFiles();
        if(oldSymLinks!=null)
          {
          for(java.io.File child : oldSymLinks)
            if(child.getName().endsWith(".apk"))
              child.delete();
          }
        symlink.delete();
        // do some dirty reflection to create the symbolic link
        try
          {
          final Class<?> libcore=Class.forName("libcore.io.Libcore");
          final java.lang.reflect.Field fOs=libcore.getDeclaredField("os");
          fOs.setAccessible(true);
          final Object os=fOs.get(null);
          final java.lang.reflect.Method method=os.getClass().getMethod("symlink",String.class,String.class);
          method.invoke(os,file.getAbsolutePath(),symlink.getAbsolutePath());
          final ArrayList<Uri> uris=new ArrayList<>();
          uris.add(Uri.fromFile(symlink));
          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);
          startActivity(intent);
          android.widget.Toast.makeText(SymLinkActivity.this,"succeeded ?",android.widget.Toast.LENGTH_SHORT).show();
          }
        catch(Exception e)
          {
          android.widget.Toast.makeText(SymLinkActivity.this,"failed :(",android.widget.Toast.LENGTH_SHORT).show();
          e.printStackTrace();
          // TODO handle the exception
          }
        }
    });

    }
}

EDIT: for the symlink part, for Android API 21 and above, you can use this instead of reflection :

 Os.symlink(originalFilePath,symLinkFilePath);
android developer
  • 114,585
  • 152
  • 739
  • 1,270