Commit 680e3222 authored by Min Qin's avatar Min Qin Committed by Commit Bot

Upstream internal DownloadCollectionBridge code

This will allow download in public chromium build to work on Q with MediaStore.

BUG=1052490

Change-Id: I31476292b92c7a5882fa4ad80cc60b29f6b14dba
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2057806Reviewed-by: default avatarRobert Sesek <rsesek@chromium.org>
Reviewed-by: default avatarYaron Friedman <yfriedman@chromium.org>
Reviewed-by: default avatarDavid Trainor <dtrainor@chromium.org>
Commit-Queue: Min Qin <qinmin@chromium.org>
Cr-Commit-Position: refs/heads/master@{#746867}
parent e9f50dd9
...@@ -56,7 +56,6 @@ import org.chromium.chrome.browser.webauth.Fido2ApiHandler; ...@@ -56,7 +56,6 @@ import org.chromium.chrome.browser.webauth.Fido2ApiHandler;
import org.chromium.chrome.browser.xsurface.SurfaceDependencyProvider; import org.chromium.chrome.browser.xsurface.SurfaceDependencyProvider;
import org.chromium.chrome.browser.xsurface.SurfaceRenderer; import org.chromium.chrome.browser.xsurface.SurfaceRenderer;
import org.chromium.components.browser_ui.widget.FeatureHighlightProvider; import org.chromium.components.browser_ui.widget.FeatureHighlightProvider;
import org.chromium.components.download.DownloadCollectionBridge;
import org.chromium.components.signin.AccountManagerDelegate; import org.chromium.components.signin.AccountManagerDelegate;
import org.chromium.components.signin.SystemAccountManagerDelegate; import org.chromium.components.signin.SystemAccountManagerDelegate;
import org.chromium.content_public.browser.UiThreadTaskTraits; import org.chromium.content_public.browser.UiThreadTaskTraits;
...@@ -329,13 +328,6 @@ public abstract class AppHooks { ...@@ -329,13 +328,6 @@ public abstract class AppHooks {
return new FeatureHighlightProvider(); return new FeatureHighlightProvider();
} }
/**
* @return A new {@link DownloadCollectionBridge} instance.
*/
public DownloadCollectionBridge getDownloadCollectionBridge() {
return DownloadCollectionBridge.getDownloadCollectionBridge();
}
/** /**
* @return A new {@link DigitalWellbeingClient} instance. * @return A new {@link DigitalWellbeingClient} instance.
*/ */
......
...@@ -50,6 +50,7 @@ import org.chromium.chrome.browser.preferences.SharedPreferencesManager; ...@@ -50,6 +50,7 @@ import org.chromium.chrome.browser.preferences.SharedPreferencesManager;
import org.chromium.chrome.browser.profiles.Profile; import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.profiles.ProfileManager; import org.chromium.chrome.browser.profiles.ProfileManager;
import org.chromium.chrome.browser.util.ConversionUtils; import org.chromium.chrome.browser.util.ConversionUtils;
import org.chromium.components.download.DownloadCollectionBridge;
import org.chromium.components.download.DownloadState; import org.chromium.components.download.DownloadState;
import org.chromium.components.feature_engagement.EventConstants; import org.chromium.components.feature_engagement.EventConstants;
import org.chromium.components.feature_engagement.Tracker; import org.chromium.components.feature_engagement.Tracker;
...@@ -278,6 +279,7 @@ public class DownloadManagerService implements DownloadController.Observer, ...@@ -278,6 +279,7 @@ public class DownloadManagerService implements DownloadController.Observer,
mHandler = handler; mHandler = handler;
mDownloadSnackbarController = new DownloadSnackbarController(); mDownloadSnackbarController = new DownloadSnackbarController();
mOMADownloadHandler = new OMADownloadHandler(applicationContext); mOMADownloadHandler = new OMADownloadHandler(applicationContext);
DownloadCollectionBridge.setDownloadDelegate(new DownloadDelegateImpl());
// Note that this technically leaks the native object, however, DownloadManagerService // Note that this technically leaks the native object, however, DownloadManagerService
// is a singleton that lives forever and there's no clean shutdown of Chrome on Android. // is a singleton that lives forever and there's no clean shutdown of Chrome on Android.
init(); init();
......
...@@ -16,7 +16,6 @@ import android.text.style.StyleSpan; ...@@ -16,7 +16,6 @@ import android.text.style.StyleSpan;
import android.view.View; import android.view.View;
import android.webkit.MimeTypeMap; import android.webkit.MimeTypeMap;
import org.chromium.base.BuildInfo;
import org.chromium.base.ContextUtils; import org.chromium.base.ContextUtils;
import org.chromium.base.annotations.CalledByNative; import org.chromium.base.annotations.CalledByNative;
import org.chromium.base.task.AsyncTask; import org.chromium.base.task.AsyncTask;
...@@ -84,11 +83,8 @@ public class DuplicateDownloadInfoBar extends ConfirmInfoBar { ...@@ -84,11 +83,8 @@ public class DuplicateDownloadInfoBar extends ConfirmInfoBar {
new AsyncTask<String>() { new AsyncTask<String>() {
@Override @Override
protected String doInBackground() { protected String doInBackground() {
if (BuildInfo.isAtLeastQ() if (DownloadCollectionBridge.shouldPublishDownload(mFilePath)) {
&& DownloadCollectionBridge.getDownloadCollectionBridge() Uri uri = DownloadCollectionBridge.getDownloadUriForFileName(filename);
.needToPublishDownload(mFilePath)) {
Uri uri = DownloadCollectionBridge.getDownloadCollectionBridge()
.getDownloadUriForFileName(filename);
return uri == null ? null : uri.toString(); return uri == null ? null : uri.toString();
} else { } else {
if (file.exists()) return mFilePath; if (file.exists()) return mFilePath;
......
...@@ -81,7 +81,6 @@ import org.chromium.chrome.browser.util.ConversionUtils; ...@@ -81,7 +81,6 @@ import org.chromium.chrome.browser.util.ConversionUtils;
import org.chromium.chrome.browser.webapps.WebApkVersionManager; import org.chromium.chrome.browser.webapps.WebApkVersionManager;
import org.chromium.chrome.browser.webapps.WebappRegistry; import org.chromium.chrome.browser.webapps.WebappRegistry;
import org.chromium.components.background_task_scheduler.BackgroundTaskSchedulerFactory; import org.chromium.components.background_task_scheduler.BackgroundTaskSchedulerFactory;
import org.chromium.components.download.DownloadCollectionBridge;
import org.chromium.components.minidump_uploader.CrashFileManager; import org.chromium.components.minidump_uploader.CrashFileManager;
import org.chromium.components.signin.AccountManagerFacade; import org.chromium.components.signin.AccountManagerFacade;
import org.chromium.components.signin.AccountsChangeObserver; import org.chromium.components.signin.AccountsChangeObserver;
...@@ -185,11 +184,6 @@ public class ProcessInitializationHandler { ...@@ -185,11 +184,6 @@ public class ProcessInitializationHandler {
application, ChromePreferenceKeys.SYNC_SESSIONS_UUID), application, ChromePreferenceKeys.SYNC_SESSIONS_UUID),
false); false);
// Set up the DownloadCollectionBridge early as display names may be immediately retrieved
// after native is loaded.
DownloadCollectionBridge.setDownloadCollectionBridge(
AppHooks.get().getDownloadCollectionBridge());
// De-jelly can also be controlled by a system property. As sandboxed processes can't // De-jelly can also be controlled by a system property. As sandboxed processes can't
// read this property directly, convert it to the equivalent command line flag. // read this property directly, convert it to the equivalent command line flag.
if (DeJellyUtils.externallyEnableDeJelly()) { if (DeJellyUtils.externallyEnableDeJelly()) {
......
...@@ -8,6 +8,7 @@ android_library("java") { ...@@ -8,6 +8,7 @@ android_library("java") {
sources = [ sources = [
"java/src/org/chromium/chrome/browser/download/DirectoryOption.java", "java/src/org/chromium/chrome/browser/download/DirectoryOption.java",
"java/src/org/chromium/chrome/browser/download/DownloadConstants.java", "java/src/org/chromium/chrome/browser/download/DownloadConstants.java",
"java/src/org/chromium/chrome/browser/download/DownloadDelegateImpl.java",
"java/src/org/chromium/chrome/browser/download/DownloadDirectoryProvider.java", "java/src/org/chromium/chrome/browser/download/DownloadDirectoryProvider.java",
"java/src/org/chromium/chrome/browser/download/DownloadFileProvider.java", "java/src/org/chromium/chrome/browser/download/DownloadFileProvider.java",
"java/src/org/chromium/chrome/browser/download/DownloadFilter.java", "java/src/org/chromium/chrome/browser/download/DownloadFilter.java",
...@@ -24,6 +25,7 @@ android_library("java") { ...@@ -24,6 +25,7 @@ android_library("java") {
"//base:base_java", "//base:base_java",
"//base:jni_java", "//base:jni_java",
"//chrome/browser/util:java", "//chrome/browser/util:java",
"//components/download/internal/common:internal_java",
"//components/download/public/common:public_java", "//components/download/public/common:public_java",
"//components/offline_items_collection/core:core_java", "//components/offline_items_collection/core:core_java",
"//content/public/android:content_java", "//content/public/android:content_java",
......
include_rules = [ include_rules = [
"+media/video", "+media/video",
"+content/public/android/java/src/org/chromium/content_public", "+content/public/android/java/src/org/chromium/content_public",
"+components/download/internal/common",
] ]
// Copyright 2020 The Chromium Authors. All rights reserved.
// 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.net.Uri;
import org.chromium.components.download.DownloadDelegate;
/**
* Utility class that implements DownloadDelegate.
*/
public class DownloadDelegateImpl extends DownloadDelegate {
public DownloadDelegateImpl() {}
@Override
public String remapGenericMimeType(String mimeType, String url, String filename) {
return MimeUtils.remapGenericMimeType(mimeType, url, filename);
}
@Override
public Uri parseOriginalUrl(String originalUrl) {
return UriUtils.parseOriginalUrl(originalUrl);
}
@Override
public boolean isDownloadOnSDCard(String filePath) {
return DownloadDirectoryProvider.isDownloadOnSDCard(filePath);
}
}
...@@ -101,11 +101,15 @@ source_set("internal") { ...@@ -101,11 +101,15 @@ source_set("internal") {
if (is_android) { if (is_android) {
android_library("internal_java") { android_library("internal_java") {
sources = [ "android/java/src/org/chromium/components/download/DownloadCollectionBridge.java" ] sources = [
"android/java/src/org/chromium/components/download/DownloadCollectionBridge.java",
"android/java/src/org/chromium/components/download/DownloadDelegate.java",
]
deps = [ deps = [
"//base:base_java", "//base:base_java",
"//base:jni_java", "//base:jni_java",
"//third_party/android_provider:android_provider_java",
] ]
annotation_processor_deps = [ "//base/android/jni_generator:jni_processor" ] annotation_processor_deps = [ "//base/android/jni_generator:jni_processor" ]
} }
......
...@@ -4,26 +4,59 @@ ...@@ -4,26 +4,59 @@
package org.chromium.components.download; package org.chromium.components.download;
import android.annotation.TargetApi;
import android.content.ContentResolver; import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.database.Cursor;
import android.net.Uri; import android.net.Uri;
import android.os.Build;
import android.os.FileUtils;
import android.os.ParcelFileDescriptor; import android.os.ParcelFileDescriptor;
import android.provider.BaseColumns;
import android.provider.MediaStore;
import android.provider.MediaStore.Downloads;
import android.provider.MediaStore.MediaColumns;
import android.text.TextUtils;
import android.text.format.DateUtils;
import androidx.annotation.NonNull;
import org.chromium.base.BuildInfo;
import org.chromium.base.ContextUtils; import org.chromium.base.ContextUtils;
import org.chromium.base.Log; import org.chromium.base.Log;
import org.chromium.base.annotations.CalledByNative; import org.chromium.base.annotations.CalledByNative;
import org.chromium.base.annotations.JNINamespace; import org.chromium.base.annotations.JNINamespace;
import org.chromium.base.annotations.NativeMethods; import org.chromium.base.annotations.NativeMethods;
import org.chromium.third_party.android.provider.MediaStoreUtils;
import org.chromium.third_party.android.provider.MediaStoreUtils.PendingParams;
import org.chromium.third_party.android.provider.MediaStoreUtils.PendingSession;
import java.io.FileInputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.Locale;
/** /**
* Helper class for publishing download files to the public download collection. * Helper class for publishing download files to the public download collection.
*/ */
@JNINamespace("download") @JNINamespace("download")
public class DownloadCollectionBridge { public class DownloadCollectionBridge {
// Singleton instance that allows embedders to replace their implementation.
private static DownloadCollectionBridge sDownloadCollectionBridge;
private static final String TAG = "DownloadCollection"; private static final String TAG = "DownloadCollection";
// Guards access to sDownloadCollectionBridge.
private static final Object sLock = new Object(); // File name pattern to be used when media store has too many duplicates. This matches
// that of download_path_reservation_tracker.cc.
private static final String FILE_NAME_PATTERN = "yyyy-MM-dd'T'HHmmss.SSS";
private static final List<String> COMMON_DOUBLE_EXTENSIONS =
new ArrayList<String>(Arrays.asList("tar.gz", "tar.z", "tar.bz2", "tar.bz", "user.js"));
private static DownloadDelegate sDownloadDelegate = new DownloadDelegate();
/** /**
* Class representing the Uri and display name pair for downloads. * Class representing the Uri and display name pair for downloads.
...@@ -49,127 +82,13 @@ public class DownloadCollectionBridge { ...@@ -49,127 +82,13 @@ public class DownloadCollectionBridge {
} }
/** /**
* Return getDownloadCollectionBridge singleton. * Sets the DownloadDelegate to be used for utility methods.
*/ * TODO(qinmin): remove this method once we moved all the utility methods into
public static DownloadCollectionBridge getDownloadCollectionBridge() { * components/.
synchronized (sLock) { * @param downloadDelegate The new delegate to be used.
if (sDownloadCollectionBridge == null) {
sDownloadCollectionBridge = new DownloadCollectionBridge();
}
}
return sDownloadCollectionBridge;
}
/**
* Sets the singlton object to use later.
*/
public static void setDownloadCollectionBridge(DownloadCollectionBridge bridge) {
synchronized (sLock) {
sDownloadCollectionBridge = bridge;
}
}
/**
* Returns whether a download needs to be published.
* @param filePath File path of the download.
* @return True if the download needs to be published, or false otherwise.
*/
public boolean needToPublishDownload(final String filePath) {
return false;
}
/**
* Creates a pending session for download to be written into.
* @param fileName Name of the file.
* @param mimeType Mime type of the file.
* @param originalUrl Originating URL of the download.
* @param referrer Referrer of the download.
* @return Uri created for the pending session.
*/
protected Uri createPendingSession(final String fileName, final String mimeType,
final String originalUrl, final String referrer) {
return null;
}
/**
* Copy file content from a source file to the pending Uri.
* @param sourcePath File content to be copied from.
* @param pendingUri Destination Uri to be copied to.
* @return true on success, or false otherwise.
*/
protected boolean copyFileToPendingUri(final String sourcePath, final String pendingUri) {
return false;
}
/**
* Abandon the the intermediate Uri.
* @param pendingUri Intermediate Uri that is going to be deleted.
*/
protected void abandonPendingUri(final String pendingUri) {}
/**
* Publish a completed download to public repository.
* @param pendingUri Pending uri to publish.
* @return Uri of the published file.
*/
protected Uri publishCompletedDownload(final String pendingUri) {
return null;
}
/**
* Gets the content URI of the download that has the given file name.
* @param pendingUri name of the file.
* @return Uri of the download with the given display name.
*/ */
public Uri getDownloadUriForFileName(final String fileName) { public static void setDownloadDelegate(DownloadDelegate downloadDelegate) {
return null; sDownloadDelegate = downloadDelegate;
}
/**
* Renames a download Uri with a display name.
* @param downloadUri Uri of the download.
* @param displayName New display name for the download.
* @return whether rename was successful.
*/
protected boolean rename(final String downloadUri, final String displayName) {
return false;
}
/**
* @return Whether download display names needs to be retrieved.
*/
protected boolean needToGetDisplayNames() {
return false;
}
/**
* Gets the display names for all downloads
* @return an array of download Uri and display name pair.
*/
protected DisplayNameInfo[] getDisplayNames() {
return null;
}
/**
* @return whether download collection is supported.
*/
protected boolean isDownloadCollectionSupported() {
return false;
}
/**
* Refreshes the expiration date so the unpublished download won't get abandoned.
* @param intermediateUri The intermediate Uri that is not yet published.
*/
protected void refreshExpirationDate(final String intermediateUri) {}
/**
* Gets the display name for a download.
* @param downloadUri Uri of the download.
* @return the display name of the download.
*/
protected String getDisplayNameForUri(final String downloadUri) {
return null;
} }
/** /**
...@@ -183,8 +102,20 @@ public class DownloadCollectionBridge { ...@@ -183,8 +102,20 @@ public class DownloadCollectionBridge {
@CalledByNative @CalledByNative
public static String createIntermediateUriForPublish(final String fileName, public static String createIntermediateUriForPublish(final String fileName,
final String mimeType, final String originalUrl, final String referrer) { final String mimeType, final String originalUrl, final String referrer) {
Uri uri = getDownloadCollectionBridge().createPendingSession( Uri uri = createPendingSessionInternal(fileName, mimeType, originalUrl, referrer);
fileName, mimeType, originalUrl, referrer); if (uri != null) return uri.toString();
// If there are too many duplicates on the same file name, createPendingSessionInternal()
// will return null. Generate a new file name with timestamp.
SimpleDateFormat sdf = new SimpleDateFormat(FILE_NAME_PATTERN, Locale.getDefault());
// Remove the extension first.
String baseName = getBaseName(fileName);
String extension = fileName.substring(baseName.length());
StringBuilder sb = new StringBuilder(baseName);
sb.append(" - ");
sb.append(sdf.format(new Date()));
sb.append(extension);
uri = createPendingSessionInternal(sb.toString(), mimeType, originalUrl, referrer);
return uri == null ? null : uri.toString(); return uri == null ? null : uri.toString();
} }
...@@ -194,8 +125,13 @@ public class DownloadCollectionBridge { ...@@ -194,8 +125,13 @@ public class DownloadCollectionBridge {
* @return True if the download needs to be published, or false otherwise. * @return True if the download needs to be published, or false otherwise.
*/ */
@CalledByNative @CalledByNative
private static boolean shouldPublishDownload(final String filePath) { public static boolean shouldPublishDownload(final String filePath) {
return getDownloadCollectionBridge().needToPublishDownload(filePath); if (isAtLeastQ()) {
if (filePath == null) return false;
// Only need to publish downloads that are on primary storage.
return !sDownloadDelegate.isDownloadOnSDCard(filePath);
}
return false;
} }
/** /**
...@@ -205,9 +141,21 @@ public class DownloadCollectionBridge { ...@@ -205,9 +141,21 @@ public class DownloadCollectionBridge {
* @return True on success, or false otherwise. * @return True on success, or false otherwise.
*/ */
@CalledByNative @CalledByNative
@TargetApi(29)
public static boolean copyFileToIntermediateUri( public static boolean copyFileToIntermediateUri(
final String sourcePath, final String destinationUri) { final String sourcePath, final String destinationUri) {
return getDownloadCollectionBridge().copyFileToPendingUri(sourcePath, destinationUri); try {
PendingSession session = openPendingUri(destinationUri);
OutputStream out = session.openOutputStream();
InputStream in = new FileInputStream(sourcePath);
FileUtils.copy(in, out);
in.close();
out.close();
return true;
} catch (Exception e) {
Log.e(TAG, "Unable to copy content to pending Uri.", e);
}
return false;
} }
/** /**
...@@ -216,7 +164,8 @@ public class DownloadCollectionBridge { ...@@ -216,7 +164,8 @@ public class DownloadCollectionBridge {
*/ */
@CalledByNative @CalledByNative
public static void deleteIntermediateUri(final String uri) { public static void deleteIntermediateUri(final String uri) {
getDownloadCollectionBridge().abandonPendingUri(uri); PendingSession session = openPendingUri(uri);
session.abandon();
} }
/** /**
...@@ -226,8 +175,34 @@ public class DownloadCollectionBridge { ...@@ -226,8 +175,34 @@ public class DownloadCollectionBridge {
*/ */
@CalledByNative @CalledByNative
public static String publishDownload(final String intermediateUri) { public static String publishDownload(final String intermediateUri) {
Uri uri = getDownloadCollectionBridge().publishCompletedDownload(intermediateUri); // Android Q's MediaStore.Downloads has an issue that the custom mime type which is not
return uri == null ? null : uri.toString(); // supported by MimeTypeMap is overridden to "application/octet-stream" when publishing.
// To deal with this issue we set the mime type again after publishing.
// See crbug.com/1010829 for more details.
ContentResolver resolver = ContextUtils.getApplicationContext().getContentResolver();
String mimeType = null;
Cursor cursor = null;
try {
cursor = resolver.query(Uri.parse(intermediateUri),
new String[] {MediaColumns.MIME_TYPE}, null, null, null);
if (cursor != null && cursor.getCount() != 0 && cursor.moveToNext()) {
mimeType = cursor.getString(cursor.getColumnIndex(MediaColumns.MIME_TYPE));
}
} catch (Exception e) {
Log.e(TAG, "Unable to get mimeType.", e);
} finally {
if (cursor != null) cursor.close();
}
PendingSession session = openPendingUri(intermediateUri);
Uri publishedUri = session.publish();
if (!TextUtils.isEmpty(mimeType)) {
final ContentValues updateValues = new ContentValues();
updateValues.put(MediaColumns.MIME_TYPE, mimeType);
resolver.update(publishedUri, updateValues, null, null);
}
return publishedUri.toString();
} }
/** /**
...@@ -241,7 +216,10 @@ public class DownloadCollectionBridge { ...@@ -241,7 +216,10 @@ public class DownloadCollectionBridge {
ContentResolver resolver = ContextUtils.getApplicationContext().getContentResolver(); ContentResolver resolver = ContextUtils.getApplicationContext().getContentResolver();
ParcelFileDescriptor pfd = ParcelFileDescriptor pfd =
resolver.openFileDescriptor(Uri.parse(intermediateUri), "rw"); resolver.openFileDescriptor(Uri.parse(intermediateUri), "rw");
getDownloadCollectionBridge().refreshExpirationDate(intermediateUri); ContentValues updateValues = new ContentValues();
updateValues.put("date_expires", getNewExpirationTime());
ContextUtils.getApplicationContext().getContentResolver().update(
Uri.parse(intermediateUri), updateValues, null, null);
return pfd.detachFd(); return pfd.detachFd();
} catch (Exception e) { } catch (Exception e) {
Log.e(TAG, "Cannot open intermediate Uri.", e); Log.e(TAG, "Cannot open intermediate Uri.", e);
...@@ -250,12 +228,13 @@ public class DownloadCollectionBridge { ...@@ -250,12 +228,13 @@ public class DownloadCollectionBridge {
} }
/** /**
* Check if a download with the same name already exists.
* @param fileName The name of the file to check.
* @return whether a download with the file name exists. * @return whether a download with the file name exists.
*/ */
@CalledByNative @CalledByNative
private static boolean fileNameExists(final String fileName) { private static boolean fileNameExists(final String fileName) {
Uri uri = getDownloadCollectionBridge().getDownloadUriForFileName(fileName); return getDownloadUriForFileName(fileName) != null;
return uri != null;
} }
/** /**
...@@ -266,7 +245,12 @@ public class DownloadCollectionBridge { ...@@ -266,7 +245,12 @@ public class DownloadCollectionBridge {
*/ */
@CalledByNative @CalledByNative
private static boolean renameDownloadUri(final String downloadUri, final String displayName) { private static boolean renameDownloadUri(final String downloadUri, final String displayName) {
return getDownloadCollectionBridge().rename(downloadUri, displayName); final ContentValues updateValues = new ContentValues();
Uri uri = Uri.parse(downloadUri);
updateValues.put(MediaColumns.DISPLAY_NAME, displayName);
return ContextUtils.getApplicationContext().getContentResolver().update(
uri, updateValues, null, null)
== 1;
} }
/** /**
...@@ -274,7 +258,7 @@ public class DownloadCollectionBridge { ...@@ -274,7 +258,7 @@ public class DownloadCollectionBridge {
*/ */
@CalledByNative @CalledByNative
private static boolean needToRetrieveDisplayNames() { private static boolean needToRetrieveDisplayNames() {
return getDownloadCollectionBridge().needToGetDisplayNames(); return isAtLeastQ();
} }
/** /**
...@@ -282,15 +266,63 @@ public class DownloadCollectionBridge { ...@@ -282,15 +266,63 @@ public class DownloadCollectionBridge {
* @return an array of download Uri and display name pair. * @return an array of download Uri and display name pair.
*/ */
@CalledByNative @CalledByNative
@TargetApi(29)
private static DisplayNameInfo[] getDisplayNamesForDownloads() { private static DisplayNameInfo[] getDisplayNamesForDownloads() {
return getDownloadCollectionBridge().getDisplayNames(); ContentResolver resolver = ContextUtils.getApplicationContext().getContentResolver();
Cursor cursor = null;
try {
Uri uri = Downloads.EXTERNAL_CONTENT_URI;
cursor = resolver.query(MediaStore.setIncludePending(uri),
new String[] {BaseColumns._ID, MediaColumns.DISPLAY_NAME}, null, null, null);
if (cursor == null || cursor.getCount() == 0) return null;
List<DisplayNameInfo> infos = new ArrayList<DisplayNameInfo>();
while (cursor.moveToNext()) {
String displayName =
cursor.getString(cursor.getColumnIndex(MediaColumns.DISPLAY_NAME));
Uri downloadUri = ContentUris.withAppendedId(
uri, cursor.getInt(cursor.getColumnIndex(BaseColumns._ID)));
infos.add(new DisplayNameInfo(downloadUri.toString(), displayName));
}
return infos.toArray(new DisplayNameInfo[0]);
} catch (Exception e) {
Log.e(TAG, "Unable to get display names for downloads.", e);
} finally {
if (cursor != null) cursor.close();
}
return null;
} }
/** /**
* @return whether download collection is supported. * @return whether download collection is supported.
*/ */
public static boolean supportsDownloadCollection() { public static boolean supportsDownloadCollection() {
return getDownloadCollectionBridge().isDownloadCollectionSupported(); return isAtLeastQ();
}
/**
* Gets the content URI of the download that has the given file name.
* @param pendingUri name of the file.
* @return Uri of the download with the given display name.
*/
@TargetApi(29)
public static Uri getDownloadUriForFileName(String fileName) {
Cursor cursor = null;
try {
Uri uri = Downloads.EXTERNAL_CONTENT_URI;
cursor = ContextUtils.getApplicationContext().getContentResolver().query(
MediaStore.setIncludePending(uri), new String[] {BaseColumns._ID},
"_display_name LIKE ?1", new String[] {fileName}, null);
if (cursor == null) return null;
if (cursor.moveToNext()) {
return ContentUris.withAppendedId(
uri, cursor.getInt(cursor.getColumnIndex(BaseColumns._ID)));
}
} catch (Exception e) {
Log.e(TAG, "Unable to check file name existence.", e);
} finally {
if (cursor != null) cursor.close();
}
return null;
} }
/** /**
...@@ -300,6 +332,93 @@ public class DownloadCollectionBridge { ...@@ -300,6 +332,93 @@ public class DownloadCollectionBridge {
return DownloadCollectionBridgeJni.get().getExpirationDurationInDays(); return DownloadCollectionBridgeJni.get().getExpirationDurationInDays();
} }
private static boolean isAtLeastQ() {
return BuildInfo.isAtLeastQ() || Build.VERSION.SDK_INT >= 29;
}
/**
* Helper method to create a pending session for download to be written into.
* @param fileName Name of the file.
* @param mimeType Mime type of the file.
* @param originalUrl Originating URL of the download.
* @param referrer Referrer of the download.
* @return Uri created for the pending session, or null if failed.
*/
private static Uri createPendingSessionInternal(final String fileName, final String mimeType,
final String originalUrl, final String referrer) {
PendingParams pendingParams =
createPendingParams(fileName, mimeType, originalUrl, referrer);
pendingParams.setExpirationTime(getNewExpirationTime());
try {
return MediaStoreUtils.createPending(
ContextUtils.getApplicationContext(), pendingParams);
} catch (Exception e) {
return null;
}
}
/**
* Helper method to create PendingParams needed for PendingSession creation.
* @param fileName Name of the file.
* @param mimeType Mime type of the file.
* @param originalUrl Originating URL of the download.
* @param referrer Referrer of the download.
* @return PendingParams needed for creating the PendingSession.
*/
@TargetApi(29)
private static PendingParams createPendingParams(final String fileName, final String mimeType,
final String originalUrl, final String referrer) {
Uri downloadsUri = Downloads.EXTERNAL_CONTENT_URI;
String newMimeType =
sDownloadDelegate.remapGenericMimeType(mimeType, originalUrl, fileName);
PendingParams pendingParams = new PendingParams(downloadsUri, fileName, newMimeType);
Uri originalUri = sDownloadDelegate.parseOriginalUrl(originalUrl);
Uri referrerUri = TextUtils.isEmpty(referrer) ? null : Uri.parse(referrer);
pendingParams.setDownloadUri(originalUri);
pendingParams.setRefererUri(referrerUri);
return pendingParams;
}
/**
* Gets the base name, without extension, from a file name.
* TODO(qinmin): move this into a common utility class.
* @param fileName Name of the file.
* @return Base name of the file.
*/
private static String getBaseName(final String fileName) {
for (String extension : COMMON_DOUBLE_EXTENSIONS) {
if (fileName.endsWith(extension)) {
String name = fileName.substring(0, fileName.length() - extension.length());
// remove the "." at the end.
if (name.endsWith(".")) {
return name.substring(0, name.length() - 1);
}
}
}
int index = fileName.lastIndexOf('.');
if (index == -1) {
return fileName;
} else {
return fileName.substring(0, index);
}
}
private static @NonNull PendingSession openPendingUri(final String pendingUri) {
return MediaStoreUtils.openPending(
ContextUtils.getApplicationContext(), Uri.parse(pendingUri));
}
/**
* Helper method to generate a new expiration epoch time in seconds.
* @return Epoch time value in seconds for the download to expire.
*/
private static long getNewExpirationTime() {
return (System.currentTimeMillis()
+ DownloadCollectionBridge.getExpirationDurationInDays()
* DateUtils.DAY_IN_MILLIS)
/ 1000;
}
/** /**
* Gets the display name for a download. * Gets the display name for a download.
* @param downloadUri Uri of the download. * @param downloadUri Uri of the download.
...@@ -307,7 +426,21 @@ public class DownloadCollectionBridge { ...@@ -307,7 +426,21 @@ public class DownloadCollectionBridge {
*/ */
@CalledByNative @CalledByNative
private static String getDisplayName(final String downloadUri) { private static String getDisplayName(final String downloadUri) {
return getDownloadCollectionBridge().getDisplayNameForUri(downloadUri); ContentResolver resolver = ContextUtils.getApplicationContext().getContentResolver();
Cursor cursor = null;
try {
cursor = resolver.query(Uri.parse(downloadUri),
new String[] {MediaColumns.DISPLAY_NAME}, null, null, null);
if (cursor == null || cursor.getCount() == 0) return null;
if (cursor.moveToNext()) {
return cursor.getString(cursor.getColumnIndex(MediaColumns.DISPLAY_NAME));
}
} catch (Exception e) {
Log.e(TAG, "Unable to get display name for download.", e);
} finally {
if (cursor != null) cursor.close();
}
return null;
} }
@NativeMethods @NativeMethods
......
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.components.download;
import android.net.Uri;
/**
* Helper class for providering some helper method needed by DownloadCollectionBridge.
*/
public class DownloadDelegate {
public DownloadDelegate() {}
/**
* If the given MIME type is null, or one of the "generic" types (text/plain
* or application/octet-stream) map it to a type that Android can deal with.
* If the given type is not generic, return it unchanged.
*
* @param mimeType MIME type provided by the server.
* @param url URL of the data being loaded.
* @param filename file name obtained from content disposition header
* @return The MIME type that should be used for this data.
*/
public String remapGenericMimeType(String mimeType, String url, String filename) {
return mimeType;
}
/**
* Parses an originating URL string and returns a valid Uri that can be inserted into
* DownloadManager. The returned Uri has to be null or non-empty http(s) scheme.
* @param originalUrl String representation of the originating URL.
* @return A valid Uri that can be accepted by DownloadManager.
*/
public Uri parseOriginalUrl(String originalUrl) {
return Uri.parse(originalUrl);
}
/**
* Returns whether the downloaded file path is on an external SD card.
* @param filePath The download file path.
* @return Whether download is on external sd card.
*/
public boolean isDownloadOnSDCard(String filePath) {
return false;
}
}
# Copyright 2020 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import("//build/config/android/rules.gni")
assert(is_android)
android_library("android_provider_java") {
sources = [
"java/src/org/chromium/third_party/android/provider/MediaStoreUtils.java",
]
deps = [ "//third_party/android_deps:androidx_annotation_annotation_java" ]
}
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
qinmin@chromium.org
dtrainor@chromium.org
# TEAM: chrome-downloads@chromium.org
# COMPONENT: UI>Browser>Downloads
Name: MediaStoreUtils Android sample.
URL: https://android.googlesource.com/platform/cts/+/master/tests/tests/provider/src/android/provider/cts/MediaStoreUtils.java
Version: 50f25a19f2a3de940d6ef7eac84b37d1c62f1b5f
License: Apache 2.0
Security Critical: yes
Description:
This contains a modified copy of MediaStoreUtils.java. Please don't modify this.
MediaStoreUtils.java is based on a public Android sample that was
available as part of the Android cts libraries. It is
also available from:
https://android.googlesource.com/platform/cts/+/master/tests/tests/provider/src/android/provider/cts/MediaStoreUtils.java
Local Modifications:
- Added logs
- Introduced some helper method.
/*
* Copyright (C) 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License
*/
package org.chromium.third_party.android.provider;
import android.content.ContentValues;
import android.content.Context;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.provider.MediaStore;
import android.provider.MediaStore.DownloadColumns;
import android.provider.MediaStore.MediaColumns;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.io.FileNotFoundException;
import java.io.OutputStream;
import java.util.Objects;
/**
* Utility class to contribute download to the public download collection using
* MediaStore API from Q.
*/
public class MediaStoreUtils {
private static final String TAG = "MediaStoreUtils";
/**
* Creates a new pending media item using the given parameters. Pending items
* are expected to have a short lifetime, and owners should either
* {@link PendingSession#publish()} or {@link PendingSession#abandon()} a
* pending item within a few hours after first creating it.
*
* @param context Application context.
* @param params Parameters used to configure the item.
* @return token which can be passed to {@link #openPending(Context, Uri)}
* to work with this pending item.
*/
public static @NonNull Uri createPending(
@NonNull Context context, @NonNull PendingParams params) {
return context.getContentResolver().insert(params.mInsertUri, params.mInsertValues);
}
/**
* Opens a pending media item to make progress on it. You can open a pending
* item multiple times before finally calling either
* {@link PendingSession#publish()} or {@link PendingSession#abandon()}.
*
* @param uri token which was previously returned from
* {@link #createPending(Context, PendingParams)}.
* @return pending session that was opened.
*/
public static @NonNull PendingSession openPending(@NonNull Context context, @NonNull Uri uri) {
return new PendingSession(context, uri);
}
/**
* Parameters that describe a pending media item.
*/
public static class PendingParams {
final Uri mInsertUri;
final ContentValues mInsertValues;
/**
* Creates parameters that describe a pending media item.
*
* @param insertUri the {@code content://} Uri where this pending item
* should be inserted when finally published. For example, to
* publish an image, use
* {@link MediaStore.Images.Media#getContentUri(String)}.
* @param displayName Display name of the item.
* @param mimeType MIME type of the item.
*/
public PendingParams(
@NonNull Uri insertUri, @NonNull String displayName, @NonNull String mimeType) {
mInsertUri = Objects.requireNonNull(insertUri);
final long now = System.currentTimeMillis() / 1000;
mInsertValues = new ContentValues();
mInsertValues.put(MediaColumns.DISPLAY_NAME, Objects.requireNonNull(displayName));
mInsertValues.put(MediaColumns.MIME_TYPE, Objects.requireNonNull(mimeType));
mInsertValues.put(MediaColumns.DATE_ADDED, now);
mInsertValues.put(MediaColumns.DATE_MODIFIED, now);
try {
setPendingContentValues(this.mInsertValues, true);
} catch (Exception e) {
Log.e(TAG, "Unable to set pending content values.", e);
}
}
/**
* Optionally sets the Uri from where the file has been downloaded. This is used
* for files being added to {@link Downloads} table.
*
* @see DownloadColumns#DOWNLOAD_URI
*/
public void setDownloadUri(@Nullable Uri downloadUri) {
if (downloadUri == null) {
mInsertValues.remove(DownloadColumns.DOWNLOAD_URI);
} else {
mInsertValues.put(DownloadColumns.DOWNLOAD_URI, downloadUri.toString());
}
}
/**
* Optionally set the Uri indicating HTTP referer of the file. This is used for
* files being added to {@link Downloads} table.
*
* @see DownloadColumns#REFERER_URI
*/
public void setRefererUri(@Nullable Uri refererUri) {
if (refererUri == null) {
mInsertValues.remove(DownloadColumns.REFERER_URI);
} else {
mInsertValues.put(DownloadColumns.REFERER_URI, refererUri.toString());
}
}
/**
* Sets the expiration time of the download.
*
* @time Epoch time in seconds.
*/
public void setExpirationTime(long time) {
mInsertValues.put("date_expires", time);
}
}
/**
* Session actively working on a pending media item. Pending items are
* expected to have a short lifetime, and owners should either
* {@link PendingSession#publish()} or {@link PendingSession#abandon()} a
* pending item within a few hours after first creating it.
*/
public static class PendingSession implements AutoCloseable {
private final Context mContext;
private final Uri mUri;
/**
* Create a new pending session item to be published.
* @param context Contexxt of the application.
* @param uri Token which was previously returned from
* {@link #createPending(Context, PendingParams)}.
*/
PendingSession(Context context, Uri uri) {
mContext = Objects.requireNonNull(context);
mUri = Objects.requireNonNull(uri);
}
/**
* Open the underlying file representing this media item. When a media
* item is successfully completed, you should
* {@link ParcelFileDescriptor#close()} and then {@link #publish()} it.
*
* @return ParcelFileDescriptor to be written into.
*/
public @NonNull ParcelFileDescriptor open() throws FileNotFoundException {
return mContext.getContentResolver().openFileDescriptor(mUri, "rw");
}
/**
* Open the underlying file representing this media item. When a media
* item is successfully completed, you should
* {@link OutputStream#close()} and then {@link #publish()} it.
*
* @return OutputStream to be written into.
*/
public @NonNull OutputStream openOutputStream() throws FileNotFoundException {
return mContext.getContentResolver().openOutputStream(mUri);
}
/**
* When this media item is successfully completed, call this method to
* publish and make the final item visible to the user.
*
* @return the final {@code content://} Uri representing the newly
* published media.
*/
public @NonNull Uri publish() {
try {
final ContentValues values = new ContentValues();
setPendingContentValues(values, false);
values.putNull("date_expires");
mContext.getContentResolver().update(mUri, values, null, null);
} catch (Exception e) {
Log.e(TAG, "Unable to publish pending session.", e);
}
return mUri;
}
/**
* When this media item has failed to be completed, call this method to
* destroy the pending item record and any data related to it.
*/
public void abandon() {
mContext.getContentResolver().delete(mUri, null, null);
}
@Override
public void close() {
// No resources to close, but at least we can inform people that no
// progress is being actively made.
}
}
/**
* Helper method to set the ContentValues to pending or non-pending.
* @param values ContentValues to be set.
* @param isPending Whether the item is pending.
*/
private static void setPendingContentValues(ContentValues values, boolean isPending)
throws Exception {
values.put(MediaColumns.IS_PENDING, isPending ? 1 : 0);
}
}
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment