5

Background

I wish to be able to share some files (via a send intent) as a single compressed file, via FileProvider, but without actually creating this file.

For the intent, all you do is add the ArrayList<Uri> as a parameter, as such:

ArrayList<Uri> uris = MyFileProvider.prepareFileProviderFiles(...)
sharingIntent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris)

The problem

A FileProvider can be used to deliver real files to outside apps.

I don't want to have some junk files (the compressed ones, which are meant just for sharing) in my app staying around without any purpose, in case the app that used them has finished, crashed or stopped for some reason.

What I've found

According to the API of FileProvider, I'm supposed to implement real files handling:

By default, FileProvider automatically returns the ParcelFileDescriptor for a file associated with a content:// Uri. To get the ParcelFileDescriptor, call ContentResolver.openFileDescriptor. To override this method, you must provide your own subclass of FileProvider.

So it returns a ParcelFileDescriptor, but according to all of the functions to create ParcelFileDescriptor , I need a real file:

The questions

  1. Is it possible to have it offer a file that doesn't really exist, but is actually a compressed one of a different file/s? A stream of the zipped file, perhaps?

  2. If this is not possible, is there any way for me to avoid having those junk files? Meaning that I would know for sure that it's safe to delete the compressed file/s that I've shared in the past?

  3. If even that isn't possible, how could I decide when it's ok to delete them? Just putting them in the cache folder? I remember that the cache folder doesn't really automatically get handled nicely by the OS, removing old files when needed. Isn't it still correct?

Since I tried this for a long time, I will only accept a working solution that I can test myself.


EDIT: based on the answer below, I've made a tiny sample here. This is its code:

manifest

...
    <provider
        android:name=".ZipFilesProvider"
        android:authorities="${applicationId}.zip_file_provider"
        android:exported="false"
        android:grantUriPermissions="true"/>

ZipFilesProvider.kt

import android.content.ContentProvider
import android.content.ContentValues
import android.content.Context
import android.content.pm.ProviderInfo
import android.database.Cursor
import android.database.MatrixCursor
import android.net.Uri
import android.os.Build
import android.os.ParcelFileDescriptor
import android.provider.MediaStore
import android.provider.OpenableColumns
import java.io.*
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import kotlin.concurrent.thread

class ZipFilesProvider : ContentProvider() {
    override fun onCreate() = true
    override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<String>?) = 0
    override fun delete(uri: Uri, arg1: String?, arg2: Array<String>?) = 0
    override fun insert(uri: Uri, values: ContentValues?): Uri? = null
    override fun getType(uri: Uri) = ZIP_FILE_MIME_TYPE

    override fun attachInfo(context: Context, info: ProviderInfo) {
        super.attachInfo(context, info)
        authority = info.authority
    }

    override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
        val filesPathsToCompress = getFilesPathsToCompress(uri)
        filesPathsToCompress.forEach { if (!it.exists()) throw FileNotFoundException(it.absolutePath) }
        val pipes = if (Build.VERSION.SDK_INT >= 19) ParcelFileDescriptor.createReliablePipe() else ParcelFileDescriptor.createPipe()
        thread {
            val writeFd = pipes[1]
            try {
                ZipOutputStream(FileOutputStream(writeFd.fileDescriptor)).use { zipStream: ZipOutputStream ->
                    filesPathsToCompress.forEach {
                        zipStream.putNextEntry(ZipEntry(it.name))
                        FileInputStream(it).copyTo(zipStream)
                        zipStream.closeEntry()
                    }
                    zipStream.close()
                    writeFd.close()
                }
            } catch (e: IOException) {
                e.printStackTrace()
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
                    try {
                        writeFd.closeWithError(e.message)
                    } catch (e: Exception) {
                        e.printStackTrace()
                    }
                }
            }
        }
        return pipes[0]
    }

    override fun query(uri: Uri, projection: Array<out String>?, selection: String?, selectionArgs: Array<out String>?, sortOrder: String?): Cursor? {
        val filesPathsToCompress = getFilesPathsToCompress(uri)
        val fileToCompressInto = uri.encodedPath!!.substringAfter("/")
        val columnNames = projection ?: arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
        val ret = MatrixCursor(columnNames)
        val values = arrayOfNulls<Any>(columnNames.size)
        for (i in columnNames.indices) {
            when (columnNames[i]) {
                MediaStore.MediaColumns.DISPLAY_NAME -> values[i] = fileToCompressInto
                MediaStore.MediaColumns.SIZE -> {
                    var totalFilesSize = 0L
                    filesPathsToCompress.forEach { totalFilesSize += it.length() }
                    values[i] = totalFilesSize
                }
            }
        }
        ret.addRow(values)
        return ret
    }

    companion object {
        lateinit var authority: String
        const val ZIP_FILE_MIME_TYPE = "application/zip"

        private fun getFilesPathsToCompress(uri: Uri): HashSet<File> {
            val filesPathsToCompress = HashSet<File>(uri.queryParameterNames.size)
            uri.queryParameterNames.forEach {
                val path = uri.getQueryParameters(it)[0]// alternative: String(Base64.decode(uri.getQueryParameters(it)[0], Base64.URL_SAFE))
                filesPathsToCompress.add(File(path))
            }
            return filesPathsToCompress
        }

        fun prepareFilesToShareAsZippedFile(filesToCompress: Collection<String>, zipFileName: String): Uri {
            val builder = Uri.Builder().scheme("content").authority(authority).encodedPath(zipFileName)
            for ((index, filePath) in filesToCompress.withIndex())
                builder.appendQueryParameter(index.toString(), filePath)// alternative: String(Base64.encode(filePath.toByteArray(), Base64.URL_SAFE)))
            return builder.build()
        }
    }
}

