| // 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.weblayer_private; |
| |
| import android.app.PendingIntent; |
| import android.content.ActivityNotFoundException; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.content.res.Resources; |
| import android.graphics.Bitmap; |
| import android.net.Uri; |
| import android.os.Build; |
| import android.os.RemoteException; |
| import android.text.TextUtils; |
| |
| import androidx.annotation.VisibleForTesting; |
| import androidx.core.app.NotificationCompat; |
| |
| import org.chromium.base.ContentUriUtils; |
| import org.chromium.base.ContextUtils; |
| import org.chromium.base.Log; |
| import org.chromium.base.annotations.CalledByNative; |
| import org.chromium.base.annotations.JNINamespace; |
| import org.chromium.base.annotations.NativeMethods; |
| import org.chromium.components.browser_ui.notifications.NotificationManagerProxy; |
| import org.chromium.components.browser_ui.notifications.NotificationManagerProxyImpl; |
| import org.chromium.components.browser_ui.notifications.NotificationMetadata; |
| import org.chromium.components.browser_ui.notifications.PendingIntentProvider; |
| import org.chromium.components.browser_ui.util.DownloadUtils; |
| import org.chromium.url.GURL; |
| import org.chromium.weblayer_private.interfaces.APICallException; |
| import org.chromium.weblayer_private.interfaces.DownloadError; |
| import org.chromium.weblayer_private.interfaces.DownloadState; |
| import org.chromium.weblayer_private.interfaces.IClientDownload; |
| import org.chromium.weblayer_private.interfaces.IDownload; |
| import org.chromium.weblayer_private.interfaces.IDownloadCallbackClient; |
| import org.chromium.weblayer_private.interfaces.StrictModeWorkaround; |
| |
| import java.io.File; |
| import java.util.HashMap; |
| |
| /** |
| * Implementation of IDownload. |
| */ |
| @JNINamespace("weblayer") |
| public final class DownloadImpl extends IDownload.Stub { |
| private static final String DOWNLOADS_PREFIX = "org.chromium.weblayer.downloads"; |
| |
| // These actions have to be synchronized with the receiver defined in AndroidManifest.xml. |
| private static final String OPEN_INTENT = DOWNLOADS_PREFIX + ".OPEN"; |
| private static final String ACTIVATE_TRANSIENT_INTENT = |
| DOWNLOADS_PREFIX + ".ACTIVATE_TRANSIENT"; |
| private static final String DELETE_INTENT = DOWNLOADS_PREFIX + ".DELETE"; |
| private static final String PAUSE_INTENT = DOWNLOADS_PREFIX + ".PAUSE"; |
| private static final String RESUME_INTENT = DOWNLOADS_PREFIX + ".RESUME"; |
| private static final String CANCEL_INTENT = DOWNLOADS_PREFIX + ".CANCEL"; |
| |
| private static final String EXTRA_NOTIFICATION_ID = DOWNLOADS_PREFIX + ".NOTIFICATION_ID"; |
| private static final String EXTRA_NOTIFICATION_LOCATION = |
| DOWNLOADS_PREFIX + ".NOTIFICATION_LOCATION"; |
| private static final String EXTRA_NOTIFICATION_MIME_TYPE = |
| DOWNLOADS_PREFIX + ".NOTIFICATION_MIME_TYPE"; |
| private static final String EXTRA_NOTIFICATION_PROFILE = |
| DOWNLOADS_PREFIX + ".NOTIFICATION_PROFILE"; |
| private static final String EXTRA_NOTIFICATION_PROFILE_IS_INCOGNITO = |
| DOWNLOADS_PREFIX + ".NOTIFICATION_PROFILE_IS_INCOGNITO"; |
| private static final String EXTRA_NOTIFICATION_SESSION_ID = |
| DOWNLOADS_PREFIX + ".NOTIFICATION_SESSION_ID"; |
| // The intent prefix is used as the notification's tag since it's guaranteed not to conflict |
| // with intent prefixes used by other subsystems that display notifications. |
| private static final String NOTIFICATION_TAG = DOWNLOADS_PREFIX; |
| private static final String TAG = "DownloadImpl"; |
| |
| private final String mProfileName; |
| private final boolean mIsIncognito; |
| // The client is only used for downloads to disk, so it will be null for transient downloads. |
| private final IDownloadCallbackClient mClient; |
| private final IClientDownload mClientDownload; |
| // WARNING: DownloadImpl may outlive the native side, in which case this member is set to 0. |
| private long mNativeDownloadImpl; |
| private boolean mDisableNotification; |
| |
| // The time this download started, in milliseconds. |
| private final long mStartTime; |
| |
| // A transient download is not persisted to disk, which affects its UI treatment. |
| private final boolean mIsTransient; |
| |
| // The originating URL for this download. |
| private final GURL mSourceUrl; |
| |
| // The large icon to show. Once this is successfully fetched from native, it won't be updated. |
| private Bitmap mLargeIcon; |
| |
| private final int mNotificationId; |
| private static final HashMap<Integer, DownloadImpl> sMap = new HashMap<Integer, DownloadImpl>(); |
| |
| /** |
| * @return a string that prefixes all intents that can be handled by {@link forwardIntent}. |
| */ |
| public static String getIntentPrefix() { |
| return DOWNLOADS_PREFIX; |
| } |
| |
| public static void forwardIntent( |
| Context context, Intent intent, ProfileManager profileManager) { |
| if (intent.getAction().equals(OPEN_INTENT)) { |
| String location = intent.getStringExtra(EXTRA_NOTIFICATION_LOCATION); |
| if (TextUtils.isEmpty(location)) { |
| Log.d(TAG, "Didn't find location for open intent"); |
| return; |
| } |
| |
| String mimeType = intent.getStringExtra(EXTRA_NOTIFICATION_MIME_TYPE); |
| |
| Intent openIntent = new Intent(Intent.ACTION_VIEW); |
| if (TextUtils.isEmpty(mimeType)) { |
| openIntent.setData(getDownloadUri(location)); |
| } else { |
| openIntent.setDataAndType(getDownloadUri(location), mimeType); |
| } |
| openIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); |
| openIntent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); |
| openIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); |
| try { |
| context.startActivity(openIntent); |
| } catch (ActivityNotFoundException ex) { |
| // TODO: show some UI that there were no apps to handle this? |
| } |
| |
| return; |
| } |
| |
| String profileName = intent.getStringExtra(EXTRA_NOTIFICATION_PROFILE); |
| boolean isIncognito; |
| if (intent.hasExtra(EXTRA_NOTIFICATION_PROFILE_IS_INCOGNITO)) { |
| isIncognito = intent.getBooleanExtra(EXTRA_NOTIFICATION_PROFILE_IS_INCOGNITO, false); |
| } else { |
| isIncognito = "".equals(profileName); |
| } |
| |
| ProfileImpl profile = profileManager.getProfile(profileName, isIncognito); |
| if (!profile.areDownloadsInitialized()) { |
| profile.addDownloadNotificationIntent(intent); |
| } else { |
| handleIntent(intent); |
| } |
| } |
| |
| public static void handleIntent(Intent intent) { |
| int id = intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1); |
| DownloadImpl download = sMap.get(id); |
| if (download == null) { |
| Log.d(TAG, "Didn't find download for " + id); |
| // TODO(jam): handle download resumption after restart |
| return; |
| } |
| |
| if (intent.getAction().equals(PAUSE_INTENT)) { |
| download.pause(); |
| } else if (intent.getAction().equals(RESUME_INTENT)) { |
| download.resume(); |
| } else if (intent.getAction().equals(CANCEL_INTENT)) { |
| download.cancel(); |
| } else if (intent.getAction().equals(DELETE_INTENT)) { |
| sMap.remove(id); |
| DownloadImplJni.get().onFinishedImpl(download.mNativeDownloadImpl, /*activated=*/false); |
| } else if (intent.getAction().equals(ACTIVATE_TRANSIENT_INTENT)) { |
| assert download.mIsTransient; |
| DownloadImplJni.get().onFinishedImpl(download.mNativeDownloadImpl, /*activated=*/true); |
| } |
| } |
| |
| public DownloadImpl(String profileName, boolean isIncognito, IDownloadCallbackClient client, |
| long nativeDownloadImpl, int id, boolean isTransient, GURL sourceUrl) { |
| mProfileName = profileName; |
| mIsIncognito = isIncognito; |
| mClient = isTransient ? null : client; |
| mNativeDownloadImpl = nativeDownloadImpl; |
| mNotificationId = id; |
| mStartTime = System.currentTimeMillis(); |
| mIsTransient = isTransient; |
| mSourceUrl = sourceUrl; |
| |
| if (mClient == null) { |
| mClientDownload = null; |
| } else { |
| try { |
| mClientDownload = mClient.createClientDownload(this); |
| } catch (RemoteException e) { |
| throw new APICallException(e); |
| } |
| } |
| DownloadImplJni.get().setJavaDownload(mNativeDownloadImpl, DownloadImpl.this); |
| } |
| |
| public IClientDownload getClientDownload() { |
| return mClientDownload; |
| } |
| |
| @DownloadState |
| private static int implStateToJavaType(@ImplDownloadState int type) { |
| switch (type) { |
| case ImplDownloadState.IN_PROGRESS: |
| return DownloadState.IN_PROGRESS; |
| case ImplDownloadState.COMPLETE: |
| return DownloadState.COMPLETE; |
| case ImplDownloadState.PAUSED: |
| return DownloadState.PAUSED; |
| case ImplDownloadState.CANCELLED: |
| return DownloadState.CANCELLED; |
| case ImplDownloadState.FAILED: |
| return DownloadState.FAILED; |
| } |
| assert false; |
| return DownloadState.FAILED; |
| } |
| |
| @DownloadError |
| private static int implErrorToJavaType(@ImplDownloadError int type) { |
| switch (type) { |
| case ImplDownloadError.NO_ERROR: |
| return DownloadError.NO_ERROR; |
| case ImplDownloadError.SERVER_ERROR: |
| return DownloadError.SERVER_ERROR; |
| case ImplDownloadError.SSL_ERROR: |
| return DownloadError.SSL_ERROR; |
| case ImplDownloadError.CONNECTIVITY_ERROR: |
| return DownloadError.CONNECTIVITY_ERROR; |
| case ImplDownloadError.NO_SPACE: |
| return DownloadError.NO_SPACE; |
| case ImplDownloadError.FILE_ERROR: |
| return DownloadError.FILE_ERROR; |
| case ImplDownloadError.CANCELLED: |
| return DownloadError.CANCELLED; |
| case ImplDownloadError.OTHER_ERROR: |
| return DownloadError.OTHER_ERROR; |
| } |
| assert false; |
| return DownloadError.OTHER_ERROR; |
| } |
| |
| @Override |
| @DownloadState |
| public int getState() { |
| StrictModeWorkaround.apply(); |
| throwIfNativeDestroyed(); |
| return implStateToJavaType(DownloadImplJni.get().getStateImpl(mNativeDownloadImpl)); |
| } |
| |
| @Override |
| public long getTotalBytes() { |
| StrictModeWorkaround.apply(); |
| throwIfNativeDestroyed(); |
| return DownloadImplJni.get().getTotalBytesImpl(mNativeDownloadImpl); |
| } |
| |
| @Override |
| public long getReceivedBytes() { |
| StrictModeWorkaround.apply(); |
| throwIfNativeDestroyed(); |
| return DownloadImplJni.get().getReceivedBytesImpl(mNativeDownloadImpl); |
| } |
| |
| @Override |
| public void pause() { |
| StrictModeWorkaround.apply(); |
| throwIfNativeDestroyed(); |
| DownloadImplJni.get().pauseImpl(mNativeDownloadImpl); |
| updateNotification(); |
| } |
| |
| @Override |
| public void resume() { |
| StrictModeWorkaround.apply(); |
| throwIfNativeDestroyed(); |
| DownloadImplJni.get().resumeImpl(mNativeDownloadImpl); |
| updateNotification(); |
| } |
| |
| @Override |
| public void cancel() { |
| StrictModeWorkaround.apply(); |
| throwIfNativeDestroyed(); |
| DownloadImplJni.get().cancelImpl(mNativeDownloadImpl); |
| updateNotification(); |
| } |
| |
| @Override |
| public String getLocation() { |
| StrictModeWorkaround.apply(); |
| throwIfNativeDestroyed(); |
| return DownloadImplJni.get().getLocationImpl(mNativeDownloadImpl); |
| } |
| |
| @Override |
| public String getFileNameToReportToUser() { |
| StrictModeWorkaround.apply(); |
| throwIfNativeDestroyed(); |
| return DownloadImplJni.get().getFileNameToReportToUserImpl(mNativeDownloadImpl); |
| } |
| |
| @Override |
| public String getMimeType() { |
| StrictModeWorkaround.apply(); |
| throwIfNativeDestroyed(); |
| return DownloadImplJni.get().getMimeTypeImpl(mNativeDownloadImpl); |
| } |
| |
| @Override |
| @DownloadError |
| public int getError() { |
| StrictModeWorkaround.apply(); |
| throwIfNativeDestroyed(); |
| return implErrorToJavaType(DownloadImplJni.get().getErrorImpl(mNativeDownloadImpl)); |
| } |
| |
| @Override |
| public void disableNotification() { |
| StrictModeWorkaround.apply(); |
| throwIfNativeDestroyed(); |
| mDisableNotification = true; |
| |
| NotificationManagerProxy notificationManager = getNotificationManager(); |
| notificationManager.cancel(NOTIFICATION_TAG, mNotificationId); |
| } |
| |
| private void throwIfNativeDestroyed() { |
| if (mNativeDownloadImpl == 0) { |
| throw new IllegalStateException("Using Download after native destroyed"); |
| } |
| } |
| |
| private Intent createIntent(String actionId) { |
| // Because the intent is using classes from the implementation's class loader, |
| // we need to use the constructor which doesn't take the app's context. |
| Intent intent = WebLayerImpl.createIntent(); |
| intent.setAction(actionId); |
| intent.putExtra(EXTRA_NOTIFICATION_ID, mNotificationId); |
| intent.putExtra(EXTRA_NOTIFICATION_PROFILE, mProfileName); |
| intent.putExtra(EXTRA_NOTIFICATION_PROFILE_IS_INCOGNITO, mIsIncognito); |
| return intent; |
| } |
| |
| public void downloadStarted() { |
| if (mDisableNotification) return; |
| |
| // TODO(jam): create a foreground service while the download is running to avoid the process |
| // being shut down if the user switches apps. |
| |
| sMap.put(Integer.valueOf(mNotificationId), this); |
| |
| updateNotification(); |
| } |
| |
| public void downloadProgressChanged() { |
| updateNotification(); |
| } |
| |
| public void downloadCompleted() { |
| updateNotification(); |
| } |
| |
| public void downloadFailed() { |
| updateNotification(); |
| } |
| |
| private void updateNotification() { |
| NotificationManagerProxy notificationManager = getNotificationManager(); |
| if (mDisableNotification || notificationManager == null) return; |
| |
| Context context = ContextUtils.getApplicationContext(); |
| |
| Intent deleteIntent = createIntent(DELETE_INTENT); |
| PendingIntentProvider deletePendingIntent = getPendingIntentProvider(deleteIntent); |
| |
| @DownloadState |
| int state = getState(); |
| if (state == DownloadState.CANCELLED) { |
| notificationManager.cancel(NOTIFICATION_TAG, mNotificationId); |
| mDisableNotification = true; |
| return; |
| } |
| |
| String channelId = state == DownloadState.COMPLETE |
| ? WebLayerNotificationChannels.ChannelId.COMPLETED_DOWNLOADS |
| : WebLayerNotificationChannels.ChannelId.ACTIVE_DOWNLOADS; |
| |
| WebLayerNotificationWrapperBuilder builder = WebLayerNotificationWrapperBuilder.create( |
| channelId, new NotificationMetadata(0, NOTIFICATION_TAG, mNotificationId)); |
| builder.setOngoing(true) |
| .setWhen(mStartTime) |
| .setShowWhen(true) |
| .setDeleteIntent(deletePendingIntent) |
| .setPriorityBeforeO(NotificationCompat.PRIORITY_DEFAULT); |
| |
| // The filename might not have been available initially. |
| String name = getFileNameToReportToUser(); |
| if (!TextUtils.isEmpty(name)) { |
| builder.setContentTitle(name); |
| } |
| |
| // Set the large icon/thumbnail, except when incognito. |
| if (!mIsIncognito && mLargeIcon == null) { |
| mLargeIcon = DownloadImplJni.get().getLargeIconImpl(mNativeDownloadImpl); |
| } |
| if (mLargeIcon != null) { |
| builder.setLargeIcon(mLargeIcon); |
| } |
| |
| // As with Chrome, transient downloads "promote" the source URL. |
| if (!mIsIncognito && mIsTransient) { |
| String formattedUrl = DownloadUtils.formatUrlForDisplayInNotification(mSourceUrl); |
| if (formattedUrl != null) setSubText(builder, formattedUrl); |
| } |
| // TODO(estade): In incognito, Chrome uses a subtext of "Incognito tab". Should WL display |
| // something similar? |
| |
| Resources resources = context.getResources(); |
| |
| if (state == DownloadState.COMPLETE) { |
| builder.setOngoing(false) |
| .setSmallIcon(android.R.drawable.stat_sys_download_done) |
| .setAutoCancel(true) |
| .setProgress(0, 0, false); |
| |
| Intent openIntent = null; |
| |
| if (mIsTransient) { |
| builder.setContentText( |
| resources.getString(R.string.download_notification_completed)); |
| openIntent = createIntent(ACTIVATE_TRANSIENT_INTENT); |
| } else { |
| builder.setContentText( |
| resources.getString(R.string.download_notification_completed_with_size, |
| DownloadUtils.getStringForBytes(context, getTotalBytes()))); |
| |
| openIntent = createIntent(OPEN_INTENT); |
| openIntent.putExtra(EXTRA_NOTIFICATION_LOCATION, getLocation()); |
| openIntent.putExtra(EXTRA_NOTIFICATION_MIME_TYPE, getMimeType()); |
| } |
| builder.setContentIntent(getPendingIntentProvider(openIntent)); |
| } else if (state == DownloadState.FAILED) { |
| builder.setContentText(resources.getString(R.string.download_notification_failed)) |
| .setOngoing(false) |
| .setSmallIcon(android.R.drawable.stat_sys_download_done) |
| .setProgress(0, 0, false); |
| } else if (state == DownloadState.IN_PROGRESS) { |
| Intent pauseIntent = createIntent(PAUSE_INTENT); |
| PendingIntentProvider pausePendingIntent = getPendingIntentProvider(pauseIntent); |
| |
| long bytes = getReceivedBytes(); |
| long totalBytes = getTotalBytes(); |
| boolean indeterminate = totalBytes == -1; |
| int progressCurrent = -1; |
| if (!indeterminate && totalBytes != 0) { |
| progressCurrent = (int) (bytes * 100 / totalBytes); |
| } |
| |
| if (!mIsTransient) { |
| String contentText; |
| String bytesString = DownloadUtils.getStringForBytes(context, bytes); |
| if (indeterminate) { |
| contentText = resources.getString( |
| R.string.download_ui_indeterminate_bytes, bytesString); |
| } else { |
| String totalString = DownloadUtils.getStringForBytes(context, totalBytes); |
| contentText = resources.getString( |
| R.string.download_ui_determinate_bytes, bytesString, totalString); |
| } |
| builder.setContentText(contentText); |
| } |
| builder.addAction(0 /* no icon */, |
| resources.getString(R.string.download_notification_pause_button), |
| pausePendingIntent, 0 /* no action for UMA */) |
| .setSmallIcon(android.R.drawable.stat_sys_download) |
| .setProgress(100, progressCurrent, indeterminate); |
| } else if (state == DownloadState.PAUSED) { |
| Intent resumeIntent = createIntent(RESUME_INTENT); |
| PendingIntentProvider resumePendingIntent = getPendingIntentProvider(resumeIntent); |
| builder.setContentText(resources.getString(R.string.download_notification_paused)) |
| .addAction(0 /* no icon */, |
| resources.getString(R.string.download_notification_resume_button), |
| resumePendingIntent, 0 /* no action for UMA */) |
| .setSmallIcon(android.R.drawable.ic_media_pause) |
| .setProgress(0, 0, false); |
| } |
| |
| if (state == DownloadState.IN_PROGRESS || state == DownloadState.PAUSED) { |
| Intent cancelIntent = createIntent(CANCEL_INTENT); |
| PendingIntentProvider cancelPendingIntent = getPendingIntentProvider(cancelIntent); |
| builder.addAction(0 /* no icon */, |
| resources.getString(R.string.download_notification_cancel_button), |
| cancelPendingIntent, 0 /* no action for UMA */); |
| } |
| |
| notificationManager.notify(builder.buildNotificationWrapper()); |
| } |
| |
| private PendingIntentProvider getPendingIntentProvider(Intent notificationIntent) { |
| // Transient intents use FLAG_CANCEL_CURRENT because the IDs can overlap across sessions. |
| // CANCEL_CURRENT makes sure the PendingIntent is not also reused, and prevents intents from |
| // old sessions from working (e.g. notifications lingering after WebLayer has crashed and |
| // failed to clear them). |
| return PendingIntentProvider.getBroadcast(ContextUtils.getApplicationContext(), |
| mNotificationId, notificationIntent, |
| mIsTransient ? PendingIntent.FLAG_CANCEL_CURRENT : 0); |
| } |
| |
| /** |
| * Helper method to set the sub text on different versions of Android. |
| * @param builder The builder to build notification. |
| * @param subText A string shown as sub text on the notification. |
| */ |
| private static void setSubText(WebLayerNotificationWrapperBuilder builder, String subText) { |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { |
| builder.setSubText(subText); |
| } else { |
| builder.setContentInfo(subText); |
| } |
| } |
| |
| /** |
| * Returns the notification manager. |
| */ |
| private static NotificationManagerProxy getNotificationManager() { |
| return new NotificationManagerProxyImpl(ContextUtils.getApplicationContext()); |
| } |
| |
| private static Uri getDownloadUri(String location) { |
| if (ContentUriUtils.isContentUri(location)) return Uri.parse(location); |
| return ContentUriUtils.getContentUriFromFile(new File(location)); |
| } |
| |
| @VisibleForTesting |
| public static void activateNotificationForTesting(int id) { |
| DownloadImpl download = sMap.get(id); |
| assert download != null; |
| DownloadImplJni.get().onFinishedImpl(download.mNativeDownloadImpl, /*activated=*/true); |
| } |
| |
| @CalledByNative |
| private void onNativeDestroyed() { |
| mNativeDownloadImpl = 0; |
| sMap.remove(mNotificationId); |
| if (mIsTransient) { |
| getNotificationManager().cancel(NOTIFICATION_TAG, mNotificationId); |
| } |
| // TODO: this should likely notify delegate in some way. |
| } |
| |
| @NativeMethods |
| interface Natives { |
| void setJavaDownload(long nativeDownloadImpl, DownloadImpl caller); |
| int getStateImpl(long nativeDownloadImpl); |
| long getTotalBytesImpl(long nativeDownloadImpl); |
| long getReceivedBytesImpl(long nativeDownloadImpl); |
| void pauseImpl(long nativeDownloadImpl); |
| void resumeImpl(long nativeDownloadImpl); |
| void cancelImpl(long nativeDownloadImpl); |
| void onFinishedImpl(long nativeDownloadImpl, boolean activated); |
| String getLocationImpl(long nativeDownloadImpl); |
| String getFileNameToReportToUserImpl(long nativeDownloadImpl); |
| String getMimeTypeImpl(long nativeDownloadImpl); |
| int getErrorImpl(long nativeDownloadImpl); |
| Bitmap getLargeIconImpl(long nativeDownloadImpl); |
| } |
| } |