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

// Copyright 2015 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.app.Activity;
import android.app.DownloadManager;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.os.ParcelFileDescriptor;
import android.provider.Browser;
import android.text.TextUtils;
import android.util.LongSparseArray;
import android.view.LayoutInflater;
import android.view.View;
import android.webkit.URLUtil;
import android.widget.TextView;

import androidx.annotation.VisibleForTesting;
import androidx.appcompat.app.AlertDialog;

import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
import org.xmlpull.v1.XmlPullParserFactory;

import org.chromium.base.ApplicationStatus;
import org.chromium.base.ContentUriUtils;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.ObserverList;
import org.chromium.base.PackageManagerUtils;
import org.chromium.base.shared_preferences.SharedPreferencesManager;
import org.chromium.base.task.AsyncTask;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.content.ContentUtils;
import org.chromium.chrome.browser.download.DownloadManagerBridge.DownloadEnqueueRequest;
import org.chromium.chrome.browser.download.DownloadManagerBridge.DownloadEnqueueResponse;
import org.chromium.chrome.browser.download.items.OfflineContentAggregatorFactory;
import org.chromium.chrome.browser.preferences.ChromePreferenceKeys;
import org.chromium.chrome.browser.preferences.ChromeSharedPreferences;
import org.chromium.components.download.DownloadCollectionBridge;
import org.chromium.components.offline_items_collection.ContentId;
import org.chromium.components.offline_items_collection.LegacyHelpers;
import org.chromium.components.offline_items_collection.OfflineItem;
import org.chromium.components.offline_items_collection.OfflineItemState;
import org.chromium.net.ChromiumNetworkAdapter;
import org.chromium.net.NetworkTrafficAnnotationTag;
import org.chromium.url.GURL;

import java.io.DataOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * This class handles OMA downloads according to the steps described in
 * http://xml.coverpages.org/OMA-Download-OTA-V10-20020620.pdf:
 * 1. Receives a download descriptor xml file.
 * 2. Parses all the contents.
 * 3. Checks device capability to see if it is able to handle the content.
 * 4. Find the objectURI value from the download descriptor and prompt user with
 *    a dialog to proceed with the download.
 * 5. On positive confirmation, sends a request to the download manager.
 * 6. Once the download is completed, sends a message to the server if installNotifyURI
 *    is present in the download descriptor.
 * 7. Prompts user with a dialog to open the NextURL specified in the download descriptor.
 * If steps 2 - 6 fails, a warning dialog will be prompted to the user to let them
 * know the error. Steps 6-7 will be executed afterwards.
 * If installNotifyURI is present in the download descriptor, the downloaded content will
 * be saved to the app directory first. If step 6 completes successfully, the content will
 * be moved to the public external storage. Otherwise, it will be removed from the device.
 */
public class OMADownloadHandler extends BroadcastReceiver {
    /** Alerted about changes to internal state. */
    public interface TestObserver {
        void onDownloadEnqueued(long downloadId);
    }

    private static final String TAG = "OMADownloadHandler";

    // Undocumented outside of Android source, but held by the Android Download Manager since at
    // least Android M in order to send download completed broadcasts.
    private static final String PERMISSION_SEND_DOWNLOAD_COMPLETED_INTENTS =
            "android.permission.SEND_DOWNLOAD_COMPLETED_INTENTS";

    // Valid download descriptor attributes.
    protected static final String OMA_TYPE = "type";
    protected static final String OMA_SIZE = "size";
    protected static final String OMA_OBJECT_URI = "objectURI";
    protected static final String OMA_INSTALL_NOTIFY_URI = "installNotifyURI";
    protected static final String OMA_NEXT_URL = "nextURL";
    protected static final String OMA_DD_VERSION = "DDVersion";
    protected static final String OMA_NAME = "name";
    protected static final String OMA_DESCRIPTION = "description";
    protected static final String OMA_VENDOR = "vendor";
    protected static final String OMA_INFO_URL = "infoURL";
    protected static final String OMA_ICON_URI = "iconURI";
    protected static final String OMA_INSTALL_PARAM = "installParam";

    // Error message to send to the notification server.
    private static final String DOWNLOAD_STATUS_SUCCESS = "900 Success \n\r";
    private static final String DOWNLOAD_STATUS_INSUFFICIENT_MEMORY =
            "901 insufficient memory \n\r";
    private static final String DOWNLOAD_STATUS_USER_CANCELLED = "902 User Cancelled \n\r";
    private static final String DOWNLOAD_STATUS_LOSS_OF_SERVICE = "903 Loss of Service \n\r";
    private static final String DOWNLOAD_STATUS_INVALID_DESCRIPTOR = "906 Invalid descriptor \n\r";
    private static final String DOWNLOAD_STATUS_INVALID_DDVERSION = "951 Invalid DDVersion \n\r";
    private static final String DOWNLOAD_STATUS_DEVICE_ABORTED = "952 Device Aborted \n\r";
    private static final String DOWNLOAD_STATUS_NON_ACCEPTABLE_CONTENT =
            "953 Non-Acceptable Content \n\r";
    private static final String DOWNLOAD_STATUS_LOADER_ERROR = "954 Loader Error \n\r";

    private static final NetworkTrafficAnnotationTag TRAFFIC_ANNOTATION =
            NetworkTrafficAnnotationTag.createComplete(
                    "oma_download_handler_android",
                    """
                    semantics {
                      sender: "OMA Download Handler (Android)"
                      description:
                        "Uploads file download status to the server URL specified in the download "
                        "descriptor XML, as required by the OMA DRM specification."
                      trigger: "After an OMA DRM file download completes."
                      data: "Info related to the download."
                      destination: OTHER
                    }
                    policy {
                      cookies_allowed: NO
                      setting:
                        "This feature cannot be disabled by settings as it is part of the OMA DRM "
                        "specification."
                      policy_exception_justification: "Not implemented."
                    }""");

