blob: f0d98f62dc603e777d8d80c6f774ac7235ad33ed [file] [log] [blame]
// Copyright 2017 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.offlinepages;
import android.annotation.TargetApi;
import android.app.DownloadManager;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.provider.MediaStore.MediaColumns;
import android.text.format.DateUtils;
import org.chromium.base.ContentUriUtils;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.VisibleForTesting;
import org.chromium.base.annotations.CalledByNative;
import org.chromium.base.annotations.JNINamespace;
import java.io.FileInputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
/**
* Since the {@link AndroidDownloadManager} can only be accessed from Java, this bridge will
* transfer all C++ calls over to Java land for making the call to ADM. This is a one-way bridge,
* from C++ to Java only. The Java side of this bridge is not called by other Java code.
*/
@JNINamespace("offline_pages")
public class OfflinePageArchivePublisherBridge {
private static final String TAG = "Publisher";
/** Offline pages should not be scanned as for media content. */
public static final boolean IS_MEDIA_SCANNER_SCANNABLE = false;
/** We don't want another download notification, since we already made one. */
public static final boolean SHOW_NOTIFICATION = false;
/** Mime type to use for Offline Pages. */
public static final String MIME_TYPE = "multipart/related";
/** Returns true if DownloadManager is installed on the phone. */
@CalledByNative
@VisibleForTesting
public static boolean isAndroidDownloadManagerInstalled() {
DownloadManager downloadManager = getDownloadManager();
return (downloadManager != null);
}
/**
* This is a pass through to the {@link AndroidDownloadManager} function of the same name.
* @param title The display name for this download.
* @param description Long description for this download.
* @param path File system path for this download.
* @param length Length in bytes of this downloaded item.
* @param uri The origin of this download. Used in API 24+ only.
* @param referer Where this download was refered from. Used in API 24+ only.
* @return the download ID of this item as assigned by the download manager.
*/
@CalledByNative
@VisibleForTesting
public static long addCompletedDownload(String title, String description, String path,
long length, String uri, String referer) {
try {
// Call the proper version of the pass through based on the supported API level.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return callAddCompletedDownload(title, description, path, length);
}
return callAddCompletedDownload(title, description, path, length, uri, referer);
} catch (Exception e) {
// In case of exception, we return a download id of 0.
Log.d(TAG, "ADM threw while trying to add a download. " + e);
return 0;
}
}
// Use this pass through before API level 24.
private static long callAddCompletedDownload(
String title, String description, String path, long length) {
DownloadManager downloadManager = getDownloadManager();
if (downloadManager == null) return 0;
return downloadManager.addCompletedDownload(title, description, IS_MEDIA_SCANNER_SCANNABLE,
MIME_TYPE, path, length, SHOW_NOTIFICATION);
}
// Use this pass through for API levels 24 and higher.
@TargetApi(Build.VERSION_CODES.N)
private static long callAddCompletedDownload(String title, String description, String path,
long length, String uri, String referer) {
DownloadManager downloadManager = getDownloadManager();
if (downloadManager == null) return 0;
return downloadManager.addCompletedDownload(title, description, IS_MEDIA_SCANNER_SCANNABLE,
MIME_TYPE, path, length, SHOW_NOTIFICATION, Uri.parse(uri), Uri.parse(referer));
}
/**
* This is a pass through to the {@link AndroidDownloadManager} function of the same name.
* @param ids An array of download IDs to be removed from the download manager.
* @return the number of IDs that were removed.
*/
@CalledByNative
@VisibleForTesting
public static int remove(long[] ids) {
DownloadManager downloadManager = getDownloadManager();
try {
if (downloadManager == null) return 0;
return downloadManager.remove(ids);
} catch (Exception e) {
Log.d(TAG, "ADM threw while trying to remove a download. " + e);
return 0;
}
}
private static DownloadManager getDownloadManager() {
return (DownloadManager) ContextUtils.getApplicationContext().getSystemService(
Context.DOWNLOAD_SERVICE);
}
/**
* Adds an archive to the downloads collection on Android Q+. Preferred alternative to
* addCompletedDownload for Android Q and later.
*
* TODO(iwells): Remove reflection once API level 29 is supported.
*
* @param page Offline page to be published.
* @return Content URI referring to the published page.
*/
@CalledByNative
@VisibleForTesting
public static String publishArchiveToDownloadsCollection(OfflinePageItem page) {
assert org.chromium.base.BuildInfo.isAtLeastQ();
final String isPending = "is_pending"; // MediaStore.IS_PENDING
// Collect all fields for creating intermediate URI.
final long now = System.currentTimeMillis() / 1000;
ContentValues pendingValues = new ContentValues();
pendingValues.put(MediaColumns.DATE_ADDED, now);
pendingValues.put(MediaColumns.DATE_MODIFIED, now);
pendingValues.put(isPending, 1);
pendingValues.put("download_uri", page.getUrl()); // MediaStore.DownloadColumns.DOWNLOAD_URI
Uri externalDownloadUri;
try {
// Class android.provider.MediaStore.Downloads added in API level 29.
Class<?> downloadsClazz = Class.forName("android.provider.MediaStore$Downloads");
Field externalUriField = downloadsClazz.getDeclaredField("EXTERNAL_CONTENT_URI");
externalDownloadUri = (Uri) externalUriField.get(null);
Field primaryDirectoryField = MediaColumns.class.getDeclaredField("PRIMARY_DIRECTORY");
pendingValues.put(
(String) primaryDirectoryField.get(null), Environment.DIRECTORY_DOWNLOADS);
} catch (Exception e) {
Log.d(TAG, "Unable to set pending download fields.", e);
return "";
}
// Pending URI will expire after 3 days.
long newExpirationTime = (System.currentTimeMillis() + 3 * DateUtils.DAY_IN_MILLIS) / 1000;
pendingValues.put("date_expires", newExpirationTime);
// Create intermediate URI.
ContentResolver contentResolver = ContextUtils.getApplicationContext().getContentResolver();
Uri intermediateUri = contentResolver.insert(externalDownloadUri, pendingValues);
if (intermediateUri == null || !ContentUriUtils.isContentUri(intermediateUri.toString())) {
Log.d(TAG, "Failed to create intermediate URI.");
return "";
}
// Copy archive to intermediate URI.
try {
// Class android.os.FileUtils added in API level 29.
Class<?> fileUtilsClazz = Class.forName("android.os.FileUtils");
Method copyMethod =
fileUtilsClazz.getMethod("copy", InputStream.class, OutputStream.class);
OutputStream out = contentResolver.openOutputStream(intermediateUri);
InputStream in = new FileInputStream(page.getFilePath());
copyMethod.invoke(null, in, out);
in.close();
out.close();
} catch (Exception e) {
Log.d(TAG, "Unable to copy archive to pending URI.", e);
return "";
}
// Set display name, MIME type, and pending -> false.
final ContentValues publishValues = new ContentValues();
publishValues.put(isPending, 0);
publishValues.putNull("date_expires");
publishValues.put(MediaColumns.DISPLAY_NAME, page.getTitle());
publishValues.put(MediaColumns.MIME_TYPE, "multipart/related");
if (contentResolver.update(intermediateUri, publishValues, null, null) != 1) {
Log.d(TAG, "Failed to finish publishing archive.");
}
return intermediateUri.toString();
}
}