// 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.chrome.browser.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.SharedPreferences; import android.content.pm.ActivityInfo; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.ResolveInfo; import android.graphics.Bitmap; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.AsyncTask; import android.os.Build; import android.support.annotation.Nullable; import android.support.v7.app.AlertDialog; import android.text.TextUtils; import android.util.Pair; import android.view.MenuItem; import android.view.View; import android.widget.AdapterView; import android.widget.AdapterView.OnItemClickListener; import org.chromium.base.ApiCompatibilityUtils; import org.chromium.base.ApplicationState; import org.chromium.base.ApplicationStatus; import org.chromium.base.Callback; import org.chromium.base.ContextUtils; import org.chromium.base.Log; import org.chromium.base.VisibleForTesting; import org.chromium.base.annotations.SuppressFBWarnings; import org.chromium.base.metrics.RecordHistogram; import org.chromium.chrome.R; import org.chromium.components.dom_distiller.core.DomDistillerUrlUtils; import org.chromium.ui.UiUtils; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.util.Collections; import java.util.List; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; /** * 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); } private static final String TAG = "share"; /** 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 JPEG_EXTENSION = ".jpg"; private static final String PACKAGE_NAME_KEY = "last_shared_package_name"; private static final String CLASS_NAME_KEY = "last_shared_class_name"; private static final String EXTRA_SHARE_SCREENSHOT_AS_STREAM = "share_screenshot_as_stream"; private static final long COMPONENT_INFO_READ_TIMEOUT_IN_MS = 1000; /** * Directory name for shared images. * * Named "screenshot" for historical reasons as we only initially shared screenshot images. */ private static final String SHARE_IMAGES_DIRECTORY_NAME = "screenshot"; /** Force the use of a Chrome-specific intent chooser, not the system chooser. */ private static boolean sForceCustomChooserForTesting = false; /** If non-null, will be used instead of the real activity. */ private static FakeIntentReceiver sFakeIntentReceiverForTesting; private ShareHelper() {} private static void fireIntent(Activity activity, Intent intent) { if (sFakeIntentReceiverForTesting != null) { Context context = activity.getApplicationContext(); sFakeIntentReceiverForTesting.fireIntent(context, intent); } else { activity.startActivity(intent); } } @SuppressFBWarnings("NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE") private static void deleteShareImageFiles(File file) { if (!file.exists()) return; if (file.isDirectory()) { for (File f : file.listFiles()) deleteShareImageFiles(f); } if (!file.delete()) { Log.w(TAG, "Failed to delete share image file: %s", file.getAbsolutePath()); } } /** * 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; } /** * 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(); } /** * Receiver to record the chosen component when sharing an Intent. */ static class TargetChosenReceiver extends BroadcastReceiver { private static final String EXTRA_RECEIVER_TOKEN = "receiver_token"; private static final Object LOCK = new Object(); private static String sTargetChosenReceiveAction; private static TargetChosenReceiver sLastRegisteredReceiver; private final boolean mSaveLastUsed; @Nullable private final TargetChosenCallback mCallback; private TargetChosenReceiver(boolean saveLastUsed, @Nullable TargetChosenCallback callback) { mSaveLastUsed = saveLastUsed; mCallback = callback; } static boolean isSupported() { return !sForceCustomChooserForTesting && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1; } @TargetApi(Build.VERSION_CODES.LOLLIPOP_MR1) static void sendChooserIntent(boolean saveLastUsed, Activity activity, Intent sharingIntent, @Nullable TargetChosenCallback callback) { synchronized (LOCK) { if (sTargetChosenReceiveAction == null) { sTargetChosenReceiveAction = activity.getPackageName() + "/" + TargetChosenReceiver.class.getName() + "_ACTION"; } Context context = activity.getApplicationContext(); if (sLastRegisteredReceiver != null) { context.unregisterReceiver(sLastRegisteredReceiver); // Must cancel the callback (to satisfy guarantee that exactly one method of // TargetChosenCallback is called). // TODO(mgiuca): This should be called immediately upon cancelling the chooser, // not just when the next share takes place (https://crbug.com/636274). sLastRegisteredReceiver.cancel(); } sLastRegisteredReceiver = new TargetChosenReceiver(saveLastUsed, callback); context.registerReceiver( sLastRegisteredReceiver, new IntentFilter(sTargetChosenReceiveAction)); } Intent intent = new Intent(sTargetChosenReceiveAction); intent.setPackage(activity.getPackageName()); intent.putExtra(EXTRA_RECEIVER_TOKEN, sLastRegisteredReceiver.hashCode()); final PendingIntent pendingIntent = PendingIntent.getBroadcast(activity, 0, intent, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_ONE_SHOT); Intent chooserIntent = Intent.createChooser(sharingIntent, activity.getString(R.string.share_link_chooser_title), pendingIntent.getIntentSender()); if (sFakeIntentReceiverForTesting != null) { sFakeIntentReceiverForTesting.setIntentToSendBack(intent); } fireIntent(activity, chooserIntent); } @Override public void onReceive(Context context, Intent intent) { synchronized (LOCK) { if (sLastRegisteredReceiver != this) return; context.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); } if (mSaveLastUsed && target != null) { setLastShareComponentName(target); } } private void cancel() { if (mCallback != null) { mCallback.onCancel(); } } } /** * Clears all shared image files. */ public static void clearSharedImages() { new AsyncTask<Void, Void, Void>() { @Override protected Void doInBackground(Void... params) { try { File imagePath = UiUtils.getDirectoryForImageCapture( ContextUtils.getApplicationContext()); deleteShareImageFiles(new File(imagePath, SHARE_IMAGES_DIRECTORY_NAME)); } catch (IOException ie) { // Ignore exception. } return null; } }.execute(); } /** * Creates and shows a share intent picker dialog or starts a share intent directly with the * activity that was most recently used to share based on shareDirectly value. * * This function will save |screenshot| under {app's root}/files/images/screenshot (or * /sdcard/DCIM/browser-images/screenshot if ADK is lower than JB MR2). * Cleaning up doesn't happen automatically, and so an app should call clearSharedScreenshots() * explicitly when needed. * * @param shareDirectly Whether it should share directly with the activity that was most * recently used to share. * @param saveLastUsed Whether to save the chosen activity for future direct sharing. * @param activity Activity that is used to access package manager. * @param title Title of the page to be shared. * @param text Text to be shared. If both |text| and |url| are supplied, they are concatenated * with a space. * @param url URL of the page to be shared. * @param offlineUri URI to the offline MHTML file to be shared. * @param screenshotUri Uri of the screenshot of the page to be shared. * @param callback 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). */ public static void share(boolean shareDirectly, boolean saveLastUsed, Activity activity, String title, String text, String url, @Nullable Uri offlineUri, Uri screenshotUri, @Nullable TargetChosenCallback callback) { if (shareDirectly) { shareWithLastUsed(activity, title, text, url, offlineUri, screenshotUri); } else if (TargetChosenReceiver.isSupported()) { makeIntentAndShare(saveLastUsed, activity, title, text, url, offlineUri, screenshotUri, null, callback); } else { showShareDialog( saveLastUsed, activity, title, text, url, offlineUri, screenshotUri, callback); } } /** * Trigger the share action for the given image data. * @param activity The activity used to trigger the share action. * @param jpegImageData The image data to be shared in jpeg format. */ public static void shareImage(final Activity activity, final byte[] jpegImageData) { if (jpegImageData.length == 0) { Log.w(TAG, "Share failed -- Received image contains no data."); return; } new AsyncTask<Void, Void, File>() { @Override protected File doInBackground(Void... params) { FileOutputStream fOut = null; try { File path = new File(UiUtils.getDirectoryForImageCapture(activity), SHARE_IMAGES_DIRECTORY_NAME); if (path.exists() || path.mkdir()) { File saveFile = File.createTempFile( String.valueOf(System.currentTimeMillis()), JPEG_EXTENSION, path); fOut = new FileOutputStream(saveFile); fOut.write(jpegImageData); fOut.flush(); fOut.close(); return saveFile; } else { Log.w(TAG, "Share failed -- Unable to create share image directory."); } } catch (IOException ie) { if (fOut != null) { try { fOut.close(); } catch (IOException e) { // Ignore exception. } } } return null; } @Override protected void onPostExecute(File saveFile) { if (saveFile == null) return; if (ApplicationStatus.getStateForApplication() != ApplicationState.HAS_DESTROYED_ACTIVITIES) { Uri imageUri = UiUtils.getUriForImageCaptureFile(activity, saveFile); Intent chooserIntent = Intent.createChooser(getShareImageIntent(imageUri), activity.getString(R.string.share_link_chooser_title)); fireIntent(activity, chooserIntent); } } }.execute(); } /** * Persists the screenshot file and notifies the file provider that the file is ready to be * accessed by the client. * * The bitmap is compressed to JPEG before being written to the file. * * @param screenshot The screenshot bitmap to be written to file. * @param callback The callback that will be called once the bitmap is saved. */ public static void saveScreenshotToDisk(final Bitmap screenshot, final Context context, final Callback<Uri> callback) { if (screenshot == null) { callback.onResult(null); return; } new AsyncTask<Void, Void, File>() { @Override protected File doInBackground(Void... params) { FileOutputStream fOut = null; try { File path = new File(UiUtils.getDirectoryForImageCapture(context) + "/" + SHARE_IMAGES_DIRECTORY_NAME); if (path.exists() || path.mkdir()) { String fileName = String.valueOf(System.currentTimeMillis()); File saveFile = File.createTempFile(fileName, JPEG_EXTENSION, path); fOut = new FileOutputStream(saveFile); screenshot.compress(Bitmap.CompressFormat.JPEG, 85, fOut); fOut.flush(); fOut.close(); return saveFile; } } catch (IOException ie) { if (fOut != null) { try { fOut.close(); } catch (IOException e) { // Ignore exception. } } } return null; } @Override protected void onPostExecute(File savedFile) { Uri fileUri = null; if (ApplicationStatus.getStateForApplication() != ApplicationState.HAS_DESTROYED_ACTIVITIES && savedFile != null) { fileUri = UiUtils.getUriForImageCaptureFile(context, savedFile); } callback.onResult(fileUri); } }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); } /** * Creates and shows a share intent picker dialog. * * @param saveLastUsed Whether to save the chosen activity for future direct sharing. * @param activity Activity that is used to access package manager. * @param title Title of the page to be shared. * @param text Text to be shared. If both |text| and |url| are supplied, they are concatenated * with a space. * @param url URL of the page to be shared. * @oaram offlineUri URI of the offline page to be shared. * @param screenshotUri Uri of the screenshot of the page to be shared. * @param callback 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 static void showShareDialog(final boolean saveLastUsed, final Activity activity, final String title, final String text, final String url, final Uri offlineUri, final Uri screenshotUri, @Nullable final TargetChosenCallback callback) { Intent intent = getShareIntent(activity, title, text, url, null, null); PackageManager manager = activity.getPackageManager(); List<ResolveInfo> resolveInfoList = manager.queryIntentActivities(intent, 0); assert resolveInfoList.size() > 0; if (resolveInfoList.size() == 0) return; Collections.sort(resolveInfoList, new ResolveInfo.DisplayNameComparator(manager)); final ShareDialogAdapter adapter = new ShareDialogAdapter(activity, manager, resolveInfoList); AlertDialog.Builder builder = new AlertDialog.Builder(activity, R.style.AlertDialogTheme); builder.setTitle(activity.getString(R.string.share_link_chooser_title)); builder.setAdapter(adapter, null); // 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; } if (saveLastUsed) setLastShareComponentName(component); makeIntentAndShare(false, activity, title, text, url, offlineUri, screenshotUri, component, null); dialog.dismiss(); } }); if (callback != null) { dialog.setOnDismissListener(new OnDismissListener() { @Override public void onDismiss(DialogInterface dialog) { if (!callbackCalled[0]) { callback.onCancel(); callbackCalled[0] = true; } } }); } if (sFakeIntentReceiverForTesting != null) { sFakeIntentReceiverForTesting.onCustomChooserShown(dialog); } } /** * Starts a share intent with the activity that was most recently used to share. * If there is no most recently used activity, it does nothing. * @param activity Activity that is used to start the share intent. * @param title Title of the page to be shared. * @param text Text to be shared. If both |text| and |url| are supplied, they are concatenated * with a space. * @param url URL of the page to be shared. * @oaram offlineUri URI of the offline page to be shared. * @param screenshotUri Uri of the screenshot of the page to be shared. */ private static void shareWithLastUsed(Activity activity, String title, String text, String url, Uri offlineUri, Uri screenshotUri) { ComponentName component = getLastShareComponentName(); if (component == null) return; makeIntentAndShare( false, activity, title, text, url, offlineUri, screenshotUri, component, null); } private static void shareIntent(boolean saveLastUsed, Activity activity, Intent sharingIntent, @Nullable TargetChosenCallback callback) { if (sharingIntent.getComponent() != null) { // If a component was specified, there should not also be a callback. assert callback == null; fireIntent(activity, sharingIntent); } else { assert TargetChosenReceiver.isSupported(); TargetChosenReceiver.sendChooserIntent(saveLastUsed, activity, sharingIntent, callback); } } private static void makeIntentAndShare(final boolean saveLastUsed, final Activity activity, final String title, final String text, final String url, final Uri offlineUri, final Uri screenshotUri, final ComponentName component, @Nullable final TargetChosenCallback callback) { Intent intent = getDirectShareIntentForComponent( activity, title, text, url, offlineUri, screenshotUri, component); shareIntent(saveLastUsed, activity, intent, callback); } /** * Set the icon and the title for the menu item used for direct share. * * @param activity Activity that is used to access the package manager. * @param item The menu item that is used for direct share */ public static void configureDirectShareMenuItem(Activity activity, MenuItem item) { Drawable directShareIcon = null; CharSequence directShareTitle = null; final ComponentName component = getLastShareComponentName(); boolean isComponentValid = false; if (component != null) { Intent intent = getShareIntent(activity, "", "", "", null, null); intent.setPackage(component.getPackageName()); PackageManager manager = activity.getPackageManager(); List<ResolveInfo> resolveInfoList = manager.queryIntentActivities(intent, 0); for (ResolveInfo info : resolveInfoList) { ActivityInfo ai = info.activityInfo; if (component.equals(new ComponentName(ai.applicationInfo.packageName, ai.name))) { isComponentValid = true; break; } } } if (isComponentValid) { boolean retrieved = false; try { final PackageManager pm = activity.getPackageManager(); AsyncTask<Void, Void, Pair<Drawable, CharSequence>> task = new AsyncTask<Void, Void, Pair<Drawable, CharSequence>>() { @Override protected Pair<Drawable, CharSequence> doInBackground(Void... params) { Drawable directShareIcon = null; CharSequence directShareTitle = null; try { directShareIcon = pm.getActivityIcon(component); ApplicationInfo ai = pm.getApplicationInfo(component.getPackageName(), 0); directShareTitle = pm.getApplicationLabel(ai); } catch (NameNotFoundException exception) { // Use the default null values. } return new Pair<Drawable, CharSequence>( directShareIcon, directShareTitle); } }; task.execute(); Pair<Drawable, CharSequence> result = task.get(COMPONENT_INFO_READ_TIMEOUT_IN_MS, TimeUnit.MILLISECONDS); directShareIcon = result.first; directShareTitle = result.second; retrieved = true; } catch (InterruptedException ie) { // Use the default null values. } catch (ExecutionException ee) { // Use the default null values. } catch (TimeoutException te) { // Use the default null values. } RecordHistogram.recordBooleanHistogram( "Android.IsLastSharedAppInfoRetrieved", retrieved); } item.setIcon(directShareIcon); if (directShareTitle != null) { item.setTitle(activity.getString(R.string.accessibility_menu_share_via, directShareTitle)); } } /* * Stores the component selected for sharing last time share was called. * * This method is public since it is used in tests to avoid creating share dialog. */ @VisibleForTesting public static void setLastShareComponentName(ComponentName component) { SharedPreferences preferences = ContextUtils.getAppSharedPreferences(); SharedPreferences.Editor editor = preferences.edit(); editor.putString(PACKAGE_NAME_KEY, component.getPackageName()); editor.putString(CLASS_NAME_KEY, component.getClassName()); editor.apply(); } @VisibleForTesting public static Intent getShareIntent(Activity activity, String title, String text, String url, Uri offlineUri, Uri screenshotUri) { if (!TextUtils.isEmpty(url)) { url = DomDistillerUrlUtils.getOriginalUrlFromDistillerUrl(url); if (!TextUtils.isEmpty(text)) { // Concatenate text and URL with a space. text = text + " " + url; } else { text = url; } } Intent intent = new Intent(Intent.ACTION_SEND); intent.addFlags(ApiCompatibilityUtils.getActivityNewDocumentFlag()); intent.putExtra(Intent.EXTRA_SUBJECT, title); intent.putExtra(Intent.EXTRA_TEXT, text); intent.putExtra(EXTRA_TASK_ID, activity.getTaskId()); if (screenshotUri != null) { intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); } if (screenshotUri != null) { // 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 (offlineUri == null) { intent.setType("text/plain"); } else { intent.setType("multipart/related"); intent.putExtra(Intent.EXTRA_STREAM, offlineUri); } return intent; } private static Intent getShareImageIntent(Uri imageUri) { Intent intent = new Intent(Intent.ACTION_SEND); intent.addFlags(ApiCompatibilityUtils.getActivityNewDocumentFlag()); intent.setType("image/jpeg"); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); intent.putExtra(Intent.EXTRA_STREAM, imageUri); return intent; } private static Intent getDirectShareIntentForComponent(Activity activity, String title, String text, String url, Uri offlineUri, Uri screenshotUri, ComponentName component) { Intent intent = getShareIntent(activity, title, text, url, offlineUri, screenshotUri); intent.addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT | Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP); intent.setComponent(component); return intent; } private static ComponentName getLastShareComponentName() { SharedPreferences preferences = ContextUtils.getAppSharedPreferences(); String packageName = preferences.getString(PACKAGE_NAME_KEY, null); String className = preferences.getString(CLASS_NAME_KEY, null); if (packageName == null || className == null) return null; return new ComponentName(packageName, className); } }