    private final Context mContext;
    private final SharedPreferencesManager mSharedPrefs;
    private final LongSparseArray<DownloadItem> mSystemDownloadIdMap =
            new LongSparseArray<DownloadItem>();
    private final LongSparseArray<OMAInfo> mPendingOMADownloads = new LongSparseArray<OMAInfo>();
    private final ObserverList<TestObserver> mObservers = new ObserverList<>();

    /**
     * Information about the OMA content. The object is parsed from the download
     * descriptor. There can be multiple MIME types for the object.
     */
    @VisibleForTesting
    protected static class OMAInfo {
        private final Map<String, String> mDescription;
        private final List<String> mTypes;

        OMAInfo() {
            mDescription = new HashMap<String, String>();
            mTypes = new ArrayList<String>();
        }

        /**
         * Inserts an attribute-value pair about the OMA content. If the attribute already
         * exists, the new value will replace the old one. For MIME type, it will be appended
         * to the existing MIME types.
         *
         * @param attribute The attribute to be inserted.
         * @param value The new value of the attribute.
         */
        void addAttributeValue(String attribute, String value) {
            if (attribute.equals(OMA_TYPE)) {
                mTypes.add(value);
            } else {
                // TODO(qinmin): Handle duplicate attributes
                mDescription.put(attribute, value);
            }
        }

        /**
         * Gets the value for an attribute.
         *
         * @param attribute The attribute to be retrieved.
         * @return value of the attribute.
         */
        String getValue(String attribute) {
            return mDescription.get(attribute);
        }

        /**
         * Checks whether the value is empty for an attribute.
         *
         * @param attribute The attribute to be retrieved.
         * @return true if it is empty, or false otherwise.
         */
        boolean isValueEmpty(String attribute) {
            return TextUtils.isEmpty(getValue(attribute));
        }

        /**
         * Gets the list of MIME types of the OMA content.
         *
         * @return List of MIME types.
         */
        List<String> getTypes() {
            return mTypes;
        }

        /**
         * Checks whether the information about the OMA content is empty.
         *
         * @return true if all attributes are empty, or false otherwise.
         */
        boolean isEmpty() {
            return mDescription.isEmpty() && mTypes.isEmpty();
        }

        /**
         * Gets the DRM MIME type of this object.
         *
         * @return the DRM MIME type if it is found, or null otherwise.
         */
        String getDrmType() {
            for (String type : mTypes) {
                if (type.equalsIgnoreCase(MimeUtils.OMA_DRM_MESSAGE_MIME)
                        || type.equalsIgnoreCase(MimeUtils.OMA_DRM_CONTENT_MIME)) {
                    return type;
                }
            }
            return null;
        }
    }

    /** Class representing an OMA download entry to be stored in SharedPrefs. */
    @VisibleForTesting
    protected static class OMAEntry {
        final long mDownloadId;
        final String mInstallNotifyURI;

        OMAEntry(long downloadId, String installNotifyURI) {
            mDownloadId = downloadId;
            mInstallNotifyURI = installNotifyURI;
        }

        /**
         * Parse OMA entry from the SharedPrefs String
         * TODO(qinmin): use a file instead of SharedPrefs to store the OMA entry.
         *
         * @param entry String contains the OMA information.
         * @return an OMAEntry object.
         */
        @VisibleForTesting
        static OMAEntry parseOMAEntry(String entry) {
            int index = entry.indexOf(",");
            long downloadId = Long.parseLong(entry.substring(0, index));
            return new OMAEntry(downloadId, entry.substring(index + 1));
        }

        /**
         * Generates a string for an OMA entry to be inserted into the SharedPrefs.
         * TODO(qinmin): use a file instead of SharedPrefs to store the OMA entry.
         *
         * @return a String representing the download entry.
         */
        String generateSharedPrefsString() {
            return String.valueOf(mDownloadId) + "," + mInstallNotifyURI;
        }
    }

    /** Constructor. */
    public OMADownloadHandler(Context context) {
        mContext = context;
        mSharedPrefs = ChromeSharedPreferences.getInstance();
    }

    /**
     * Starts handling the OMA download.
     *
     * @param downloadInfo The information about the download.
     * @param downloadId The unique identifier maintained by the Android DownloadManager.
     */
    public void handleOMADownload(DownloadInfo downloadInfo, long downloadId) {
        OMAParserTask task = new OMAParserTask(downloadInfo, downloadId);
        task.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
    }

    void addObserverForTest(TestObserver testObserver) {
        mObservers.addObserver(testObserver);
    }

    /** Async task to parse an OMA download descriptor. */
    private class OMAParserTask extends AsyncTask<OMAInfo> {
        private final DownloadInfo mDownloadInfo;
        private final long mDownloadId;
        private long mFreeSpace;

        public OMAParserTask(DownloadInfo downloadInfo, long downloadId) {
            mDownloadInfo = downloadInfo;
            mDownloadId = downloadId;
        }

        @Override
        public OMAInfo doInBackground() {
            OMAInfo omaInfo = null;
            final DownloadManager manager =
                    (DownloadManager) mContext.getSystemService(Context.DOWNLOAD_SERVICE);
            boolean isContentUri =
                    (mDownloadId == DownloadConstants.INVALID_DOWNLOAD_ID)
                            && ContentUriUtils.isContentUri(mDownloadInfo.getFilePath());
            try {
                ParcelFileDescriptor fd = null;
                if (isContentUri) {
                    int fileDescriptor =
                            ContentUriUtils.openContentUri(mDownloadInfo.getFilePath(), "r");
                    if (fileDescriptor > 0) {
                        fd = ParcelFileDescriptor.fromFd(fileDescriptor);
                    }
                } else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
                    fd = manager.openDownloadedFile(mDownloadId);
                } else {
                    fd =
                            ParcelFileDescriptor.open(
                                    new File(mDownloadInfo.getFilePath()),
                                    ParcelFileDescriptor.MODE_READ_ONLY);
                }
                if (fd != null) {
                    omaInfo = parseDownloadDescriptor(new FileInputStream(fd.getFileDescriptor()));
                    fd.close();
                }
            } catch (FileNotFoundException e) {
                Log.w(TAG, "File not found.", e);
            } catch (IOException e) {
                Log.w(TAG, "Cannot read file.", e);
            }

