chromium/chrome/browser/download/android/java/src/org/chromium/chrome/browser/download/DownloadFileProvider.java

// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.chrome.browser.download;

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.ParcelFileDescriptor;
import android.text.TextUtils;
import android.webkit.MimeTypeMap;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.core.content.FileProvider;

import org.chromium.base.ContentUriUtils;
import org.chromium.base.ContextUtils;
import org.chromium.chrome.browser.download.DownloadDirectoryProvider.SecondaryStorageInfo;
import org.chromium.components.embedder_support.util.UrlConstants;

import java.io.File;
import java.io.FileNotFoundException;

/**
 * Provides data access and generate content URI for downloaded files open or shared to other
 * application. By default, {@link FileProvider} doesn't support any paths on external SD card. This
 * provider can generate content URI for arbitrary path.
 *
 * File on primary storage: /storage/emulated/0/Download/demo.apk
 * generates URI: content://[package name].DownloadFileProvide/download?file=demo.apk
 * Currently the primary storage downloads still use {@link FileProvider} instead of this.
 *
 * File on external SD card pre R: /storage/724E-59EE/Android/data/[package
 * name]/files/Download/demo.apk" generates URI: content://[package
 * name].DownloadFileProvide/download_external?file=demo.apk
 *
 * File on external SD card from R: /storage/724E-59EE/Download/demo.apk"
 *  * generates URI: content://[package name].DownloadFileProvide/external_volume?file=demo.apk
 */
public class DownloadFileProvider extends FileProvider {
    private static final String[] COLUMNS = new String[] {"_display_name", "_size"};
    private static final String URI_AUTHORITY_SUFFIX = ".DownloadFileProvider";

    /** The URI path for downloads on primary storage. */
    private static final String URI_PATH = "download";

    /** The URI path for downloads on external storage before Android R. */
    private static final String URI_EXTERNAL_PATH_LEGACY = "download_external";

    /** The URI path for downloads on external storage from Android R. */
    private static final String URI_EXTERNAL_PATH = "external_volume";

    private static final String URI_QUERY_FILE = "file";

    /**
     * Create content URI on user device.
     * See {@link #createContentUri(String, DownloadDirectoryProvider.Delegate)}
     */
    public static Uri createContentUri(String filePath) {
        return createContentUri(
                filePath, new DownloadDirectoryProvider.DownloadDirectoryProviderDelegate());
    }

    /**
     * Create content uri for a downloaded file, mainly for files on external sd card.
     * @param filePath The file path of the file.
     * @param delegate Delegate that queries download directories.
     * @return The content URI that points to the downloaded file.
     */
    @VisibleForTesting
    public static Uri createContentUri(
            String filePath, DownloadDirectoryProvider.Delegate delegate) {
        // From Android Q, we may already have a content URI generated by the media store. Then
        // just let the media store to handle this content URI.
        if (ContentUriUtils.isContentUri(filePath)) return Uri.parse(filePath);
        if (TextUtils.isEmpty(filePath)) return Uri.EMPTY;

        // Check whether the download is on primary storage.
        File primaryDir = delegate.getPrimaryDownloadDirectory();
        int index = filePath.indexOf(primaryDir.getAbsolutePath());
        if (index == 0 && filePath.length() > primaryDir.getAbsolutePath().length()) {
            return buildUri(
                    URI_PATH, filePath.substring(primaryDir.getAbsolutePath().length() + 1));
        }

        // Check whether the download is in Android R's SD card directory.
        SecondaryStorageInfo info = delegate.getSecondaryStorageDownloadDirectories();
        if (info.directories != null) {
            for (File file : info.directories) {
                if (file == null) continue;
                if (filePath.startsWith(file.getAbsolutePath())) {
                    return buildUri(
                            URI_EXTERNAL_PATH,
                            filePath.substring(file.getAbsolutePath().length() + 1));
                }
            }
        }

        // Check whether the download is in legacy SD card directory pre R.
        if (info.directoriesPreR == null) return Uri.EMPTY;
        for (File file : info.directoriesPreR) {
            if (file == null) continue;
            if (filePath.startsWith(file.getAbsolutePath())) {
                return buildUri(
                        URI_EXTERNAL_PATH_LEGACY,
                        filePath.substring(file.getAbsolutePath().length() + 1));
            }
        }
        return Uri.EMPTY;
    }

    private static Uri buildUri(String path, String query) {
        Uri uri =
                new Uri.Builder()
                        .scheme(UrlConstants.CONTENT_SCHEME)
                        .authority(
                                ContextUtils.getApplicationContext().getPackageName()
                                        + URI_AUTHORITY_SUFFIX)
                        .path(path)
                        .appendQueryParameter(URI_QUERY_FILE, query)
                        .build();
        return uri;
    }

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

    @Override
    public void attachInfo(@NonNull Context context, @NonNull ProviderInfo info) {
        super.attachInfo(context, info);
        if (info.exported) {
            throw new SecurityException("Provider must not be exported");
        } else if (!info.grantUriPermissions) {
            throw new SecurityException("Provider must grant uri permissions");
        }
    }

    @Override
    public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
        String filePath =
                getFilePathFromUri(
                        uri, new DownloadDirectoryProvider.DownloadDirectoryProviderDelegate());
        if (filePath == null) throw new FileNotFoundException();

