diff options
Diffstat (limited to 'chromium/components/browser_ui/share')
9 files changed, 1583 insertions, 0 deletions
diff --git a/chromium/components/browser_ui/share/DEPS b/chromium/components/browser_ui/share/DEPS new file mode 100644 index 00000000000..876f1347c5f --- /dev/null +++ b/chromium/components/browser_ui/share/DEPS @@ -0,0 +1,7 @@ +include_rules = [ + "+chrome/android/java/src/org/chromium/chrome/browser/FileProviderHelper.java", + "+components/dom_distiller/core/android", + "+content/public/android", + "+content/public/test/android/javatests", + "+ui/android", +] diff --git a/chromium/components/browser_ui/share/OWNERS b/chromium/components/browser_ui/share/OWNERS new file mode 100644 index 00000000000..109f4d68517 --- /dev/null +++ b/chromium/components/browser_ui/share/OWNERS @@ -0,0 +1,5 @@ +dtrainor@chromium.org + +file://components/send_tab_to_self/OWNERS + +# COMPONENT: UI>Browser>Sharing diff --git a/chromium/components/browser_ui/share/android/BUILD.gn b/chromium/components/browser_ui/share/android/BUILD.gn new file mode 100644 index 00000000000..6c370e7cba3 --- /dev/null +++ b/chromium/components/browser_ui/share/android/BUILD.gn @@ -0,0 +1,53 @@ +# 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") + +android_library("java") { + sources = [ + "java/src/org/chromium/components/browser_ui/share/ShareDialogAdapter.java", + "java/src/org/chromium/components/browser_ui/share/ShareHelper.java", + "java/src/org/chromium/components/browser_ui/share/ShareImageFileUtils.java", + "java/src/org/chromium/components/browser_ui/share/ShareParams.java", + ] + deps = [ + ":java_resources", + "//base:base_java", + "//components/browser_ui/util/android:java", + "//components/dom_distiller/core/android:dom_distiller_core_java", + "//content/public/android:content_java", + "//third_party/android_deps:androidx_annotation_annotation_java", + "//third_party/android_deps:androidx_appcompat_appcompat_java", + "//third_party/android_deps:androidx_core_core_java", + "//ui/android:ui_java", + ] +} + +android_resources("java_resources") { + custom_package = "org.chromium.components.browser_ui.share" + sources = [ "java/res/layout/share_dialog_item.xml" ] + deps = [ + "//components/browser_ui/strings/android:browser_ui_strings_grd", + "//components/browser_ui/styles/android:java_resources", + ] +} + +android_library("javatests") { + testonly = true + + sources = [ "java/src/org/chromium/components/browser_ui/share/ShareImageFileUtilsTest.java" ] + deps = [ + ":java", + "//base:base_java", + "//base:base_java_test_support", + "//chrome/android:chrome_java", + "//content/public/test/android:content_java_test_support", + "//third_party/android_deps:androidx_appcompat_appcompat_java", + "//third_party/android_deps:androidx_core_core_java", + "//third_party/hamcrest:hamcrest_java", + "//third_party/junit", + "//ui/android:ui_java", + "//ui/android:ui_java_test_support", + ] +} diff --git a/chromium/components/browser_ui/share/android/java/res/layout/share_dialog_item.xml b/chromium/components/browser_ui/share/android/java/res/layout/share_dialog_item.xml new file mode 100644 index 00000000000..7a716c41522 --- /dev/null +++ b/chromium/components/browser_ui/share/android/java/res/layout/share_dialog_item.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright 2014 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. --> + +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="?attr/selectableItemBackground" + android:gravity="center_vertical" + android:orientation="horizontal" > + <ImageView android:id="@+id/icon" + android:layout_width="48dp" + android:layout_height="48dp" + android:layout_marginStart="12dp" + android:padding="4dp" + android:scaleType="fitCenter" + tools:ignore="ContentDescription" /> + <TextView android:id="@+id/text" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="12dp" + android:ellipsize="end" + android:maxLines="2" + android:textAlignment="viewStart" + android:textAppearance="?android:attr/textAppearanceMedium" /> +</LinearLayout>
\ No newline at end of file diff --git a/chromium/components/browser_ui/share/android/java/src/org/chromium/components/browser_ui/share/ShareDialogAdapter.java b/chromium/components/browser_ui/share/android/java/src/org/chromium/components/browser_ui/share/ShareDialogAdapter.java new file mode 100644 index 00000000000..811dfdd3d81 --- /dev/null +++ b/chromium/components/browser_ui/share/android/java/src/org/chromium/components/browser_ui/share/ShareDialogAdapter.java @@ -0,0 +1,52 @@ +// Copyright 2014 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.browser_ui.share; + +import android.content.Context; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.ImageView; +import android.widget.TextView; + +import java.util.List; + +/** + * Adapter that provides the list of activities via which a web page can be shared. + */ +class ShareDialogAdapter extends ArrayAdapter<ResolveInfo> { + private final LayoutInflater mInflater; + private final PackageManager mManager; + + /** + * @param context Context used to for layout inflation. + * @param manager PackageManager used to query for activity information. + * @param objects The list of possible share intents. + */ + public ShareDialogAdapter(Context context, PackageManager manager, List<ResolveInfo> objects) { + super(context, R.layout.share_dialog_item, objects); + mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + mManager = manager; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + View view; + if (convertView == null) { + view = mInflater.inflate(R.layout.share_dialog_item, parent, false); + } else { + view = convertView; + } + TextView text = (TextView) view.findViewById(R.id.text); + ImageView icon = (ImageView) view.findViewById(R.id.icon); + + text.setText(getItem(position).loadLabel(mManager)); + icon.setImageDrawable(ShareHelper.loadIconForResolveInfo(getItem(position), mManager)); + return view; + } +} diff --git a/chromium/components/browser_ui/share/android/java/src/org/chromium/components/browser_ui/share/ShareHelper.java b/chromium/components/browser_ui/share/android/java/src/org/chromium/components/browser_ui/share/ShareHelper.java new file mode 100644 index 00000000000..993e5d7c165 --- /dev/null +++ b/chromium/components/browser_ui/share/android/java/src/org/chromium/components/browser_ui/share/ShareHelper.java @@ -0,0 +1,394 @@ +// 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.browser_ui.share; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.ClipData; +import android.content.ComponentName; +import android.content.Context; +import android.content.DialogInterface; +import android.content.DialogInterface.OnDismissListener; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.ActivityInfo; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; +import android.content.pm.ResolveInfo; +import android.content.res.Resources; +import android.content.res.Resources.NotFoundException; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Build; +import android.text.TextUtils; +import android.view.View; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemClickListener; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.appcompat.app.AlertDialog; + +import org.chromium.base.ApiCompatibilityUtils; +import org.chromium.base.ContextUtils; +import org.chromium.base.PackageManagerUtils; +import org.chromium.components.browser_ui.share.ShareParams.TargetChosenCallback; +import org.chromium.ui.UiUtils; +import org.chromium.ui.base.WindowAndroid; +import org.chromium.ui.base.WindowAndroid.IntentCallback; + +import java.util.Collections; +import java.util.List; + +/** + * A helper class that helps to start an intent to share titles and URLs. + */ +public class ShareHelper { + /** Interface that receives intents for testing (to fake out actually sending them). */ + public interface FakeIntentReceiver { + /** Sets the intent to send back in the broadcast. */ + public void setIntentToSendBack(Intent intent); + + /** Called when a custom chooser dialog is shown. */ + public void onCustomChooserShown(AlertDialog dialog); + + /** + * Simulates firing the given intent, without actually doing so. + * + * @param context The context that will receive broadcasts from the simulated activity. + * @param intent The intent to send to the system. + */ + public void fireIntent(Context context, Intent intent); + } + + /** The task ID of the activity that triggered the share action. */ + public static final String EXTRA_TASK_ID = "org.chromium.chrome.extra.TASK_ID"; + + private static final String EXTRA_SHARE_SCREENSHOT_AS_STREAM = "share_screenshot_as_stream"; + + /** Force the use of a Chrome-specific intent chooser, not the system chooser. */ + private static boolean sForceCustomChooserForTesting; + + /** If non-null, will be used instead of the real activity. */ + private static FakeIntentReceiver sFakeIntentReceiverForTesting; + + protected ShareHelper() {} + + /** + * Fire the intent to share content with the target app. + * + * @param window The current window. + * @param intent The intent to fire. + * @param callback The callback to be triggered when the calling activity has finished. This + * allows the target app to identify Chrome as the source. + */ + protected static void fireIntent( + WindowAndroid window, Intent intent, @Nullable IntentCallback callback) { + if (sFakeIntentReceiverForTesting != null) { + sFakeIntentReceiverForTesting.fireIntent(ContextUtils.getApplicationContext(), intent); + } else if (callback != null) { + window.showIntent(intent, callback, null); + } else { + // TODO(tedchoc): Allow startActivity w/o intent via Window. + Activity activity = window.getActivity().get(); + activity.startActivity(intent); + } + } + + /** + * Force the use of a Chrome-specific intent chooser, not the system chooser. + * + * This emulates the behavior on pre Lollipop-MR1 systems, where the system chooser is not + * available. + */ + public static void setForceCustomChooserForTesting(boolean enabled) { + sForceCustomChooserForTesting = enabled; + } + + /** + * Uses a FakeIntentReceiver instead of actually sending intents to the system. + * + * @param receiver The object to send intents to. If null, resets back to the default behavior + * (really send intents). + */ + public static void setFakeIntentReceiverForTesting(FakeIntentReceiver receiver) { + sFakeIntentReceiverForTesting = receiver; + } + + /** + * Receiver to record the chosen component when sharing an Intent. + */ + public static class TargetChosenReceiver extends BroadcastReceiver implements IntentCallback { + private static final String EXTRA_RECEIVER_TOKEN = "receiver_token"; + private static final Object LOCK = new Object(); + + private static String sTargetChosenReceiveAction; + private static TargetChosenReceiver sLastRegisteredReceiver; + + @Nullable + private TargetChosenCallback mCallback; + + private TargetChosenReceiver(@Nullable TargetChosenCallback callback) { + mCallback = callback; + } + + public static boolean isSupported() { + return !sForceCustomChooserForTesting + && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1; + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP_MR1) + public static void sendChooserIntent(WindowAndroid window, Intent sharingIntent, + @Nullable TargetChosenCallback callback) { + final Context context = ContextUtils.getApplicationContext(); + final String packageName = context.getPackageName(); + synchronized (LOCK) { + if (sTargetChosenReceiveAction == null) { + sTargetChosenReceiveAction = + packageName + "/" + TargetChosenReceiver.class.getName() + "_ACTION"; + } + if (sLastRegisteredReceiver != null) { + context.unregisterReceiver(sLastRegisteredReceiver); + // Must cancel the callback (to satisfy guarantee that exactly one method of + // TargetChosenCallback is called). + sLastRegisteredReceiver.cancel(); + } + sLastRegisteredReceiver = new TargetChosenReceiver(callback); + context.registerReceiver( + sLastRegisteredReceiver, new IntentFilter(sTargetChosenReceiveAction)); + } + + Intent intent = new Intent(sTargetChosenReceiveAction); + intent.setPackage(packageName); + intent.putExtra(EXTRA_RECEIVER_TOKEN, sLastRegisteredReceiver.hashCode()); + Activity activity = window.getActivity().get(); + final PendingIntent pendingIntent = PendingIntent.getBroadcast(activity, 0, intent, + PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_ONE_SHOT); + Intent chooserIntent = Intent.createChooser(sharingIntent, + context.getString(R.string.share_link_chooser_title), + pendingIntent.getIntentSender()); + if (sFakeIntentReceiverForTesting != null) { + sFakeIntentReceiverForTesting.setIntentToSendBack(intent); + } + fireIntent(window, chooserIntent, sLastRegisteredReceiver); + } + + @Override + public void onReceive(Context context, Intent intent) { + synchronized (LOCK) { + if (sLastRegisteredReceiver != this) return; + ContextUtils.getApplicationContext().unregisterReceiver(sLastRegisteredReceiver); + sLastRegisteredReceiver = null; + } + if (!intent.hasExtra(EXTRA_RECEIVER_TOKEN) + || intent.getIntExtra(EXTRA_RECEIVER_TOKEN, 0) != this.hashCode()) { + return; + } + + ComponentName target = intent.getParcelableExtra(Intent.EXTRA_CHOSEN_COMPONENT); + if (mCallback != null) { + mCallback.onTargetChosen(target); + mCallback = null; + } + } + + @Override + public void onIntentCompleted(WindowAndroid window, int resultCode, Intent data) { + if (resultCode == Activity.RESULT_CANCELED) { + cancel(); + } + } + + private void cancel() { + if (mCallback != null) { + mCallback.onCancel(); + mCallback = null; + } + } + } + + /** + * Creates and shows a custom share intent picker dialog. + * + * @param params The container holding the share parameters. + */ + static void showCompatShareDialog(final ShareParams params) { + Intent intent = getShareLinkAppCompatibilityIntent(); + List<ResolveInfo> resolveInfoList = PackageManagerUtils.queryIntentActivities(intent, 0); + assert resolveInfoList.size() > 0; + if (resolveInfoList.size() == 0) return; + + final Context context = params.getWindow().getContext().get(); + final PackageManager manager = context.getPackageManager(); + Collections.sort(resolveInfoList, new ResolveInfo.DisplayNameComparator(manager)); + + final ShareDialogAdapter adapter = + new ShareDialogAdapter(context, manager, resolveInfoList); + AlertDialog.Builder builder = new UiUtils.CompatibleAlertDialogBuilder( + context, R.style.Theme_Chromium_AlertDialog); + builder.setTitle(context.getString(R.string.share_link_chooser_title)); + builder.setAdapter(adapter, null); + + final TargetChosenCallback callback = params.getCallback(); + // Need a mutable object to record whether the callback has been fired. + final boolean[] callbackCalled = new boolean[1]; + + final AlertDialog dialog = builder.create(); + dialog.show(); + dialog.getListView().setOnItemClickListener(new OnItemClickListener() { + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + ResolveInfo info = adapter.getItem(position); + ActivityInfo ai = info.activityInfo; + ComponentName component = + new ComponentName(ai.applicationInfo.packageName, ai.name); + + if (callback != null && !callbackCalled[0]) { + callback.onTargetChosen(component); + callbackCalled[0] = true; + } + shareDirectly(params, component); + dialog.dismiss(); + } + }); + + dialog.setOnDismissListener(new OnDismissListener() { + @Override + public void onDismiss(DialogInterface dialog) { + if (callback != null && !callbackCalled[0]) { + callback.onCancel(); + callbackCalled[0] = true; + } + } + }); + + if (sFakeIntentReceiverForTesting != null) { + sFakeIntentReceiverForTesting.onCustomChooserShown(dialog); + } + } + + /** + * Shares the params using the system share sheet, or skipping the sheet and sharing directl if + * the target component is specified. + */ + static void shareWithSystemSheet(ShareParams params) { + assert TargetChosenReceiver.isSupported(); + TargetChosenReceiver.sendChooserIntent( + params.getWindow(), getShareLinkIntent(params), params.getCallback()); + } + + /** + * Shows a picker and allows the user to choose a share target. + * + * @param params The container holding the share parameters. + */ + public static void shareWithUi(ShareParams params) { + if (TargetChosenReceiver.isSupported()) { + // On L+ open system share sheet. + shareWithSystemSheet(params); + } else { + // On K and below open custom share dialog. + showCompatShareDialog(params); + } + } + + /** + * Share directly with the provied share target. + * @param params The container holding the share parameters. + * @param component The component to share to, bypassing any UI. + */ + public static void shareDirectly( + @NonNull ShareParams params, @NonNull ComponentName component) { + Intent intent = getShareLinkIntent(params); + intent.addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT | Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP); + intent.setComponent(component); + fireIntent(params.getWindow(), intent, null); + } + + @VisibleForTesting + public static Intent getShareLinkIntent(ShareParams params) { + final boolean isFileShare = (params.getFileUris() != null); + final boolean isMultipleFileShare = isFileShare && (params.getFileUris().size() > 1); + final String action = + isMultipleFileShare ? Intent.ACTION_SEND_MULTIPLE : Intent.ACTION_SEND; + Intent intent = new Intent(action); + intent.addFlags(ApiCompatibilityUtils.getActivityNewDocumentFlag() + | Intent.FLAG_ACTIVITY_FORWARD_RESULT | Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP); + intent.putExtra(EXTRA_TASK_ID, params.getWindow().getActivity().get().getTaskId()); + + Uri screenshotUri = params.getScreenshotUri(); + if (screenshotUri != null) { + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + // To give read access to an Intent target, we need to put |screenshotUri| in clipData + // because adding Intent.FLAG_GRANT_READ_URI_PERMISSION doesn't work for + // EXTRA_SHARE_SCREENSHOT_AS_STREAM. + intent.setClipData(ClipData.newRawUri("", screenshotUri)); + intent.putExtra(EXTRA_SHARE_SCREENSHOT_AS_STREAM, screenshotUri); + } + + if (params.getOfflineUri() != null) { + intent.putExtra(Intent.EXTRA_SUBJECT, params.getTitle()); + intent.addFlags(Intent.FLAG_ACTIVITY_MULTIPLE_TASK); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + intent.putExtra(Intent.EXTRA_STREAM, params.getOfflineUri()); + intent.addCategory(Intent.CATEGORY_DEFAULT); + intent.setType("multipart/related"); + } else { + if (!TextUtils.equals(params.getText(), params.getTitle())) { + intent.putExtra(Intent.EXTRA_SUBJECT, params.getTitle()); + } + intent.putExtra(Intent.EXTRA_TEXT, params.getText()); + + if (isFileShare) { + intent.setType(params.getFileContentType()); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + + if (isMultipleFileShare) { + intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, params.getFileUris()); + } else { + intent.putExtra(Intent.EXTRA_STREAM, params.getFileUris().get(0)); + } + } else { + intent.setType("text/plain"); + } + } + + return intent; + } + + /** + * Convenience method to create an Intent to retrieve all the apps support sharing text. + */ + public static Intent getShareLinkAppCompatibilityIntent() { + Intent intent = new Intent(Intent.ACTION_SEND); + intent.addFlags(ApiCompatibilityUtils.getActivityNewDocumentFlag()); + intent.putExtra(Intent.EXTRA_SUBJECT, ""); + intent.putExtra(Intent.EXTRA_TEXT, ""); + intent.setType("text/plain"); + return intent; + } + + /** + * Loads the icon for the provided ResolveInfo. + * @param info The ResolveInfo to load the icon for. + * @param manager The package manager to use to load the icon. + */ + public static Drawable loadIconForResolveInfo(ResolveInfo info, PackageManager manager) { + try { + final int iconRes = info.getIconResource(); + if (iconRes != 0) { + Resources res = manager.getResourcesForApplication(info.activityInfo.packageName); + Drawable icon = ApiCompatibilityUtils.getDrawable(res, iconRes); + return icon; + } + } catch (NameNotFoundException | NotFoundException e) { + // Could not find the icon. loadIcon call below will return the default app icon. + } + return info.loadIcon(manager); + } +} diff --git a/chromium/components/browser_ui/share/android/java/src/org/chromium/components/browser_ui/share/ShareImageFileUtils.java b/chromium/components/browser_ui/share/android/java/src/org/chromium/components/browser_ui/share/ShareImageFileUtils.java new file mode 100644 index 00000000000..246b0c7a9b3 --- /dev/null +++ b/chromium/components/browser_ui/share/android/java/src/org/chromium/components/browser_ui/share/ShareImageFileUtils.java @@ -0,0 +1,449 @@ +// 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.browser_ui.share; + +import android.annotation.TargetApi; +import android.app.DownloadManager; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.graphics.Bitmap; +import android.net.Uri; +import android.os.Environment; +import android.provider.MediaStore; +import android.text.TextUtils; + +import androidx.annotation.VisibleForTesting; + +import org.chromium.base.ApplicationState; +import org.chromium.base.ApplicationStatus; +import org.chromium.base.BuildInfo; +import org.chromium.base.Callback; +import org.chromium.base.ContentUriUtils; +import org.chromium.base.ContextUtils; +import org.chromium.base.FileUtils; +import org.chromium.base.Log; +import org.chromium.base.StreamUtil; +import org.chromium.base.task.AsyncTask; +import org.chromium.components.browser_ui.util.DownloadUtils; +import org.chromium.content_public.browser.RenderWidgetHostView; +import org.chromium.content_public.browser.WebContents; +import org.chromium.ui.UiUtils; +import org.chromium.ui.base.Clipboard; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Locale; + +/** + * Utility class for file operations for image data. + */ +public class ShareImageFileUtils { + private static final String TAG = "share"; + + /** + * Directory name for shared images. + * + * Named "screenshot" for historical reasons as we only initially shared screenshot images. + * TODO(crbug.com/1055886): consider changing the directory name. + */ + private static final String SHARE_IMAGES_DIRECTORY_NAME = "screenshot"; + private static final String JPEG_EXTENSION = ".jpg"; + private static final String FILE_NUMBER_FORMAT = " (%d)"; + private static final String MIME_TYPE = "image/JPEG"; + + /** + * Check if the file related to |fileUri| is in the |folder|. + * + * @param fileUri The {@link Uri} related to the file to be checked. + * @param folder The folder that may contain the |fileUrl|. + * @return Whether the |fileUri| is in the |folder|. + */ + private static boolean isUriInDirectory(Uri fileUri, File folder) { + if (fileUri == null) return false; + + Uri chromeUriPrefix = ContentUriUtils.getContentUriFromFile(folder); + if (chromeUriPrefix == null) return false; + + return fileUri.toString().startsWith(chromeUriPrefix.toString()); + } + + /** + * Check if the system clipboard contains a Uri that comes from Chrome. If yes, return the file + * name from the Uri, otherwise return null. + * + * @return The file name if system clipboard contains a Uri from Chrome, otherwise return null. + */ + private static String getClipboardCurrentFilepath() throws IOException { + Uri clipboardUri = Clipboard.getInstance().getImageUri(); + if (isUriInDirectory(clipboardUri, getSharedFilesDirectory())) { + return clipboardUri.getPath(); + } + return null; + } + + /** + * Returns the directory where temporary files are stored to be shared with external + * applications. These files are deleted on startup and when there are no longer any active + * Activities. + * + * @return The directory where shared files are stored. + */ + public static File getSharedFilesDirectory() throws IOException { + File imagePath = UiUtils.getDirectoryForImageCapture(ContextUtils.getApplicationContext()); + return new File(imagePath, SHARE_IMAGES_DIRECTORY_NAME); + } + + /** + * Clears all shared image files. + */ + public static void clearSharedImages() { + AsyncTask.SERIAL_EXECUTOR.execute(() -> { + try { + String clipboardFilepath = getClipboardCurrentFilepath(); + FileUtils.recursivelyDeleteFile(getSharedFilesDirectory(), (filepath) -> { + return filepath == null || clipboardFilepath == null + || !filepath.endsWith(clipboardFilepath); + }); + } catch (IOException ie) { + // Ignore exception. + } + }); + } + + /** + * Temporarily saves the given set of image bytes and provides that URI to a callback for + * sharing. + * + * @param context The context used to trigger the share action. + * @param imageData The image data to be shared in |fileExtension| format. + * @param fileExtension File extension which |imageData| encoded to. + * @param callback A provided callback function which will act on the generated URI. + */ + public static void generateTemporaryUriFromData(final Context context, final byte[] imageData, + String fileExtension, Callback<Uri> callback) { + if (imageData.length == 0) { + Log.w(TAG, "Share failed -- Received image contains no data."); + return; + } + OnImageSaveListener listener = new OnImageSaveListener() { + @Override + public void onImageSaved(Uri uri, String displayName) { + callback.onResult(uri); + } + @Override + public void onImageSaveError(String displayName) {} + }; + + String fileName = String.valueOf(System.currentTimeMillis()); + // Path is passed as a function because in some cases getting the path should be run on a + // background thread. + saveImage(fileName, + () + -> { return ""; }, + listener, (fos) -> { writeImageData(fos, imageData); }, true, fileExtension); + } + + /** + * Saves bitmap to external storage directory. + * + * @param context The Context to use for determining download location. + * @param filename The filename without extension. + * @param bitmap The Bitmap to download. + * @param listener The OnImageSaveListener to notify the download results. + */ + public static void saveBitmapToExternalStorage( + final Context context, String fileName, Bitmap bitmap, OnImageSaveListener listener) { + // Passing the path as a function so that it can be called on a background thread in + // |saveImage|. + saveImage(fileName, + () + -> { + return context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS).getPath(); + }, + listener, (fos) -> { writeBitmap(fos, bitmap); }, false, JPEG_EXTENSION); + } + + /** + * Interface for notifying image download result. + */ + public interface OnImageSaveListener { + void onImageSaved(Uri uri, String displayName); + void onImageSaveError(String displayName); + } + + /** + * Interface for writing image information to a output stream. + */ + private interface FileOutputStreamWriter { + void write(FileOutputStream fos) throws IOException; + } + + /** + * Interface for providing file path. This is used for passing a function for getting the path + * to other function to be called while on a background thread. Should be used on a background + * thread. + */ + private interface FilePathProvider { + String getPath(); + } + + /** + * Saves image to the given file. + * + * @param fileName The File instance of a destination file. + * @param filePathProvider The FilePathProvider for obtaining destination file path. + * @param listener The OnImageSaveListener to notify the download results. + * @param writer The FileOutputStreamWriter that writes to given stream. + * @param isTemporary Indicates whether image should be save to a temporary file. + * @param fileExtension The file's extension. + */ + private static void saveImage(String fileName, FilePathProvider filePathProvider, + OnImageSaveListener listener, FileOutputStreamWriter writer, boolean isTemporary, + String fileExtension) { + new AsyncTask<Uri>() { + @Override + protected Uri doInBackground() { + FileOutputStream fOut = null; + File destFile = null; + try { + destFile = createFile( + fileName, filePathProvider.getPath(), isTemporary, fileExtension); + if (destFile != null && destFile.exists()) { + fOut = new FileOutputStream(destFile); + writer.write(fOut); + } else { + Log.w(TAG, + "Share failed -- Unable to create or write to destination file."); + } + } catch (IOException ie) { + cancel(true); + } finally { + StreamUtil.closeQuietly(fOut); + } + + Uri uri = null; + if (!isTemporary) { + if (BuildInfo.isAtLeastQ()) { + uri = addToMediaStore(destFile); + } else { + long downloadId = addCompletedDownload(destFile); + DownloadManager manager = + (DownloadManager) ContextUtils.getApplicationContext() + .getSystemService(Context.DOWNLOAD_SERVICE); + return manager.getUriForDownloadedFile(downloadId); + } + } else { + uri = FileUtils.getUriForFile(destFile); + } + return uri; + } + + @Override + protected void onCancelled() { + listener.onImageSaveError(fileName); + } + + @Override + protected void onPostExecute(Uri uri) { + if (uri == null) { + listener.onImageSaveError(fileName); + return; + } + + if (ApplicationStatus.getStateForApplication() + == ApplicationState.HAS_DESTROYED_ACTIVITIES) { + return; + } + + listener.onImageSaved(uri, fileName); + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + /** + * Creates file with specified path, name and extension. + * + * @param filePath The file path a destination file. + * @param fileName The file name a destination file. + * @param isTemporary Indicates whether image should be save to a temporary file. + * @param fileExtension The file's extension. + * + * @return The new File object. + */ + private static File createFile(String fileName, String filePath, boolean isTemporary, + String fileExtension) throws IOException { + File path; + if (filePath.isEmpty()) { + path = getSharedFilesDirectory(); + } else { + path = new File(filePath); + } + + File newFile = null; + if (path.exists() || path.mkdir()) { + if (isTemporary) { + newFile = File.createTempFile(fileName, fileExtension, path); + } else { + newFile = getNextAvailableFile(filePath, fileName, fileExtension); + } + } + + return newFile; + } + + /** + * Returns next available file for the given fileName. + * + * @param filePath The file path a destination file. + * @param fileName The file name a destination file. + * @param extension The extension a destination file. + * + * @return The new File object. + */ + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + public static File getNextAvailableFile(String filePath, String fileName, String extension) + throws IOException { + File destFile = new File(filePath, fileName + extension); + int num = 0; + while (destFile.exists()) { + destFile = new File(filePath, + fileName + String.format(Locale.getDefault(), FILE_NUMBER_FORMAT, ++num) + + extension); + } + destFile.createNewFile(); + + return destFile; + } + + /** + * Writes given bitmap to into the given fos. + * + * @param fos The FileOutputStream to write to. + * @param bitmap The Bitmap to write. + */ + private static void writeBitmap(FileOutputStream fos, Bitmap bitmap) throws IOException { + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, fos); + } + + /** + * Writes given data to into the given fos. + * + * @param fos The FileOutputStream to write to. + * @param byte[] The byte[] to write. + */ + private static void writeImageData(FileOutputStream fos, final byte[] data) throws IOException { + fos.write(data); + } + + /** + * This is a pass through to the {@link AndroidDownloadManager} function of the same name. + * @param file The File corresponding to the download. + * @return the download ID of this item as assigned by the download manager. + */ + public static long addCompletedDownload(File file) { + String title = file.getName(); + String path = file.getPath(); + long length = file.length(); + + return DownloadUtils.addCompletedDownload( + title, title, MIME_TYPE, path, length, null, null); + } + + @TargetApi(29) + public static Uri addToMediaStore(File file) { + assert BuildInfo.isAtLeastQ(); + + final ContentValues contentValues = new ContentValues(); + contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, file.getName()); + contentValues.put(MediaStore.MediaColumns.MIME_TYPE, MIME_TYPE); + contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS); + + ContentResolver database = ContextUtils.getApplicationContext().getContentResolver(); + Uri insertUri = database.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues); + + InputStream input = null; + OutputStream output = null; + try { + input = new FileInputStream(file); + if (insertUri != null) { + output = database.openOutputStream(insertUri); + } + if (output != null) { + byte[] buffer = new byte[4096]; + int byteCount = 0; + while ((byteCount = input.read(buffer)) != -1) { + output.write(buffer, 0, byteCount); + } + } + file.delete(); + } catch (IOException e) { + } finally { + StreamUtil.closeQuietly(input); + StreamUtil.closeQuietly(output); + } + return insertUri; + } + + /** + * Captures a screenshot for the provided web contents, persists it and notifies the file + * provider that the file is ready to be accessed by the client. + * + * The screenshot is compressed to JPEG before being written to the file. + * + * @param contents The WebContents instance for which to capture a screenshot. + * @param width The desired width of the resulting screenshot, or 0 for "auto." + * @param height The desired height of the resulting screenshot, or 0 for "auto." + * @param callback The callback that will be called once the screenshot is saved. + */ + public static void captureScreenshotForContents( + WebContents contents, int width, int height, Callback<Uri> callback) { + RenderWidgetHostView rwhv = contents.getRenderWidgetHostView(); + if (rwhv == null) { + callback.onResult(null); + return; + } + try { + String path = UiUtils.getDirectoryForImageCapture(ContextUtils.getApplicationContext()) + + File.separator + SHARE_IMAGES_DIRECTORY_NAME; + rwhv.writeContentBitmapToDiskAsync( + width, height, path, new ExternallyVisibleUriCallback(callback)); + } catch (IOException e) { + Log.e(TAG, "Error getting content bitmap: ", e); + callback.onResult(null); + } + } + + private static class ExternallyVisibleUriCallback implements Callback<String> { + private Callback<Uri> mComposedCallback; + ExternallyVisibleUriCallback(Callback<Uri> cb) { + mComposedCallback = cb; + } + + @Override + public void onResult(final String path) { + if (TextUtils.isEmpty(path)) { + mComposedCallback.onResult(null); + return; + } + + new AsyncTask<Uri>() { + @Override + protected Uri doInBackground() { + return ContentUriUtils.getContentUriFromFile(new File(path)); + } + + @Override + protected void onPostExecute(Uri uri) { + mComposedCallback.onResult(uri); + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + } +} diff --git a/chromium/components/browser_ui/share/android/java/src/org/chromium/components/browser_ui/share/ShareImageFileUtilsTest.java b/chromium/components/browser_ui/share/android/java/src/org/chromium/components/browser_ui/share/ShareImageFileUtilsTest.java new file mode 100644 index 00000000000..087d53556ea --- /dev/null +++ b/chromium/components/browser_ui/share/android/java/src/org/chromium/components/browser_ui/share/ShareImageFileUtilsTest.java @@ -0,0 +1,348 @@ +// 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.browser_ui.share; + +import android.annotation.TargetApi; +import android.app.DownloadManager; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.Context; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.net.Uri; +import android.os.Environment; +import android.os.Looper; +import android.provider.MediaStore; + +import androidx.test.filters.SmallTest; + +import org.hamcrest.Matchers; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.chromium.base.BuildInfo; +import org.chromium.base.Callback; +import org.chromium.base.ContentUriUtils; +import org.chromium.base.ContextUtils; +import org.chromium.base.task.AsyncTask; +import org.chromium.base.test.BaseJUnit4ClassRunner; +import org.chromium.base.test.util.CallbackHelper; +import org.chromium.base.test.util.DisableIf; +import org.chromium.base.test.util.DisabledTest; +import org.chromium.chrome.browser.FileProviderHelper; +import org.chromium.content_public.browser.test.util.Criteria; +import org.chromium.content_public.browser.test.util.CriteriaHelper; +import org.chromium.ui.base.Clipboard; +import org.chromium.ui.test.util.DummyUiActivityTestCase; + +import java.io.File; +import java.io.IOException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * Tests of {@link ShareImageFileUtils}. + */ +@RunWith(BaseJUnit4ClassRunner.class) +public class ShareImageFileUtilsTest extends DummyUiActivityTestCase { + private static final long WAIT_TIMEOUT_SECONDS = 30L; + private static final byte[] TEST_IMAGE_DATA = new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; + private static final String TEST_IMAGE_FILE_NAME = "chrome-test-bitmap"; + private static final String TEST_GIF_IMAGE_FILE_EXTENSION = ".gif"; + private static final String TEST_JPG_IMAGE_FILE_EXTENSION = ".jpg"; + private static final String TEST_PNG_IMAGE_FILE_EXTENSION = ".png"; + + private class GenerateUriCallback extends CallbackHelper implements Callback<Uri> { + private Uri mImageUri; + + public Uri getImageUri() { + return mImageUri; + } + + @Override + public void onResult(Uri uri) { + mImageUri = uri; + notifyCalled(); + } + } + + private class AsyncTaskRunnableHelper extends CallbackHelper implements Runnable { + @Override + public void run() { + notifyCalled(); + } + } + + @Override + public void setUpTest() throws Exception { + super.setUpTest(); + Looper.prepare(); + ContentUriUtils.setFileProviderUtil(new FileProviderHelper()); + } + + @Override + public void tearDownTest() throws Exception { + Clipboard.getInstance().setText(""); + clearSharedImages(); + deleteAllTestImages(); + super.tearDownTest(); + } + + private int fileCount(File file) { + if (file.isFile()) { + return 1; + } + + int count = 0; + if (file.isDirectory()) { + for (File f : file.listFiles()) count += fileCount(f); + } + return count; + } + + private boolean filepathExists(File file, String filepath) { + if (file.isFile() && filepath.endsWith(file.getName())) { + return true; + } + + if (file.isDirectory()) { + for (File f : file.listFiles()) { + if (filepathExists(f, filepath)) return true; + } + } + return false; + } + + private Uri generateAnImageToClipboard(String fileExtension) throws TimeoutException { + GenerateUriCallback imageCallback = new GenerateUriCallback(); + ShareImageFileUtils.generateTemporaryUriFromData( + getActivity(), TEST_IMAGE_DATA, fileExtension, imageCallback); + imageCallback.waitForCallback(0, 1, WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + Clipboard.getInstance().setImageUri(imageCallback.getImageUri()); + CriteriaHelper.pollInstrumentationThread(() -> { + Criteria.checkThat(Clipboard.getInstance().getImageUri(), + Matchers.is(imageCallback.getImageUri())); + }); + return imageCallback.getImageUri(); + } + + private Uri generateAnImageToClipboard() throws TimeoutException { + return generateAnImageToClipboard(TEST_JPG_IMAGE_FILE_EXTENSION); + } + + private void clearSharedImages() throws TimeoutException { + ShareImageFileUtils.clearSharedImages(); + + // ShareImageFileUtils::clearSharedImages uses AsyncTask.SERIAL_EXECUTOR to schedule a + // clearing the shared folder job, so schedule a new job and wait for the new job finished + // to make sure ShareImageFileUtils::clearSharedImages's clearing folder job finished. + waitForAsync(); + } + + private void waitForAsync() throws TimeoutException { + AsyncTaskRunnableHelper runnableHelper = new AsyncTaskRunnableHelper(); + AsyncTask.SERIAL_EXECUTOR.execute(runnableHelper); + runnableHelper.waitForCallback(0, 1, WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + AsyncTask.THREAD_POOL_EXECUTOR.execute(runnableHelper); + runnableHelper.waitForCallback(0, 1, WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + + private void deleteAllTestImages() throws TimeoutException { + AsyncTask.SERIAL_EXECUTOR.execute(() -> { + if (BuildInfo.isAtLeastQ()) { + deleteMediaStoreFiles(); + } + deleteExternalStorageFiles(); + }); + waitForAsync(); + } + + @TargetApi(29) + private void deleteMediaStoreFiles() { + ContentResolver contentResolver = ContextUtils.getApplicationContext().getContentResolver(); + Cursor cursor = + contentResolver.query(MediaStore.Downloads.EXTERNAL_CONTENT_URI, null, null, null); + while (cursor.moveToNext()) { + long id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Downloads._ID)); + Uri uri = ContentUris.withAppendedId(MediaStore.Downloads.EXTERNAL_CONTENT_URI, id); + contentResolver.delete(uri, null, null); + } + } + + public void deleteExternalStorageFiles() { + File externalStorageDir = ContextUtils.getApplicationContext().getExternalFilesDir( + Environment.DIRECTORY_DOWNLOADS); + String[] children = externalStorageDir.list(); + for (int i = 0; i < children.length; i++) { + new File(externalStorageDir, children[i]).delete(); + } + } + + private int fileCountInShareDirectory() throws IOException { + return fileCount(ShareImageFileUtils.getSharedFilesDirectory()); + } + + private boolean fileExistsInShareDirectory(Uri fileUri) throws IOException { + return filepathExists(ShareImageFileUtils.getSharedFilesDirectory(), fileUri.getPath()); + } + + private Bitmap getTestBitmap() { + int size = 10; + Bitmap bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888); + + Canvas canvas = new Canvas(bitmap); + Paint paint = new Paint(); + paint.setColor(android.graphics.Color.GREEN); + canvas.drawRect(0F, 0F, (float) size, (float) size, paint); + return bitmap; + } + + @Test + @SmallTest + public void clipboardUriDoNotClearTest() throws TimeoutException, IOException { + Uri clipboardUri = generateAnImageToClipboard(TEST_GIF_IMAGE_FILE_EXTENSION); + Assert.assertTrue(clipboardUri.getPath().endsWith(TEST_GIF_IMAGE_FILE_EXTENSION)); + clipboardUri = generateAnImageToClipboard(TEST_JPG_IMAGE_FILE_EXTENSION); + Assert.assertTrue(clipboardUri.getPath().endsWith(TEST_JPG_IMAGE_FILE_EXTENSION)); + clipboardUri = generateAnImageToClipboard(TEST_PNG_IMAGE_FILE_EXTENSION); + Assert.assertTrue(clipboardUri.getPath().endsWith(TEST_PNG_IMAGE_FILE_EXTENSION)); + Assert.assertEquals(3, fileCountInShareDirectory()); + + clearSharedImages(); + Assert.assertEquals(1, fileCountInShareDirectory()); + Assert.assertTrue(fileExistsInShareDirectory(clipboardUri)); + } + + @Test + @SmallTest + public void clearEverythingIfNoClipboardImageTest() throws TimeoutException, IOException { + generateAnImageToClipboard(); + generateAnImageToClipboard(); + generateAnImageToClipboard(); + Assert.assertEquals(3, fileCountInShareDirectory()); + + Clipboard.getInstance().setText(""); + clearSharedImages(); + Assert.assertEquals(0, fileCountInShareDirectory()); + } + + @Test + @SmallTest + @DisabledTest(message = "crbug.com/1056059") + public void testSaveBitmap() throws IOException, TimeoutException { + String fileName = TEST_IMAGE_FILE_NAME + "_save_bitmap"; + ShareImageFileUtils.OnImageSaveListener listener = + new ShareImageFileUtils.OnImageSaveListener() { + @Override + public void onImageSaved(Uri uri, String displayName) { + Assert.assertNotNull(uri); + Assert.assertEquals(fileName, displayName); + AsyncTask.SERIAL_EXECUTOR.execute(() -> { + File file = new File(uri.getPath()); + Assert.assertTrue(file.exists()); + Assert.assertTrue(file.isFile()); + }); + + // Wait for the above checks to complete. + try { + waitForAsync(); + } catch (TimeoutException ex) { + } + } + + @Override + public void onImageSaveError(String displayName) { + Assert.fail(); + } + }; + ShareImageFileUtils.saveBitmapToExternalStorage( + getActivity(), fileName, getTestBitmap(), listener); + waitForAsync(); + } + + @Test + @SmallTest + @DisableIf.Build(sdk_is_less_than = 29) + public void testSaveBitmapAndMediaStore() throws IOException, TimeoutException { + String fileName = TEST_IMAGE_FILE_NAME + "_mediastore"; + ShareImageFileUtils.OnImageSaveListener listener = + new ShareImageFileUtils.OnImageSaveListener() { + @Override + public void onImageSaved(Uri uri, String displayName) { + Assert.assertNotNull(uri); + Assert.assertEquals(fileName, displayName); + AsyncTask.SERIAL_EXECUTOR.execute(() -> { + Cursor cursor = getActivity().getContentResolver().query( + uri, null, null, null, null); + Assert.assertNotNull(cursor); + Assert.assertTrue(cursor.moveToFirst()); + Assert.assertEquals(fileName + TEST_JPG_IMAGE_FILE_EXTENSION, + cursor.getString(cursor.getColumnIndex( + MediaStore.MediaColumns.DISPLAY_NAME))); + }); + + // Wait for the above checks to complete. + try { + waitForAsync(); + } catch (TimeoutException ex) { + } + } + + @Override + public void onImageSaveError(String displayName) { + Assert.fail(); + } + }; + ShareImageFileUtils.saveBitmapToExternalStorage( + getActivity(), fileName, getTestBitmap(), listener); + waitForAsync(); + } + + @Test + @SmallTest + public void testGetNextAvailableFile() throws IOException { + String fileName = TEST_IMAGE_FILE_NAME + "_next_availble"; + File externalStorageDir = + getActivity().getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS); + File imageFile = ShareImageFileUtils.getNextAvailableFile( + externalStorageDir.getPath(), fileName, TEST_JPG_IMAGE_FILE_EXTENSION); + Assert.assertTrue(imageFile.exists()); + + File imageFile2 = ShareImageFileUtils.getNextAvailableFile( + externalStorageDir.getPath(), fileName, TEST_JPG_IMAGE_FILE_EXTENSION); + Assert.assertTrue(imageFile2.exists()); + Assert.assertNotEquals(imageFile.getPath(), imageFile2.getPath()); + } + + @Test + @SmallTest + @DisableIf.Build(sdk_is_greater_than = 28) + public void testAddCompletedDownload() throws IOException { + String filename = + TEST_IMAGE_FILE_NAME + "_add_completed_download" + TEST_JPG_IMAGE_FILE_EXTENSION; + File externalStorageDir = + getActivity().getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS); + File qrcodeFile = new File(externalStorageDir, filename); + Assert.assertTrue(qrcodeFile.createNewFile()); + + long downloadId = ShareImageFileUtils.addCompletedDownload(qrcodeFile); + Assert.assertNotEquals(0L, downloadId); + + DownloadManager downloadManager = + (DownloadManager) getActivity().getSystemService(Context.DOWNLOAD_SERVICE); + DownloadManager.Query query = new DownloadManager.Query().setFilterById(downloadId); + Cursor c = downloadManager.query(query); + + Assert.assertNotNull(c); + Assert.assertTrue(c.moveToFirst()); + Assert.assertEquals( + filename, c.getString(c.getColumnIndexOrThrow(DownloadManager.COLUMN_TITLE))); + c.close(); + } +} diff --git a/chromium/components/browser_ui/share/android/java/src/org/chromium/components/browser_ui/share/ShareParams.java b/chromium/components/browser_ui/share/android/java/src/org/chromium/components/browser_ui/share/ShareParams.java new file mode 100644 index 00000000000..f26655eb4a8 --- /dev/null +++ b/chromium/components/browser_ui/share/android/java/src/org/chromium/components/browser_ui/share/ShareParams.java @@ -0,0 +1,246 @@ +// 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.components.browser_ui.share; + +import android.content.ComponentName; +import android.net.Uri; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.chromium.components.dom_distiller.core.DomDistillerUrlUtils; +import org.chromium.ui.base.WindowAndroid; + +import java.util.ArrayList; + +/** + * A container object for passing share parameters to {@link ShareHelper}. + */ +public class ShareParams { + /** The window that triggered the share action. */ + private final WindowAndroid mWindow; + + /** The title of the page to be shared. */ + private final String mTitle; + + /** + * The text to be shared. If both |text| and |url| are supplied, they are concatenated with a + * space. + */ + private final String mText; + + /** The URL of the page to be shared. */ + private final String mUrl; + + /** The common MIME type of the files to be shared. A wildcard if they have differing types. */ + private final String mFileContentType; + + /** The list of Uris of the files to be shared. */ + private final ArrayList<Uri> mFileUris; + + /** The Uri to the offline MHTML file to be shared. */ + private final Uri mOfflineUri; + + /** The Uri of the screenshot of the page to be shared. */ + private final Uri mScreenshotUri; + + /** + * Optional callback to be called when user makes a choice. Will not be called if receiving a + * response when the user makes a choice is not supported (on older Android versions). + */ + private TargetChosenCallback mCallback; + + private ShareParams(WindowAndroid window, String title, String text, String url, + @Nullable String fileContentType, @Nullable ArrayList<Uri> fileUris, + @Nullable Uri offlineUri, @Nullable Uri screenshotUri, + @Nullable TargetChosenCallback callback) { + mWindow = window; + mTitle = title; + mText = text; + mUrl = url; + mFileContentType = fileContentType; + mFileUris = fileUris; + mOfflineUri = offlineUri; + mScreenshotUri = screenshotUri; + mCallback = callback; + } + + /** + * @return The window that triggered share. + */ + public WindowAndroid getWindow() { + return mWindow; + } + + /** + * @return The title of the page to be shared. + */ + public String getTitle() { + return mTitle; + } + + /** + * @return The text to be shared. + */ + public String getText() { + return mText; + } + + /** + * @return The URL of the page to be shared. + */ + public String getUrl() { + return mUrl; + } + + /** + * @return The MIME type to the arbitrary files to be shared. + */ + @Nullable + public String getFileContentType() { + return mFileContentType; + } + + /** + * @return The Uri to the arbitrary files to be shared. + */ + @Nullable + public ArrayList<Uri> getFileUris() { + return mFileUris; + } + + /** + * @return The Uri to the offline MHTML file to be shared. + */ + @Nullable + public Uri getOfflineUri() { + return mOfflineUri; + } + + /** + * @return The Uri of the screenshot of the page to be shared. + */ + @Nullable + public Uri getScreenshotUri() { + return mScreenshotUri; + } + + /** + * @return The callback to be called when user makes a choice. + */ + @Nullable + public TargetChosenCallback getCallback() { + return mCallback; + } + + /** + * @param callback To be called when user makes a choice. + */ + public void setCallback(@Nullable TargetChosenCallback callback) { + mCallback = callback; + } + + /** The builder for {@link ShareParams} objects. */ + public static class Builder { + private WindowAndroid mWindow; + private String mTitle; + private String mText; + private String mUrl; + private String mFileContentType; + private ArrayList<Uri> mFileUris; + private Uri mOfflineUri; + private Uri mScreenshotUri; + private TargetChosenCallback mCallback; + + public Builder(@NonNull WindowAndroid window, @NonNull String title, @NonNull String url) { + mWindow = window; + mUrl = url; + mTitle = title; + } + + /** + * Sets the text to be shared. + */ + public Builder setText(@NonNull String text) { + mText = text; + return this; + } + + /** + * Sets the MIME type of the arbitrary files to be shared. + */ + public Builder setFileContentType(@NonNull String fileContentType) { + mFileContentType = fileContentType; + return this; + } + + /** + * Sets the Uri of the arbitrary files to be shared. + */ + public Builder setFileUris(@Nullable ArrayList<Uri> fileUris) { + mFileUris = fileUris; + return this; + } + + /** + * Sets the Uri of the offline MHTML file to be shared. + */ + public Builder setOfflineUri(@Nullable Uri offlineUri) { + mOfflineUri = offlineUri; + return this; + } + + /** + * Sets the Uri of the screenshot of the page to be shared. + */ + public Builder setScreenshotUri(@Nullable Uri screenshotUri) { + mScreenshotUri = screenshotUri; + return this; + } + + /** + * Sets the callback to be called when user makes a choice. + */ + public Builder setCallback(@Nullable TargetChosenCallback callback) { + mCallback = callback; + return this; + } + + /** @return A fully constructed {@link ShareParams} object. */ + public ShareParams build() { + if (!TextUtils.isEmpty(mUrl)) { + mUrl = DomDistillerUrlUtils.getOriginalUrlFromDistillerUrl(mUrl); + if (!TextUtils.isEmpty(mText)) { + // Concatenate text and URL with a space. + mText = mText + " " + mUrl; + } else { + mText = mUrl; + } + } + return new ShareParams(mWindow, mTitle, mText, mUrl, mFileContentType, mFileUris, + mOfflineUri, mScreenshotUri, mCallback); + } + } + + /** + * Callback interface for when a target is chosen. + */ + public static interface TargetChosenCallback { + /** + * Called when the user chooses a target in the share dialog. + * + * Note that if the user cancels the share dialog, this callback is never called. + */ + public void onTargetChosen(ComponentName chosenComponent); + + /** + * Called when the user cancels the share dialog. + * + * Guaranteed that either this, or onTargetChosen (but not both) will be called, eventually. + */ + public void onCancel(); + } +} |