            if (isContentUri) {
                ContentUriUtils.delete(mDownloadInfo.getFilePath());
            }
            mFreeSpace = Environment.getExternalStorageDirectory().getUsableSpace();
            DownloadMetrics.recordDownloadOpen(
                    DownloadOpenSource.ANDROID_DOWNLOAD_MANAGER, mDownloadInfo.getMimeType());
            return omaInfo;
        }

        @Override
        protected void onPostExecute(OMAInfo omaInfo) {
            OfflineContentAggregatorFactory.get().removeItem(mDownloadInfo.getContentId());

            if (omaInfo == null) return;
            // Send notification if required attributes are missing.
            if (omaInfo.getTypes().isEmpty()
                    || getSize(omaInfo) <= 0
                    || omaInfo.isValueEmpty(OMA_OBJECT_URI)) {
                sendNotification(
                        omaInfo,
                        mDownloadInfo,
                        DownloadConstants.INVALID_DOWNLOAD_ID,
                        DOWNLOAD_STATUS_INVALID_DESCRIPTOR);
                return;
            }
            // Check version. Null version are treated as 1.0.
            String version = omaInfo.getValue(OMA_DD_VERSION);
            if (version != null && !version.startsWith("1.")) {
                sendNotification(
                        omaInfo,
                        mDownloadInfo,
                        DownloadConstants.INVALID_DOWNLOAD_ID,
                        DOWNLOAD_STATUS_INVALID_DDVERSION);
                return;
            }
            // Check device capabilities.
            if (mFreeSpace < getSize(omaInfo)) {
                showDownloadWarningDialog(
                        R.string.oma_download_insufficient_memory,
                        omaInfo,
                        mDownloadInfo,
                        DOWNLOAD_STATUS_INSUFFICIENT_MEMORY);
                return;
            }
            if (getOpennableType(omaInfo) == null) {
                showDownloadWarningDialog(
                        R.string.oma_download_non_acceptable_content,
                        omaInfo,
                        mDownloadInfo,
                        DOWNLOAD_STATUS_NON_ACCEPTABLE_CONTENT);
                return;
            }
            showOMAInfoDialog(mDownloadId, mDownloadInfo, omaInfo);
        }
    }

    @Override
    public void onReceive(Context context, Intent intent) {
        String action = intent.getAction();
        if (!DownloadManager.ACTION_DOWNLOAD_COMPLETE.equals(action)) return;
        long downloadId =
                intent.getLongExtra(
                        DownloadManager.EXTRA_DOWNLOAD_ID, DownloadConstants.INVALID_DOWNLOAD_ID);
        if (downloadId == DownloadConstants.INVALID_DOWNLOAD_ID) return;
        boolean isPendingOMADownload = isPendingOMADownload(downloadId);
        boolean isInOMASharedPrefs = isDownloadIdInOMASharedPrefs(downloadId);
        if (isPendingOMADownload || isInOMASharedPrefs) {
            clearPendingOMADownload(downloadId, null);
            return;
        }

        DownloadItem downloadItem = mSystemDownloadIdMap.get(downloadId);
        if (downloadItem != null) {
            DownloadManagerBridge.queryDownloadResult(
                    downloadId,
                    (result) -> {
                        DownloadManagerService.getDownloadManagerService()
                                .onQueryCompleted(downloadItem, true, result);
                    });
            removeFromSystemDownloadIdMap(downloadId);
        }
    }

    private void removeFromSystemDownloadIdMap(long downloadId) {
        if (mSystemDownloadIdMap.size() == 0) return;
        mSystemDownloadIdMap.remove(downloadId);
        if (mSystemDownloadIdMap.size() == 0) {
            mContext.unregisterReceiver(this);
        }
    }

    /**
     * Called when the content is successfully downloaded by the Android DownloadManager.
     *
     * @param downloadInfo The information about the download.
     * @param downloadId Download Id from the Android DownloadManager.
     * @param notifyURI The previously saved installNotifyURI attribute.
     */
    private void onDownloadCompleted(DownloadInfo downloadInfo, long downloadId, String notifyURI) {
        OMAInfo omaInfo = mPendingOMADownloads.get(downloadId);
        if (omaInfo == null) {
            omaInfo = new OMAInfo();
            omaInfo.addAttributeValue(OMA_INSTALL_NOTIFY_URI, notifyURI);
        }
        sendInstallNotificationAndNextStep(
                omaInfo, downloadInfo, downloadId, DOWNLOAD_STATUS_SUCCESS);
        mPendingOMADownloads.remove(downloadId);
    }

    /**
     * Called when android DownloadManager fails to download the content.
     *
     * @param downloadInfo The information about the download.
     * @param downloadId Download Id from the Android DownloadManager.
     * @param reason The reason of failure.
     * @param notifyURI The previously saved installNotifyURI attribute.
     */
    private void onDownloadFailed(
            DownloadInfo downloadInfo, long downloadId, int reason, String notifyURI) {
        String status = DOWNLOAD_STATUS_DEVICE_ABORTED;
        switch (reason) {
            case DownloadManager.ERROR_CANNOT_RESUME:
                status = DOWNLOAD_STATUS_LOSS_OF_SERVICE;
                break;
            case DownloadManager.ERROR_HTTP_DATA_ERROR:
            case DownloadManager.ERROR_TOO_MANY_REDIRECTS:
            case DownloadManager.ERROR_UNHANDLED_HTTP_CODE:
                status = DOWNLOAD_STATUS_LOADER_ERROR;
                break;
            case DownloadManager.ERROR_INSUFFICIENT_SPACE:
                status = DOWNLOAD_STATUS_INSUFFICIENT_MEMORY;
                break;
            default:
                break;
        }
        OMAInfo omaInfo = mPendingOMADownloads.get(downloadId);
        if (omaInfo == null) {
            // Just send the notification in this case.
            omaInfo = new OMAInfo();
            omaInfo.addAttributeValue(OMA_INSTALL_NOTIFY_URI, notifyURI);
            sendInstallNotificationAndNextStep(omaInfo, downloadInfo, downloadId, status);
            return;
        }
        showDownloadWarningDialog(R.string.oma_download_failed, omaInfo, downloadInfo, status);
        mPendingOMADownloads.remove(downloadId);
    }

    /**
     * Sends the install notification and then opens the nextURL if they are provided.
     * If the install notification is sent, nextURL will be opened after the server
     * response is received.
     *
     * @param omaInfo Information about the OMA content.
     * @param downloadInfo Information about the download.
     * @param downloadId Id of the download in Android DownloadManager.
     * @param statusMessage The message to send to the notification server.
     */
    private void sendInstallNotificationAndNextStep(
            OMAInfo omaInfo, DownloadInfo downloadInfo, long downloadId, String statusMessage) {
        if (!sendNotification(omaInfo, downloadInfo, downloadId, statusMessage)) {
            showNextUrlDialog(omaInfo);
        }
    }

    /**
     * Sends the install notification to the server.
     *
     * @param omaInfo Information about the OMA content.
     * @param downloadInfo Information about the download.
     * @param downloadId Id of the download in Android DownloadManager.
     * @param statusMessage The message to send to the notification server.
     * @return true if the notification ise sent, or false otherwise.
     */
    @VisibleForTesting
    protected boolean sendNotification(
            OMAInfo omaInfo, DownloadInfo downloadInfo, long downloadId, String statusMessage) {
        if (omaInfo == null) return false;
        if (omaInfo.isValueEmpty(OMA_INSTALL_NOTIFY_URI)) return false;
        PostStatusTask task = new PostStatusTask(omaInfo, downloadInfo, downloadId, statusMessage);
        task.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
        return true;
    }

    /**
     * Shows the OMA information to the user and ask whether user want to proceed.
     *
     * @param downloadId The unique identifier maintained by the Android DownloadManager.
     * @param downloadInfo Information about the download.
     * @param omaInfo Information about the OMA content.
     */
    private void showOMAInfoDialog(
            final long downloadId, final DownloadInfo downloadInfo, final OMAInfo omaInfo) {
        LayoutInflater inflater =
                (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
        View v = inflater.inflate(R.layout.confirm_oma_download, null);

        TextView textView = v.findViewById(R.id.oma_download_name);
        textView.setText(omaInfo.getValue(OMA_NAME));
        textView = v.findViewById(R.id.oma_download_vendor);
        textView.setText(omaInfo.getValue(OMA_VENDOR));
        textView = v.findViewById(R.id.oma_download_size);
        textView.setText(omaInfo.getValue(OMA_SIZE));
        textView = v.findViewById(R.id.oma_download_type);
        textView.setText(getOpennableType(omaInfo));
        textView = v.findViewById(R.id.oma_download_description);
        textView.setText(omaInfo.getValue(OMA_DESCRIPTION));

        DialogInterface.OnClickListener clickListener =
                (dialog, which) -> {
                    if (which == AlertDialog.BUTTON_POSITIVE) {
                        downloadOMAContent(downloadId, downloadInfo, omaInfo);
                    } else {
                        sendNotification(
                                omaInfo,
                                downloadInfo,
                                DownloadConstants.INVALID_DOWNLOAD_ID,
                                DOWNLOAD_STATUS_USER_CANCELLED);
                    }
                };
        new AlertDialog.Builder(
                        ApplicationStatus.getLastTrackedFocusedActivity(),
                        R.style.ThemeOverlay_BrowserUI_AlertDialog)
                .setTitle(R.string.proceed_oma_download_message)
                .setPositiveButton(R.string.ok, clickListener)
                .setNegativeButton(R.string.cancel, clickListener)
                .setView(v)
                .setCancelable(false)
                .show();
    }

    /**
     * Shows a warning dialog indicating that download has failed. When user confirms
     * the warning, a message will be sent to the notification server to  inform about the
     * error.
     *
     * @param titleId The resource identifier for the title.
     * @param omaInfo Information about the OMA content.
     * @param downloadInfo Information about the download.
     * @param statusMessage Message to be sent to the notification server.
     */
    private void showDownloadWarningDialog(
            int titleId,
            final OMAInfo omaInfo,
            final DownloadInfo downloadInfo,
            final String statusMessage) {
        DialogInterface.OnClickListener clickListener =
                (dialog, which) -> {
                    if (which == AlertDialog.BUTTON_POSITIVE) {
                        sendInstallNotificationAndNextStep(
                                omaInfo,
                                downloadInfo,
                                DownloadConstants.INVALID_DOWNLOAD_ID,
                                statusMessage);
                    }
                };
        new AlertDialog.Builder(
                        ApplicationStatus.getLastTrackedFocusedActivity(),
                        R.style.ThemeOverlay_BrowserUI_AlertDialog)
                .setTitle(titleId)
                .setPositiveButton(R.string.ok, clickListener)
                .setCancelable(false)
                .show();
    }

    /**
     * Shows a dialog to ask whether user wants to open the nextURL.
     *
     * @param omaInfo Information about the OMA content.
     */
    private void showNextUrlDialog(OMAInfo omaInfo) {
        if (omaInfo.isValueEmpty(OMA_NEXT_URL)) {
            return;
        }
        final String nextUrl = omaInfo.getValue(OMA_NEXT_URL);
        final Activity activity = ApplicationStatus.getLastTrackedFocusedActivity();
        DialogInterface.OnClickListener clickListener =
                (dialog, which) -> {
                    if (which == AlertDialog.BUTTON_POSITIVE) {
                        Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(nextUrl));
                        intent.putExtra(Browser.EXTRA_APPLICATION_ID, activity.getPackageName());
                        intent.putExtra(Browser.EXTRA_CREATE_NEW_TAB, true);
                        intent.setPackage(mContext.getPackageName());
                        activity.startActivity(intent);
                    }
                };
        new AlertDialog.Builder(activity)
                .setTitle(R.string.open_url_post_oma_download)
                .setPositiveButton(R.string.ok, clickListener)
                .setNegativeButton(R.string.cancel, clickListener)
                .setMessage(nextUrl)
                .setCancelable(false)
                .show();
    }

    /**
     * Returns the first MIME type in the OMA download that can be opened on the device.
     *
     * @param omaInfo Information about the OMA content.
     * @return the MIME type can be opened by the device.
     */
    static String getOpennableType(OMAInfo omaInfo) {
        if (omaInfo.isValueEmpty(OMA_OBJECT_URI)) {
            return null;
        }
        Intent intent = new Intent(Intent.ACTION_VIEW);
        Uri uri = Uri.parse(omaInfo.getValue(OMA_OBJECT_URI));
        for (String type : omaInfo.getTypes()) {
            if (!type.equalsIgnoreCase(MimeUtils.OMA_DRM_MESSAGE_MIME)
                    && !type.equalsIgnoreCase(MimeUtils.OMA_DRM_CONTENT_MIME)
                    && !type.equalsIgnoreCase(MimeUtils.OMA_DOWNLOAD_DESCRIPTOR_MIME)
                    && !type.equalsIgnoreCase(MimeUtils.OMA_DRM_RIGHTS_MIME)) {
                intent.setDataAndType(uri, type);
                if (PackageManagerUtils.canResolveActivity(
                        intent, PackageManager.MATCH_DEFAULT_ONLY)) {
                    return type;
                }
            }
        }
        return null;
    }

    /**
     * Parses the input stream and returns the OMA information.
     *
     * @param is The input stream to the parser.
     * @return OMA information about the download content, or null if an error is found.
     */
    @VisibleForTesting
    static OMAInfo parseDownloadDescriptor(InputStream is) {
        try {
            XmlPullParserFactory factory = XmlPullParserFactory.newInstance();
            factory.setNamespaceAware(true);
            XmlPullParser parser = factory.newPullParser();
            parser.setInput(is, null);
            int eventType = parser.getEventType();
            String currentAttribute = null;
            OMAInfo info = new OMAInfo();
            StringBuilder sb = null;
            List<String> attributeList =
                    new ArrayList<String>(
                            Arrays.asList(
                                    OMA_TYPE,
                                    OMA_SIZE,
                                    OMA_OBJECT_URI,
                                    OMA_INSTALL_NOTIFY_URI,
                                    OMA_NEXT_URL,
                                    OMA_DD_VERSION,
                                    OMA_NAME,
                                    OMA_DESCRIPTION,
                                    OMA_VENDOR,
                                    OMA_INFO_URL,
                                    OMA_ICON_URI,
                                    OMA_INSTALL_PARAM));
            while (eventType != XmlPullParser.END_DOCUMENT) {
                if (eventType == XmlPullParser.START_DOCUMENT) {
                    if (!info.isEmpty()) return null;
                } else if (eventType == XmlPullParser.START_TAG) {
                    String tagName = parser.getName();
                    if (attributeList.contains(tagName)) {
                        if (currentAttribute != null) {
                            Log.w(TAG, "Nested attributes was found in the download descriptor");
                            return null;
                        }
                        sb = new StringBuilder();
                        currentAttribute = tagName;
                    }
                } else if (eventType == XmlPullParser.END_TAG) {
                    if (currentAttribute != null) {
                        if (!currentAttribute.equals(parser.getName())) {
                            Log.w(TAG, "Nested attributes was found in the download descriptor");
                            return null;
                        }
                        info.addAttributeValue(currentAttribute, sb.toString().trim());
                        currentAttribute = null;
                        sb = null;
                    }
                } else if (eventType == XmlPullParser.TEXT) {
                    if (currentAttribute != null) {
                        sb.append(parser.getText());
                    }
                }
                eventType = parser.next();
            }
            return info;
        } catch (XmlPullParserException e) {
            Log.w(TAG, "Failed to parse download descriptor.", e);
            return null;
        } catch (IOException e) {
            Log.w(TAG, "Failed to read download descriptor.", e);
            return null;
        }
    }

    /**
     * Returns the size of the OMA content.
     *
     * @param omaInfo OMA information about the download content
     * @return size in bytes or 0 if the omaInfo doesn't contain size info.
     */
    @VisibleForTesting
    protected static long getSize(OMAInfo omaInfo) {
        String sizeString = omaInfo.getValue(OMA_SIZE);
        try {
            long size = sizeString == null ? 0 : Long.parseLong(sizeString.replace(",", ""));
            return size;
        } catch (NumberFormatException e) {
            Log.w(TAG, "Cannot parse size information.", e);
        }
        return 0;
    }

    /**
     * Enqueue a download request to the DownloadManager and starts downloading the OMA content.
     *
     * @param downloadId The unique identifier maintained by the Android DownloadManager.
     * @param downloadInfo Information about the download.
     * @param omaInfo Information about the OMA content.
     */
    @VisibleForTesting
    protected void downloadOMAContent(long downloadId, DownloadInfo downloadInfo, OMAInfo omaInfo) {
        if (omaInfo == null) return;
        String mimeType = omaInfo.getDrmType();
        if (mimeType == null) {
            mimeType = getOpennableType(omaInfo);
        }
        String fileName = omaInfo.getValue(OMA_NAME);
        String url = omaInfo.getValue(OMA_OBJECT_URI);
        if (TextUtils.isEmpty(fileName)) {
            fileName = URLUtil.guessFileName(url, null, mimeType);
        }
        DownloadInfo newInfo =
                DownloadInfo.Builder.fromDownloadInfo(downloadInfo)
                        .setFileName(fileName)
                        .setUrl(new GURL(url))
                        .setMimeType(mimeType)
                        .setDescription(omaInfo.getValue(OMA_DESCRIPTION))
                        .setBytesReceived(getSize(omaInfo))
                        .build();
        // If installNotifyURI is not empty, the downloaded content cannot
        // be used until the PostStatusTask gets a 200-series response.
        // Don't show complete notification until that happens.
        DownloadItem item = new DownloadItem(true, newInfo);
        item.setSystemDownloadId(downloadId);

        DownloadEnqueueRequest enqueueRequest = new DownloadEnqueueRequest();
        enqueueRequest.fileName = fileName;
        enqueueRequest.url = url;
        enqueueRequest.mimeType = mimeType;
        enqueueRequest.description = omaInfo.getValue(OMA_DESCRIPTION);
        enqueueRequest.cookie = newInfo.getCookie();
        enqueueRequest.referrer = newInfo.getReferrer().getSpec();
        enqueueRequest.userAgent = newInfo.getUserAgent();
        enqueueRequest.notifyCompleted = omaInfo.isValueEmpty(OMA_INSTALL_NOTIFY_URI);
        DownloadManagerBridge.enqueueNewDownload(
                enqueueRequest,
                response -> {
                    onDownloadEnqueued(item, response);
                });
        mPendingOMADownloads.put(downloadId, omaInfo);
    }

    /**
     * Checks if an OMA download is currently pending.
     *
     * @param downloadId Download identifier.
     * @return true if the download is in progress, or false otherwise.
     */
    private boolean isPendingOMADownload(long downloadId) {
        return mPendingOMADownloads.get(downloadId) != null;
    }

    /**
     * Updates the download information with the new download Id.
     *
     * @param oldDownloadId Old download Id from the DownloadManager.
     * @param newDownloadId New download Id from the DownloadManager.
     */
    private void updateDownloadInfo(long oldDownloadId, long newDownloadId) {
        OMAInfo omaInfo = mPendingOMADownloads.get(oldDownloadId);
        mPendingOMADownloads.remove(oldDownloadId);
        mPendingOMADownloads.put(newDownloadId, omaInfo);
    }

    private void onDownloadEnqueued(DownloadItem downloadItem, DownloadEnqueueResponse response) {
        long oldDownloadId = downloadItem.getSystemDownloadId();
        downloadItem.setSystemDownloadId(response.downloadId);
        boolean isPendingOMADownload = isPendingOMADownload(oldDownloadId);
        if (!response.result) {
            if (isPendingOMADownload) {
                onDownloadFailed(
                        downloadItem.getDownloadInfo(),
                        oldDownloadId,
                        DownloadManager.ERROR_UNKNOWN,
                        null);
            }
            return;
        }

        if (mSystemDownloadIdMap.size() == 0) {
            ContextUtils.registerExportedBroadcastReceiver(
                    mContext,
                    this,
                    new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
                    PERMISSION_SEND_DOWNLOAD_COMPLETED_INTENTS);
        }
        mSystemDownloadIdMap.put(response.downloadId, downloadItem);

        if (isPendingOMADownload) {
            // A new downloadId is generated, needs to update the OMADownloadHandler
            // about this.
            updateDownloadInfo(oldDownloadId, response.downloadId);
            // TODO(qinmin): use a file instead of shared prefs to save the
            // OMA information in case chrome is killed. This will allow us to
            // save more information like cookies and user agent.
            String notifyUri = getInstallNotifyInfo(response.downloadId);
            if (!TextUtils.isEmpty(notifyUri)) {
                OMAEntry entry = new OMAEntry(response.downloadId, notifyUri);
                addOMADownloadToSharedPrefs(entry.generateSharedPrefsString());
            }
        }
        DownloadManagerService.getDownloadManagerService()
                .onDownloadEnqueued(downloadItem, response);
        for (TestObserver observer : mObservers) observer.onDownloadEnqueued(response.downloadId);
    }

    /**
     * Clears any pending OMA downloads for a particular download ID. If the download has been
     * already completed, notifies the user through appropriate UI.
     *
     * @param downloadId Download identifier from Android DownloadManager.
     * @param installNotifyURI URI to notify after installation.
     */
    private void clearPendingOMADownload(long downloadId, String installNotifyURI) {
        DownloadManagerBridge.queryDownloadResult(
                downloadId,
                result -> {
                    DownloadItem item = mSystemDownloadIdMap.get(downloadId);
                    if (item == null) {
                        item = new DownloadItem(true, null);
                        item.setSystemDownloadId(downloadId);
                    }

                    DownloadInfo.Builder builder =
                            item.getDownloadInfo() == null
                                    ? new DownloadInfo.Builder()
                                    : DownloadInfo.Builder.fromDownloadInfo(item.getDownloadInfo());
                    builder.setBytesReceived(result.bytesDownloaded);
                    builder.setBytesTotalSize(result.bytesTotal);
                    if (!TextUtils.isEmpty(result.fileName)) builder.setFileName(result.fileName);
                    if (!TextUtils.isEmpty(result.mimeType)) builder.setMimeType(result.mimeType);
                    builder.setFilePath(result.filePath);

                    item.setDownloadInfo(builder.build());

                    showDownloadsUi(downloadId, item, result, installNotifyURI);
                    removeFromSystemDownloadIdMap(downloadId);
                });
    }

    private void showDownloadsUi(
            long downloadId,
            DownloadItem item,
            DownloadManagerBridge.DownloadQueryResult result,
            String installNotifyURI) {
        if (result.downloadStatus == DownloadStatus.COMPLETE) {
            DownloadInfo.Builder builder =
                    item.getDownloadInfo() == null
                            ? new DownloadInfo.Builder()
                            : DownloadInfo.Builder.fromDownloadInfo(item.getDownloadInfo());
            builder.setFilePath(result.filePath);
            item.setDownloadInfo(builder.build());
            onDownloadCompleted(item.getDownloadInfo(), downloadId, installNotifyURI);
            removeOMADownloadFromSharedPrefs(downloadId);
            showDownloadOnInfoBar(item, result.downloadStatus);
        } else if (result.downloadStatus == DownloadStatus.FAILED) {
            onDownloadFailed(
                    item.getDownloadInfo(), downloadId, result.failureReason, installNotifyURI);
            removeOMADownloadFromSharedPrefs(downloadId);
            // TODO(shaktisahu): Find a way to pass the failure reason.
            showDownloadOnInfoBar(item, result.downloadStatus);
        }
    }

    private void showDownloadOnInfoBar(DownloadItem downloadItem, int downloadStatus) {
        DownloadMessageUiController messageUiController =
                DownloadManagerService.getDownloadManagerService()
                        .getMessageUiController(downloadItem.getDownloadInfo().getOTRProfileId());
        if (messageUiController == null) return;
        OfflineItem offlineItem = DownloadItem.createOfflineItem(downloadItem);
        offlineItem.id.namespace = LegacyHelpers.LEGACY_ANDROID_DOWNLOAD_NAMESPACE;
        if (downloadStatus == DownloadStatus.COMPLETE) {
            offlineItem.state = OfflineItemState.COMPLETE;
        } else if (downloadStatus == DownloadStatus.FAILED) {
            offlineItem.state = OfflineItemState.FAILED;
        }

        messageUiController.onItemUpdated(offlineItem, null);
    }

    /** Clear any pending OMA downloads by reading them from shared prefs. */
    void clearPendingOMADownloads() {
        if (mSharedPrefs.contains(ChromePreferenceKeys.DOWNLOAD_PENDING_OMA_DOWNLOADS)) {
            Set<String> omaDownloads =
                    getStoredDownloadInfo(
                            mSharedPrefs, ChromePreferenceKeys.DOWNLOAD_PENDING_OMA_DOWNLOADS);
            for (String omaDownload : omaDownloads) {
                OMAEntry entry = OMAEntry.parseOMAEntry(omaDownload);
                clearPendingOMADownload(entry.mDownloadId, entry.mInstallNotifyURI);
            }
        }
    }

    /**
     * Gets download information from SharedPreferences.
     * @param sharedPrefs The SharedPreferences object to parse.
     * @param type Type of the information to retrieve.
     * @return download information saved to the SharedPrefs for the given type.
     */
    private static Set<String> getStoredDownloadInfo(
            SharedPreferencesManager sharedPrefs, String type) {
        return DownloadManagerService.getStoredDownloadInfo(sharedPrefs, type);
    }

    /**
     * Stores download information to shared preferences. The information can be
     * either pending download IDs, or pending OMA downloads.
     *
     * @param sharedPrefs SharedPreferences to update.
     * @param type Type of the information.
     * @param downloadInfo Information to be saved.
     */
    static void storeDownloadInfo(
            SharedPreferencesManager sharedPrefs, String type, Set<String> downloadInfo) {
        DownloadManagerService.storeDownloadInfo(
                sharedPrefs, type, downloadInfo, /* forceCommit= */ false);
    }

    /**
     * Returns the installation notification URI for the OMA download.
     *
     * @param downloadId Download Identifier.
     * @return String containing the installNotifyURI.
     */
    private String getInstallNotifyInfo(long downloadId) {
        OMAInfo omaInfo = mPendingOMADownloads.get(downloadId);
        return omaInfo.getValue(OMA_INSTALL_NOTIFY_URI);
    }

    /** This class is responsible for posting the status message to the notification server. */
    private class PostStatusTask extends AsyncTask<Boolean> {
        private static final String TAG = "PostStatusTask";
        private final OMAInfo mOMAInfo;
        private final DownloadInfo mDownloadInfo;
        private final String mStatusMessage;
        private final long mDownloadId;
        private DownloadInfo mNewDownloadInfo;

        public PostStatusTask(
                OMAInfo omaInfo, DownloadInfo downloadInfo, long downloadId, String statusMessage) {
            mOMAInfo = omaInfo;
            mDownloadInfo = downloadInfo;
            mStatusMessage = statusMessage;
            mDownloadId = downloadId;
        }

        @Override
        protected Boolean doInBackground() {
            HttpURLConnection urlConnection = null;
            boolean success = false;
            try {
                URL url = new URL(mOMAInfo.getValue(OMA_INSTALL_NOTIFY_URI));
                urlConnection =
                        (HttpURLConnection)
                                ChromiumNetworkAdapter.openConnection(url, TRAFFIC_ANNOTATION);
                urlConnection.setDoOutput(true);
                urlConnection.setUseCaches(false);
                urlConnection.setRequestMethod("POST");
                String userAgent = mDownloadInfo.getUserAgent();
                if (TextUtils.isEmpty(userAgent)) {
                    userAgent = ContentUtils.getBrowserUserAgent();
                }
                urlConnection.setRequestProperty("User-Agent", userAgent);
                urlConnection.setRequestProperty("cookie", mDownloadInfo.getCookie());

                DataOutputStream dos = new DataOutputStream(urlConnection.getOutputStream());
                try {
                    dos.writeBytes(mStatusMessage);
                    dos.flush();
                } catch (IOException e) {
                    Log.w(TAG, "Cannot write status message.", e);
                } finally {
                    dos.close();
                }
                int responseCode = urlConnection.getResponseCode();
                if (responseCode == HttpURLConnection.HTTP_OK || responseCode == -1) {
                    success = true;
                } else {
                    success = false;
                }
            } catch (MalformedURLException e) {
                Log.w(TAG, "Invalid notification URL.", e);
            } catch (IOException e) {
                Log.w(TAG, "Cannot connect to server.", e);
            } catch (IllegalStateException e) {
                Log.w(TAG, "Cannot connect to server.", e);
            } finally {
                if (urlConnection != null) urlConnection.disconnect();
            }

            if (success) {
                String path = mDownloadInfo.getFilePath();
                if (!TextUtils.isEmpty(path)) {
                    File fromFile = new File(path);
                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                        // Copy the downloaded content to the intermediate URI and publish it.
                        String pendingUri =
                                DownloadCollectionBridge.createIntermediateUriForPublish(
                                        mDownloadInfo.getFileName(),
                                        mDownloadInfo.getMimeType(),
                                        mDownloadInfo.getOriginalUrl().getSpec(),
                                        mDownloadInfo.getReferrer().getSpec());
                        success =
                                DownloadCollectionBridge.copyFileToIntermediateUri(
                                        path, pendingUri);
                        if (success) {
                            String uri = DownloadCollectionBridge.publishDownload(pendingUri);
                            fromFile.delete();
                            // Post a nofification to open the Android download page.
                            mNewDownloadInfo =
                                    DownloadInfo.Builder.fromDownloadInfo(mDownloadInfo)
                                            .setFilePath(uri)
                                            .setContentId(
                                                    new ContentId("", String.valueOf(mDownloadId)))
                                            .build();
                        } else {
                            DownloadCollectionBridge.deleteIntermediateUri(pendingUri);
                        }
                    } else {
                        // Move the downloaded content from the app directory to public directory.
                        String fileName = fromFile.getName();
                        DownloadManager manager =
                                (DownloadManager)
                                        mContext.getSystemService(Context.DOWNLOAD_SERVICE);
                        File toFile =
                                new File(
                                        Environment.getExternalStoragePublicDirectory(
                                                Environment.DIRECTORY_DOWNLOADS),
                                        fileName);
                        success = fromFile.renameTo(toFile);
                        if (success) {
                            manager.addCompletedDownload(
                                    fileName,
                                    mDownloadInfo.getDescription(),
                                    false,
                                    mDownloadInfo.getMimeType(),
                                    toFile.getPath(),
                                    mDownloadInfo.getBytesReceived(),
                                    true);
                        }
                    }
                    if (!success) {
                        if (fromFile.delete()) {
                            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                                Log.w(TAG, "Failed to publish the downloaded file.");
                            } else {
                                Log.w(TAG, "Failed to rename the file.");
                            }
                        } else {
                            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                                Log.w(TAG, "Failed to publish and delete the file.");
                            } else {
                                Log.w(TAG, "Failed to rename and delete the file.");
                            }
                        }
                    }
                }
            }
            return success;
        }

        @Override
        protected void onPostExecute(Boolean success) {
            if (success) {
                if (mNewDownloadInfo != null) {
                    DownloadManagerService.getDownloadManagerService()
                            .getDownloadNotifier()
                            .notifyDownloadSuccessful(
                                    mNewDownloadInfo,
                                    mDownloadId,
                                    /* canResolve= */ false,
                                    /* isSupportedMimeType= */ false);
                }
                showNextUrlDialog(mOMAInfo);
            } else if (mDownloadId != DownloadConstants.INVALID_DOWNLOAD_ID) {
                // Remove the downloaded content.
                ((DownloadManager) mContext.getSystemService(Context.DOWNLOAD_SERVICE))
                        .remove(mDownloadId);
            }
        }
    }

    /**
     * Add OMA download info to SharedPrefs.
     * @param omaInfo OMA download information to save.
     */
    private void addOMADownloadToSharedPrefs(String omaInfo) {
        Set<String> omaDownloads =
                getStoredDownloadInfo(
                        mSharedPrefs, ChromePreferenceKeys.DOWNLOAD_PENDING_OMA_DOWNLOADS);
        omaDownloads.add(omaInfo);
        storeDownloadInfo(
                mSharedPrefs, ChromePreferenceKeys.DOWNLOAD_PENDING_OMA_DOWNLOADS, omaDownloads);
    }

    /**
     * Remove OMA download info from SharedPrefs.
     * @param downloadId ID to be removed.
     */
    private void removeOMADownloadFromSharedPrefs(long downloadId) {
        Set<String> omaDownloads =
                getStoredDownloadInfo(
                        mSharedPrefs, ChromePreferenceKeys.DOWNLOAD_PENDING_OMA_DOWNLOADS);
        for (String omaDownload : omaDownloads) {
            OMAEntry entry = OMAEntry.parseOMAEntry(omaDownload);
            if (entry.mDownloadId == downloadId) {
                omaDownloads.remove(omaDownload);
                storeDownloadInfo(
                        mSharedPrefs,
                        ChromePreferenceKeys.DOWNLOAD_PENDING_OMA_DOWNLOADS,
                        omaDownloads);
                return;
            }
        }
    }

    /**
     * Check if a download ID is in OMA SharedPrefs.
     * @param downloadId Download identifier to check.
     * @return true if it is in the SharedPrefs, or false otherwise.
     */
    private boolean isDownloadIdInOMASharedPrefs(long downloadId) {
        Set<String> omaDownloads =
                getStoredDownloadInfo(
                        mSharedPrefs, ChromePreferenceKeys.DOWNLOAD_PENDING_OMA_DOWNLOADS);
        for (String omaDownload : omaDownloads) {
            OMAEntry entry = OMAEntry.parseOMAEntry(omaDownload);
            if (entry.mDownloadId == downloadId) return true;
        }
        return false;
    }

    /**
     * Check whether a url path is OMA download.
     * @param path Path of download.
     */
    static boolean isOMAFile(String path) {
        if (path == null) return false;
        return path.endsWith(".dm")
                || path.endsWith(".dcf")
                || path.endsWith(".dr")
                || path.endsWith(".drc");
    }
}