        int fileMode = modeToMode(mode);
        File file = new File(filePath);
        return ParcelFileDescriptor.open(file, fileMode);
    }

    @Override
    public Cursor query(
            @NonNull Uri uri,
            @Nullable String[] projection,
            @Nullable String selection,
            @Nullable String[] selectionArgs,
            @Nullable String sortOrder) {
        if (projection == null) {
            projection = COLUMNS;
        }
        String[] cols = new String[projection.length];
        Object[] values = new Object[projection.length];

        String filePath =
                getFilePathFromUri(
                        uri, new DownloadDirectoryProvider.DownloadDirectoryProviderDelegate());
        if (TextUtils.isEmpty(filePath)) return new MatrixCursor(cols, 1);

        File file = new File(filePath);
        if (!file.exists() || !file.isFile()) return new MatrixCursor(cols, 1);

        int i = 0;
        String[] projectionCopy = projection;
        int projectionLen = projection.length;

        for (int j = 0; j < projectionLen; ++j) {
            String col = projectionCopy[j];
            if ("_display_name".equals(col)) {
                cols[i] = "_display_name";
                values[i++] = file.getName();
            } else if ("_size".equals(col)) {
                cols[i] = "_size";
                values[i++] = Long.valueOf(file.length());
            }
        }
        cols = copyOf(cols, i);
        values = copyOf(values, i);
        MatrixCursor cursor = new MatrixCursor(cols, 1);
        cursor.addRow(values);
        return cursor;
    }

    @Override
    public String getType(Uri uri) {
        if (uri == null) return null;

        String filePath = uri.getQueryParameter(URI_QUERY_FILE);
        if (TextUtils.isEmpty(filePath)) return null;
        return getMimeTypeFromUri(Uri.parse(filePath));
    }

    @Override
    public Uri insert(Uri uri, ContentValues contentValues) {
        throw new UnsupportedOperationException("No external inserts");
    }

    @Override
    public int delete(Uri uri, String s, String[] strings) {
        throw new UnsupportedOperationException("No external deletes");
    }

    @Override
    public int update(Uri uri, ContentValues contentValues, String s, String[] strings) {
        throw new UnsupportedOperationException("No external updates");
    }

    private static String getMimeTypeFromUri(Uri fileUri) {
        String extension = MimeTypeMap.getFileExtensionFromUrl(fileUri.toString());
        return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
    }

    /** Copy from {@link FileProvider}. */
    private static String[] copyOf(String[] original, int newLength) {
        String[] result = new String[newLength];
        System.arraycopy(original, 0, result, 0, newLength);
        return result;
    }

    /** Copy from {@link FileProvider}. */
    private static Object[] copyOf(Object[] original, int newLength) {
        Object[] result = new Object[newLength];
        System.arraycopy(original, 0, result, 0, newLength);
        return result;
    }

    /** Copy from {@link FileProvider}. */
    private static int modeToMode(String mode) {
        int modeBits;
        if ("r".equals(mode)) {
            modeBits = 268435456;
        } else if (!"w".equals(mode) && !"wt".equals(mode)) {
            if ("wa".equals(mode)) {
                modeBits = 704643072;
            } else if ("rw".equals(mode)) {
                modeBits = 939524096;
            } else {
                if (!"rwt".equals(mode)) {
                    throw new IllegalArgumentException("Invalid mode: " + mode);
                }

                modeBits = 1006632960;
            }
        } else {
            modeBits = 738197504;
        }

        return modeBits;
    }

    /**
     * Get file path based on URI. The file must live in default download directory or external
     * removable storage.
     * @param uri The content URI to parse.
     * @return The absolute path of the file or null if the file is not in known download directory
     *         or null if the file doesn't exist.
     */
    @VisibleForTesting
    public static String getFilePathFromUri(Uri uri, DownloadDirectoryProvider.Delegate delegate) {
        if (uri == null) return null;
        String path = uri.getPath();
        if (TextUtils.isEmpty(path)) return null;
        if (path.charAt(0) == File.separatorChar && path.length() > 1) path = path.substring(1);

        // Path traverse to parent is not allowed.
        String query = uri.getQueryParameter(URI_QUERY_FILE);
        if (query.contains(".." + File.separator)) return null;

        // Parse download on primary storage.
        if (path.equals(URI_PATH)) {
            File primaryDir = delegate.getPrimaryDownloadDirectory();
            return primaryDir + File.separator + query;
        }

        // Parse download on external SD card on new Android R directory.
        SecondaryStorageInfo info = delegate.getSecondaryStorageDownloadDirectories();
        if (path.equals(URI_EXTERNAL_PATH) && !info.directories.isEmpty()) {
            // Only supports one directory.
            return info.directories.get(0).getAbsolutePath() + File.separator + query;
        }

        // Parse download on legacy SD card directory. On R, we also parse this to support existing
        // downloads before R.
        if (path.equals(URI_EXTERNAL_PATH_LEGACY) && !info.directoriesPreR.isEmpty()) {
            // Only supports one directory.
            return info.directoriesPreR.get(0).getAbsolutePath() + File.separator + query;
        }
        return null;
    }
}