1 /* 2 * Copyright (C) 2022 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.intentresolver.contentpreview; 18 19 import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_FILE; 20 import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE; 21 import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_TEXT; 22 23 import android.content.ClipData; 24 import android.content.ClipDescription; 25 import android.content.ContentInterface; 26 import android.content.Intent; 27 import android.content.res.Resources; 28 import android.net.Uri; 29 import android.os.RemoteException; 30 import android.view.LayoutInflater; 31 import android.view.ViewGroup; 32 33 import androidx.annotation.Nullable; 34 35 import com.android.intentresolver.ImageLoader; 36 import com.android.intentresolver.flags.FeatureFlagRepository; 37 import com.android.intentresolver.widget.ActionRow; 38 import com.android.intentresolver.widget.ImagePreviewView; 39 import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; 40 41 import java.util.ArrayList; 42 import java.util.List; 43 import java.util.function.Consumer; 44 import java.util.stream.Collectors; 45 46 /** 47 * Collection of helpers for building the content preview UI displayed in 48 * {@link com.android.intentresolver.ChooserActivity}. 49 * 50 * A content preview façade. 51 */ 52 public final class ChooserContentPreviewUi { 53 /** 54 * Delegate to build the default system action buttons to display in the preview layout, if/when 55 * they're determined to be appropriate for the particular preview we display. 56 * TODO: clarify why action buttons are part of preview logic. 57 */ 58 public interface ActionFactory { 59 /** Create an action that copies the share content to the clipboard. */ createCopyButton()60 ActionRow.Action createCopyButton(); 61 62 /** Create an action that opens the share content in a system-default editor. */ 63 @Nullable createEditButton()64 ActionRow.Action createEditButton(); 65 66 /** Create an "Share to Nearby" action. */ 67 @Nullable createNearbyButton()68 ActionRow.Action createNearbyButton(); 69 70 /** Create custom actions */ createCustomActions()71 List<ActionRow.Action> createCustomActions(); 72 73 /** 74 * Provides a share modification action, if any. 75 */ 76 @Nullable getModifyShareAction()77 Runnable getModifyShareAction(); 78 79 /** 80 * <p> 81 * Creates an exclude-text action that can be called when the user changes shared text 82 * status in the Media + Text preview. 83 * </p> 84 * <p> 85 * <code>true</code> argument value indicates that the text should be excluded. 86 * </p> 87 */ getExcludeSharedTextAction()88 Consumer<Boolean> getExcludeSharedTextAction(); 89 } 90 91 /** 92 * Testing shim to specify whether a given mime type is considered to be an "image." 93 * 94 * TODO: move away from {@link ChooserActivityOverrideData} as a model to configure our tests, 95 * then migrate {@link com.android.intentresolver.ChooserActivity#isImageType(String)} into this 96 * class. 97 */ 98 public interface ImageMimeTypeClassifier { 99 /** @return whether the specified {@code mimeType} is classified as an "image" type. */ isImageType(String mimeType)100 boolean isImageType(String mimeType); 101 } 102 103 private final ContentPreviewUi mContentPreviewUi; 104 ChooserContentPreviewUi( Intent targetIntent, ContentInterface contentResolver, ImageMimeTypeClassifier imageClassifier, ImageLoader imageLoader, ActionFactory actionFactory, TransitionElementStatusCallback transitionElementStatusCallback, FeatureFlagRepository featureFlagRepository)105 public ChooserContentPreviewUi( 106 Intent targetIntent, 107 ContentInterface contentResolver, 108 ImageMimeTypeClassifier imageClassifier, 109 ImageLoader imageLoader, 110 ActionFactory actionFactory, 111 TransitionElementStatusCallback transitionElementStatusCallback, 112 FeatureFlagRepository featureFlagRepository) { 113 114 mContentPreviewUi = createContentPreview( 115 targetIntent, 116 contentResolver, 117 imageClassifier, 118 imageLoader, 119 actionFactory, 120 transitionElementStatusCallback, 121 featureFlagRepository); 122 if (mContentPreviewUi.getType() != CONTENT_PREVIEW_IMAGE) { 123 transitionElementStatusCallback.onAllTransitionElementsReady(); 124 } 125 } 126 createContentPreview( Intent targetIntent, ContentInterface contentResolver, ImageMimeTypeClassifier imageClassifier, ImageLoader imageLoader, ActionFactory actionFactory, TransitionElementStatusCallback transitionElementStatusCallback, FeatureFlagRepository featureFlagRepository)127 private ContentPreviewUi createContentPreview( 128 Intent targetIntent, 129 ContentInterface contentResolver, 130 ImageMimeTypeClassifier imageClassifier, 131 ImageLoader imageLoader, 132 ActionFactory actionFactory, 133 TransitionElementStatusCallback transitionElementStatusCallback, 134 FeatureFlagRepository featureFlagRepository) { 135 int type = findPreferredContentPreview(targetIntent, contentResolver, imageClassifier); 136 switch (type) { 137 case CONTENT_PREVIEW_TEXT: 138 return createTextPreview( 139 targetIntent, actionFactory, imageLoader, featureFlagRepository); 140 141 case CONTENT_PREVIEW_FILE: 142 return new FileContentPreviewUi( 143 extractContentUris(targetIntent), 144 actionFactory, 145 imageLoader, 146 contentResolver, 147 featureFlagRepository); 148 149 case CONTENT_PREVIEW_IMAGE: 150 return createImagePreview( 151 targetIntent, 152 actionFactory, 153 contentResolver, 154 imageClassifier, 155 imageLoader, 156 transitionElementStatusCallback, 157 featureFlagRepository); 158 } 159 160 return new NoContextPreviewUi(type); 161 } 162 getPreferredContentPreview()163 public int getPreferredContentPreview() { 164 return mContentPreviewUi.getType(); 165 } 166 167 /** 168 * Display a content preview of the specified {@code previewType} to preview the content of the 169 * specified {@code intent}. 170 */ displayContentPreview( Resources resources, LayoutInflater layoutInflater, ViewGroup parent)171 public ViewGroup displayContentPreview( 172 Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { 173 174 return mContentPreviewUi.display(resources, layoutInflater, parent); 175 } 176 177 /** Determine the most appropriate type of preview to show for the provided {@link Intent}. */ 178 @ContentPreviewType findPreferredContentPreview( Intent targetIntent, ContentInterface resolver, ImageMimeTypeClassifier imageClassifier)179 private static int findPreferredContentPreview( 180 Intent targetIntent, 181 ContentInterface resolver, 182 ImageMimeTypeClassifier imageClassifier) { 183 /* In {@link android.content.Intent#getType}, the app may specify a very general mime type 184 * that broadly covers all data being shared, such as {@literal *}/* when sending an image 185 * and text. We therefore should inspect each item for the preferred type, in order: IMAGE, 186 * FILE, TEXT. */ 187 final String action = targetIntent.getAction(); 188 final String type = targetIntent.getType(); 189 final boolean isSend = Intent.ACTION_SEND.equals(action); 190 final boolean isSendMultiple = Intent.ACTION_SEND_MULTIPLE.equals(action); 191 192 if (!(isSend || isSendMultiple) 193 || (type != null && ClipDescription.compareMimeTypes(type, "text/*"))) { 194 return CONTENT_PREVIEW_TEXT; 195 } 196 197 if (isSend) { 198 Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); 199 return findPreferredContentPreview(uri, resolver, imageClassifier); 200 } 201 202 List<Uri> uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); 203 if (uris == null || uris.isEmpty()) { 204 return CONTENT_PREVIEW_TEXT; 205 } 206 207 for (Uri uri : uris) { 208 // Defaulting to file preview when there are mixed image/file types is 209 // preferable, as it shows the user the correct number of items being shared 210 int uriPreviewType = findPreferredContentPreview(uri, resolver, imageClassifier); 211 if (uriPreviewType == CONTENT_PREVIEW_FILE) { 212 return CONTENT_PREVIEW_FILE; 213 } 214 } 215 216 return CONTENT_PREVIEW_IMAGE; 217 } 218 219 @ContentPreviewType findPreferredContentPreview( Uri uri, ContentInterface resolver, ImageMimeTypeClassifier imageClassifier)220 private static int findPreferredContentPreview( 221 Uri uri, ContentInterface resolver, ImageMimeTypeClassifier imageClassifier) { 222 if (uri == null) { 223 return CONTENT_PREVIEW_TEXT; 224 } 225 226 String mimeType = null; 227 try { 228 mimeType = resolver.getType(uri); 229 } catch (RemoteException ignored) { 230 } 231 return imageClassifier.isImageType(mimeType) ? CONTENT_PREVIEW_IMAGE : CONTENT_PREVIEW_FILE; 232 } 233 createTextPreview( Intent targetIntent, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, FeatureFlagRepository featureFlagRepository)234 private static TextContentPreviewUi createTextPreview( 235 Intent targetIntent, 236 ChooserContentPreviewUi.ActionFactory actionFactory, 237 ImageLoader imageLoader, 238 FeatureFlagRepository featureFlagRepository) { 239 CharSequence sharingText = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); 240 String previewTitle = targetIntent.getStringExtra(Intent.EXTRA_TITLE); 241 ClipData previewData = targetIntent.getClipData(); 242 Uri previewThumbnail = null; 243 if (previewData != null) { 244 if (previewData.getItemCount() > 0) { 245 ClipData.Item previewDataItem = previewData.getItemAt(0); 246 previewThumbnail = previewDataItem.getUri(); 247 } 248 } 249 return new TextContentPreviewUi( 250 sharingText, 251 previewTitle, 252 previewThumbnail, 253 actionFactory, 254 imageLoader, 255 featureFlagRepository); 256 } 257 createImagePreview( Intent targetIntent, ChooserContentPreviewUi.ActionFactory actionFactory, ContentInterface contentResolver, ChooserContentPreviewUi.ImageMimeTypeClassifier imageClassifier, ImageLoader imageLoader, ImagePreviewView.TransitionElementStatusCallback transitionElementStatusCallback, FeatureFlagRepository featureFlagRepository)258 static ImageContentPreviewUi createImagePreview( 259 Intent targetIntent, 260 ChooserContentPreviewUi.ActionFactory actionFactory, 261 ContentInterface contentResolver, 262 ChooserContentPreviewUi.ImageMimeTypeClassifier imageClassifier, 263 ImageLoader imageLoader, 264 ImagePreviewView.TransitionElementStatusCallback transitionElementStatusCallback, 265 FeatureFlagRepository featureFlagRepository) { 266 CharSequence text = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); 267 String action = targetIntent.getAction(); 268 // TODO: why don't we use image classifier for single-element ACTION_SEND? 269 final List<Uri> imageUris = Intent.ACTION_SEND.equals(action) 270 ? extractContentUris(targetIntent) 271 : extractContentUris(targetIntent) 272 .stream() 273 .filter(uri -> { 274 String type = null; 275 try { 276 type = contentResolver.getType(uri); 277 } catch (RemoteException ignored) { 278 } 279 return imageClassifier.isImageType(type); 280 }) 281 .collect(Collectors.toList()); 282 return new ImageContentPreviewUi( 283 imageUris, 284 text, 285 actionFactory, 286 imageLoader, 287 transitionElementStatusCallback, 288 featureFlagRepository); 289 } 290 extractContentUris(Intent targetIntent)291 private static List<Uri> extractContentUris(Intent targetIntent) { 292 List<Uri> uris = new ArrayList<>(); 293 if (Intent.ACTION_SEND.equals(targetIntent.getAction())) { 294 Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); 295 if (ContentPreviewUi.validForContentPreview(uri)) { 296 uris.add(uri); 297 } 298 } else { 299 List<Uri> receivedUris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); 300 if (receivedUris != null) { 301 for (Uri uri : receivedUris) { 302 if (ContentPreviewUi.validForContentPreview(uri)) { 303 uris.add(uri); 304 } 305 } 306 } 307 } 308 return uris; 309 } 310 } 311