Is it possible to provide a zipped file in FileProvider, of a file that doesn't really exist?

Viewed 964

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, ""))
    }
}
1 Answers

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

Related