MainActivity.kt

import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import java.io.File


class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val installedPackages = packageManager.getInstalledPackages(0)
        val filesToCompress = ArrayList<String>()
        val maxFiles = 3
        var maxTotalSize = 10 * 1024L * 1024L
        for (installedPackage in installedPackages) {
            val filePath = installedPackage.applicationInfo.publicSourceDir
            val file = File(filePath)
            val fileSize = file.length()
            if (maxTotalSize - fileSize >= 0) {
                maxTotalSize-= fileSize
                filesToCompress.add(filePath)
                if (filesToCompress.size >= maxFiles)
                    break
            }
        }
        val uri = ZipFilesProvider.prepareFilesToShareAsZippedFile(filesToCompress, "someZipFile.zip")
        val intent = Intent(Intent.ACTION_SEND).setType(ZipFilesProvider.ZIP_FILE_MIME_TYPE).putExtra(Intent.EXTRA_STREAM, uri)
        startActivity(Intent.createChooser(intent, ""))
    }
}
android developer
  • 114,585
  • 152
  • 739
  • 1,270

1 Answers1

1

Yes, It's possible.

  1. Copy a FileProvider to your code (you'll need it to use some private methods - make them protected). Create your class that extends a FileProvider.

  2. In public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) use ParcelFileDescriptor.createReliablePipe() (or ParcelFileDescriptor.createPipe() for older android) to crete a pipe and a pair of ParcelFileDescriptor for it: readFd and writeFd).

  3. Create a separate thread and use it to zip and write file to writeFd FileDescriptor.

  4. Return another ParcelFileDescriptor (readFd) to read from.

My implementation is here: https://github.com/Babay88/AndroidCodeSamplesB/blob/master/ShareZipped/src/main/java/ru/babay/codesamples/sharezip/


EDIT: code below: (but you'd better check implementation at github, as the class extends a little bit customized FileProvider)

/**
 * File provider intended to zip files on-the-fly.
 * It can send files (just like FileProvider) and zip files.
 *
 * Use {@link ZipableFileProvider#getUriForFile(Context, String, File, boolean)}
 * to create an URI.
 *
 */
//@SuppressWarnings("ALL")
public class ZipableFileProvider extends FileProvider {

    static final String TAG = "ZipableFileProvider";

    /**
     * Just like {@link FileProvider#getUriForFile}, but will create an URI for zipping wile while sending
     * @param context
     * @param authority
     * @param file
     * @param zipFile
     * @return
     */

    public static Uri getUriForFile(@NonNull Context context, @NonNull String authority,
                                    @NonNull File file, boolean zipFile) {
        Uri uri = getUriForFile(context, authority, file);
        if (zipFile) {
            return new Uri.Builder()
                    .scheme(uri.getScheme())
                    .authority(uri.getAuthority())
                    .encodedPath(uri.getPath())
                    .encodedQuery("zip").build();
        }
        return uri;
    }

    @Override
    public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException {
        File file = getFileForUri(uri);
        // if file does not exist -- let parent class handle that
        if (file.exists() && isZip(uri)) {
            if (file.exists()) {
                try {
                    return startZippedPipe(file);
                } catch (IOException e) {
                    Log.e(TAG, "openFile: ", e);
                }
            }
        }
        return super.openFile(uri, mode);
    }

    private boolean isZip(@NonNull Uri uri) {
        return "zip".equals(uri.getQuery());
    }

    @Override
    public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection,
                        @Nullable String[] selectionArgs,
                        @Nullable String sortOrder) {
        // ContentProvider has already checked granted permissions
        File file = mStrategy.getFileForUri(uri);

        if (projection == null) {
            projection = COLUMNS;
        }

        String[] cols = new String[projection.length];
        Object[] values = new Object[projection.length];
        int i = 0;
        for (String col : projection) {
            if (OpenableColumns.DISPLAY_NAME.equals(col)) {
                cols[i] = OpenableColumns.DISPLAY_NAME;
                values[i++] = file.getName() + (isZip(uri) ? ".zip" : "");
            } else if (OpenableColumns.SIZE.equals(col)) {
                // return size of original file; zip-file might differ
                cols[i] = OpenableColumns.SIZE;
                values[i++] = file.length();
            }
        }

        cols = copyOf(cols, i);
        values = copyOf(values, i);

        final MatrixCursor cursor = new MatrixCursor(cols, 1);
        cursor.addRow(values);
        return cursor;
    }

    public static ParcelFileDescriptor startZippedPipe(File file) throws IOException {
        ParcelFileDescriptor[] pipes = Build.VERSION.SDK_INT >= 19 ?
                ParcelFileDescriptor.createReliablePipe() :
                ParcelFileDescriptor.createPipe();
        new Thread(() -> doZipFile(pipes[1], file)).start();
        return pipes[0];
    }

    @RequiresApi(api = Build.VERSION_CODES.KITKAT)
    private static ParcelFileDescriptor startZippedSocketPair(File file) throws IOException {
        ParcelFileDescriptor[] pipes = ParcelFileDescriptor.createReliableSocketPair();
        new Thread(() -> doZipFile(pipes[1], file)).start();
        return pipes[0];
    }

    /**
     * zips and sends a file to a ParcelFileDescriptor writeFd
     *
     * Note that some apps (like Telegram) receives the file at once.
     * Other apps (like Gmail) open the file you share, read some kb and close it,
     * and reopen it later (when you really send the email).
     * So, it's OK if "Broken pipe" exception thrown.
     *
     * @param writeFd
     * @param inputFile
     */
    private static void doZipFile(ParcelFileDescriptor writeFd, File inputFile) {
        long start = System.currentTimeMillis();
        byte[] buf = new byte[1024];
        int writtenSize = 0;
        try (FileInputStream iStream = new FileInputStream(inputFile);
             ZipOutputStream zipStream = new ZipOutputStream(new FileOutputStream(writeFd.getFileDescriptor()))) {

            zipStream.putNextEntry(new ZipEntry(inputFile.getName()));
            int amount;
            while (0 <= (amount = iStream.read(buf))) {
                zipStream.write(buf, 0, amount);
                writtenSize += amount;
            }

            zipStream.closeEntry();
            zipStream.close();
            iStream.close();
            writeFd.close();

            if (BuildConfig.DEBUG)
                Log.d(TAG, "doZipFile: done. it took ms: " + (System.currentTimeMillis() - start));
        } catch (IOException e) {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
                try {
                    writeFd.closeWithError(e.getMessage());
                } catch (IOException e1) {
                    Log.e(TAG, "doZipFile: ", e1);
                }
            }
            if (BuildConfig.DEBUG)
                Log.d(TAG, "doZipFile: written: " + writtenSize, e);
        }
    }
}

