是否可以在 FileProvider 中提供一个实际上不存在的文件的压缩文件?

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

背景

我希望能够通过 FileProvider 将一些文件(通过发送意图)作为单个压缩文件进行共享,但无需实际创建此文件。

为此,您所做的就是添加 ArrayList<Uri> 作为参数,例如:

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

问题

FileProvider 可用于将真实 文件传送到外部应用程序。

我不希望我的应用程序中有一些垃圾文件(压缩文件,仅用于共享)毫无目的地存在,以防使用它们的应用程序已完成、崩溃或停止运行一些原因。

我发现了什么

根据 FileProvider 的 API,我应该实现真实的文件处理:

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.

所以它 returns 一个 ParcelFileDescriptor,但是根据创建 ParcelFileDescriptor 的所有函数,我需要一个真实的文件:

问题

  1. 是否可以让它提供一个实际上并不存在的文件,但实际上是另一个 file/s 的压缩文件?也许是压缩文件的流?

  2. 如果这不可能,我有什么办法可以避免这些垃圾文件吗?这意味着我可以确定删除我过去共享的压缩 file/s 是安全的吗?

  3. 如果连这都做不到,我如何决定何时可以删除它们?只是将它们放在缓存文件夹中?我记得 OS 并没有真正自动很好地处理缓存文件夹,在需要时删除旧文件。还不对吗?

由于我尝试了很长时间,所以我只会接受一个我可以测试自己的工作方案。


编辑:基于答案, I've made a tiny sample here。这是它的代码:

清单

...
    <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. 将 FileProvider 复制到您的代码中(您需要它来使用一些私有方法 - 使它们受到保护)。创建扩展 FileProvider 的 class。

  2. public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode)中使用ParcelFileDescriptor.createReliablePipe()(或ParcelFileDescriptor.createPipe()用于旧的android)创建一个管道和一对ParcelFileDescriptor为此:readFd 和 writeFd).

  3. 创建一个单独的线程并使用它来压缩和写入文件到 writeFd FileDescriptor。

  4. Return 另一个要读取的 ParcelFileDescriptor (readFd)。

我的实现在这里: https://github.com/Babay88/AndroidCodeSamplesB/blob/master/ShareZipped/src/main/java/ru/babay/codesamples/sharezip/


编辑:代码如下: (但是你最好检查 github 的实现,因为 class 扩展了一点定制的 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);
        }
    }
}

您可以对一个 zip 文件中的多个文件执行相同的操作。您只需要:

  1. 创建可用于提取文件列表的 Uri。 (注意:如果你在Uri查询中使用'.',你会失败。我不知道为什么。我在我的代码中对文件名进行了Base64编码)

  2. 将所有 zip 文件写入一个 zip 流。

已在 https://github.com/Babay88/AndroidCodeSamplesB/blob/master/ShareZipped/src/main/java/ru/babay/codesamples/sharezip/ZipFilesProvider.java

中实施

示例activity 共享&zip 文件(一个或多个)的代码: https://github.com/Babay88/AndroidCodeSamplesB/blob/master/sharedzipexample/src/main/java/ru/babay/sharedzipexample/MainActivity.java