You can do the same for multiple files in one zip-file. All you need is:

  1. create an Uri that you can use to extract files list. (note: if you use '.' in Uri query, you will fail. I don't know why. I encode Base64 file names in my code)

  2. write all zip files into one zip stream.

It's implemented in https://github.com/Babay88/AndroidCodeSamplesB/blob/master/ShareZipped/src/main/java/ru/babay/codesamples/sharezip/ZipFilesProvider.java

example activity code to share&zip file (one or multiple): https://github.com/Babay88/AndroidCodeSamplesB/blob/master/sharedzipexample/src/main/java/ru/babay/sharedzipexample/MainActivity.java

babay
  • 4,689
  • 1
  • 26
  • 40
  • Seems promising! Can you please show a full sample for it though? – android developer Nov 18 '19 at 13:35
  • For some reason I can't build&run the app. How come? I've written about this on your repository website: https://github.com/Babay88/AndroidCodeSamplesB/issues/1 – android developer Nov 23 '19 at 11:54
  • Yes, you can't run library project. It does not include activities. Are you sure we need sample activities? – babay Nov 29 '19 at 22:00
  • Well how do you know it's working without using it? Always, when a person prepares a sample library, a POC is made to demonstrate and test that it works... – android developer Nov 30 '19 at 09:02
  • Because I've implemented it in a large project. Tested it. And then copied the resulting code to sample project. ) – babay Dec 11 '19 at 18:46
  • Can you please make a fully working sample, including how it's used? – android developer Dec 12 '19 at 08:24
  • here an example. It's used just like regular file share, but with one single flag. It's easy. https://github.com/Babay88/AndroidCodeSamplesB/blob/master/sharedzipexample/src/main/java/ru/babay/sharedzipexample/MainActivity.java – babay Feb 22 '20 at 19:07
  • Looking at the code, I can see it's for a single file. I asked about multiple files within a zip file, hence the call to "putParcelableArrayListExtra" . Otherwise I could just share the single file as it is. Please show how to use it for multiple files. Also, is it possible to even make it zip within zip? For example, suppose I have 3 files: file1,file2,file3, and I want to share them as a zip that contains file1 and also inside this zip a zipped file that contains file2 and file3. Is it possible? – android developer Feb 22 '20 at 21:21
  • You can compose zip file the way you want. You just need to create a Uri so that you can get the names (paths) of all files from it. The code I've provided shows you how to do that. If you want me to write all the code for you -- it's a job. – babay Feb 23 '20 at 18:51
  • No, it was in the original question: To share a zip file that contains multiple files in it, while not really creating a new zip file beforehand. You don't have to write about the zip within zip if you don't want, but the question still remains about multi-files. – android developer Feb 24 '20 at 06:20
  • ok. How many files? are they in one folder? It's not a problem to zip multiple files. But you need to extract all the files from an uri, or keep them somewhere. – babay Feb 24 '20 at 07:32
  • I didn't want to be so specific because once I see a solution of mulitple ones I would most probably know how to make it more flexible. But let's say it's a List of File instances. Each can be of different path, and it's a list that has at least one item. What do you mean by "But you need to extract all the files from an uri, or keep them somewhere" ? The output is a zip , no? – android developer Feb 24 '20 at 07:50
  • So, you're looking not for an answer, how you can implement it, but for a complete solution. But, nevertheless, I've implemented sending multiple files as one zip https://github.com/Babay88/AndroidCodeSamplesB/commit/eff89992c67b66930b769bdc21bc02b2ad4c5dd1 – babay Feb 24 '20 at 22:37
  • Seems to work. Please update the answer with all the relevant code from Github (and let me know), so that I could grant the bounty. For now, you get the answer marked as accepted. – android developer Feb 25 '20 at 08:59
  • I think it misses a lot of important parts, of the multiple files here (both the preparation and the extration of which files to use, and then to put them into a stream to be sent. Sample seems quite large. I don't think it can fit here. OK, I will set it as un-answered, but grant the bounty instead, because bounty has a due-date. I will try to make it a minimal code that still works and edit it here, ok? – android developer Feb 25 '20 at 17:07
  • 1
    ok. :) Yes, sample is large. But... that's the critical part of a solution. And... I've reworked sharing to apply content roots for every file. Now it feels right. – babay Feb 25 '20 at 18:08
  • OK added my tiny sample. Just 1 small class file, and another for the sample itself that uses it. Seeing this, I accepted your answer. I want to ask something though: Why did you use provider_paths file and "FLAG_GRANT_READ_URI_PERMISSION" ? Both don't seem to be needed here... – android developer Feb 27 '20 at 16:53
  • 1. provider_paths is used in PathStrategy to convert file path into url part. See updated ZipFilesUriInfo. 2. flag FLAG_GRANT_READ_URI_PERMISSION makes Android allow receiving app to gain permission to read the Uri. If you remove the permission -- receiving app will not be able to receive your zip file. Try to remove it and see the result. – babay Feb 28 '20 at 03:21
  • Your code looks great and short. I've just pushed latest commit with updates to ZipFilesUriInfo. It stores file names using PathStrategy and makes nicer Uri. And it is possible to make shorter uri. ) – babay Feb 28 '20 at 03:31
  • 1. But you already know how to convert between them, and the output file doesn't need it (just a name) 2. Still works on my sample without it. What do you mean to make a shorter uri ? You could zip the paths if you want. Speaking of uri, I think it's a bit limited, no? I think a correct way would have been to use DB, but that's just too much for a sample, and if you handle just a few files, it's ok to use it this way – android developer Feb 28 '20 at 07:27
  • You can check how PathStrategy "encodes" file path. It finds longest suitable path in provider_paths and replaces it in filePath with that path name. For example, I have path "./Android/data/ru.babay.sharedzipexample/" with name "dat". And file path "/storage/emulated/0/Android/data/ru.babay.sharedzipexample/files/example.txt" encoded as "dat/files/example.txt". So, if you use good provider_paths, you can significantly shorten the Uri. Just check my actual example for two files. – babay Feb 28 '20 at 19:36
  • Is this a common strategy and practice, or your idea? I think the best way would be to have a DB that will have a queue of all of these requests, and just put the ID of the request in the URI, but then you would also have to manage the DB, and clean it from time to time (not sure when it's best though, maybe have a long timeout for each request). Again, I think that for a sample, what I've made of your sample is good enough to demonstrate how it can work. – android developer Feb 28 '20 at 21:05
  • This is strategy from original, Google's FileProvider. Google's developers created a simple provider that shares files without DB. That was intentional I think. But sharing large amount of files as a single zip requires DB, I think. But for 10, 20, 30 files as a single zip -- Uri is enough. You can check my repo for new ZipFilesUriInfo2 -- one more way to encode files into Uri. all files are in uri path part, not in query. ) – babay Feb 29 '20 at 04:38
  • What do you mean "in uri path part, not in query" ? I thought that the Uri has the query itself inside its parts... I can see in the ZipFilesUriInfo2 that it still has Uri as input. How do you know when the Uri can be too long? What's the longest that it supports? Maybe you can use the simple form when it's short enough, and DB when it got too long – android developer Feb 29 '20 at 09:24
  • Here, updated my answer. Maybe this is what you mean? – android developer Feb 29 '20 at 11:19
  • Path is uri part after authority and before "?". query is Uri part after "?" and before "#" (not use in my Uri). method 1 creates Uri: content://ru.babay.sharedzipexample.provider2/someFiles3.zip?t=mf&0=Y2NoL2V4YW1wbGUuanBn%0A&1=ZGF0L2ZpbGVzL2V4YW1wbGUyLmpwZw%3D%3D%0A method 2 creates Uri: content://ru.babay.sharedzipexample.provider2/cch/example.jpg/dat/files%2Fexample2.jpg/someFiles3.zip?t=mf2 but applications that filter files by file extension will accept Uri from method 2 as .zip and .jpg files :( – babay Feb 29 '20 at 18:11
  • I've got problems with builder.appendQueryParameter(index.toString(), filePath). If I add files with extension (*.jpg) to query part, I ended with android recreating and destroying pipes. And file transfer didn't work. So, I used base64. Dots in path part of Uri works OK. ) – babay Feb 29 '20 at 18:15
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/208778/discussion-between-babay-and-android-developer). – babay Feb 29 '20 at 18:15
  • Wait, so you mean I shouldn't use what I just did? That I should go back to encoding as I initially did ? I actually didn't remember why I did it last time, so I tried it again yet without encoding... Why would a jpg be a problem? – android developer Feb 29 '20 at 20:42
  • Let's move our discussion to chat. Wee should not do a lot of discussion here. The ".jpg" caused Broken Pipe exception. As if the other party somewhy stopped receiving data before real stream end. With some receivivg apps I've got some exceptions and finally the file was sent. In other apps the file was not sent 'cause of exceptions. So, I've used Base64 to avoid that. – babay Mar 01 '20 at 14:59
  • I reported the issue of the logs here: https://issuetracker.google.com/issues/151965838 . You say I can ignore it right? To me it seems like false positive. It claims it has issue of missing grantUriPermission , even though I see that you've set it in the manifest and that it works fine... – android developer Mar 20 '20 at 10:04
  • What are the advantages of using pipe instead of downloading a file to a temp file and returning this file? I see that the stream doesn't have to be deleted later. Is there something else? like is there progress indicator when streaming? – Malachiasz May 05 '22 at 09:05
  • The advantage is that you don't need to store zipped file. You zip it on the fly when it's requested. – babay Jun 16 '22 at 16:40