1 /* 2 * Copyright (C) 2023 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.systemui.screenshot.appclips; 18 19 import static com.android.systemui.screenshot.appclips.AppClipsEvent.SCREENSHOT_FOR_NOTE_ACCEPTED; 20 import static com.android.systemui.screenshot.appclips.AppClipsEvent.SCREENSHOT_FOR_NOTE_CANCELLED; 21 import static com.android.systemui.screenshot.appclips.AppClipsTrampolineActivity.ACTION_FINISH_FROM_TRAMPOLINE; 22 import static com.android.systemui.screenshot.appclips.AppClipsTrampolineActivity.EXTRA_CALLING_PACKAGE_NAME; 23 import static com.android.systemui.screenshot.appclips.AppClipsTrampolineActivity.EXTRA_RESULT_RECEIVER; 24 import static com.android.systemui.screenshot.appclips.AppClipsTrampolineActivity.EXTRA_SCREENSHOT_URI; 25 import static com.android.systemui.screenshot.appclips.AppClipsTrampolineActivity.PERMISSION_SELF; 26 27 import android.app.Activity; 28 import android.content.BroadcastReceiver; 29 import android.content.Context; 30 import android.content.Intent; 31 import android.content.IntentFilter; 32 import android.content.pm.PackageManager; 33 import android.content.pm.PackageManager.ApplicationInfoFlags; 34 import android.content.pm.PackageManager.NameNotFoundException; 35 import android.graphics.Bitmap; 36 import android.graphics.Rect; 37 import android.graphics.drawable.BitmapDrawable; 38 import android.graphics.drawable.Drawable; 39 import android.net.Uri; 40 import android.os.Bundle; 41 import android.os.ResultReceiver; 42 import android.util.Log; 43 import android.view.View; 44 import android.widget.Button; 45 import android.widget.ImageView; 46 47 import androidx.activity.ComponentActivity; 48 import androidx.annotation.Nullable; 49 import androidx.lifecycle.ViewModelProvider; 50 51 import com.android.internal.logging.UiEventLogger; 52 import com.android.internal.logging.UiEventLogger.UiEventEnum; 53 import com.android.settingslib.Utils; 54 import com.android.systemui.res.R; 55 import com.android.systemui.screenshot.scroll.CropView; 56 import com.android.systemui.settings.UserTracker; 57 58 import javax.inject.Inject; 59 60 /** 61 * An {@link Activity} to take a screenshot for the App Clips flow and presenting a screenshot 62 * editing tool. 63 * 64 * <p>An App Clips flow includes: 65 * <ul> 66 * <li>Checking if calling activity meets the prerequisites. This is done by 67 * {@link AppClipsTrampolineActivity}. 68 * <li>Performing the screenshot. 69 * <li>Showing a screenshot editing tool. 70 * <li>Returning the screenshot to the {@link AppClipsTrampolineActivity} so that it can return 71 * the screenshot to the calling activity after explicit user consent. 72 * </ul> 73 * 74 * <p>This {@link Activity} runs in its own separate process to isolate memory intensive image 75 * editing from SysUI process. 76 * 77 * TODO(b/267309532): Polish UI and animations. 78 */ 79 public class AppClipsActivity extends ComponentActivity { 80 81 private static final String TAG = AppClipsActivity.class.getSimpleName(); 82 private static final ApplicationInfoFlags APPLICATION_INFO_FLAGS = ApplicationInfoFlags.of(0); 83 84 private final AppClipsViewModel.Factory mViewModelFactory; 85 private final PackageManager mPackageManager; 86 private final UserTracker mUserTracker; 87 private final UiEventLogger mUiEventLogger; 88 private final BroadcastReceiver mBroadcastReceiver; 89 private final IntentFilter mIntentFilter; 90 91 private View mLayout; 92 private View mRoot; 93 private ImageView mPreview; 94 private CropView mCropView; 95 private Button mSave; 96 private Button mCancel; 97 private AppClipsViewModel mViewModel; 98 99 private ResultReceiver mResultReceiver; 100 @Nullable 101 private String mCallingPackageName; 102 private int mCallingPackageUid; 103 104 @Inject AppClipsActivity(AppClipsViewModel.Factory viewModelFactory, PackageManager packageManager, UserTracker userTracker, UiEventLogger uiEventLogger)105 public AppClipsActivity(AppClipsViewModel.Factory viewModelFactory, 106 PackageManager packageManager, UserTracker userTracker, UiEventLogger uiEventLogger) { 107 mViewModelFactory = viewModelFactory; 108 mPackageManager = packageManager; 109 mUserTracker = userTracker; 110 mUiEventLogger = uiEventLogger; 111 112 mBroadcastReceiver = new BroadcastReceiver() { 113 @Override 114 public void onReceive(Context context, Intent intent) { 115 // Trampoline activity was dismissed so finish this activity. 116 if (ACTION_FINISH_FROM_TRAMPOLINE.equals(intent.getAction())) { 117 if (!isFinishing()) { 118 // Nullify the ResultReceiver so that result cannot be sent as trampoline 119 // activity is already finishing. 120 mResultReceiver = null; 121 finish(); 122 } 123 } 124 } 125 }; 126 127 mIntentFilter = new IntentFilter(ACTION_FINISH_FROM_TRAMPOLINE); 128 } 129 130 @Override onCreate(Bundle savedInstanceState)131 public void onCreate(Bundle savedInstanceState) { 132 overridePendingTransition(0, 0); 133 super.onCreate(savedInstanceState); 134 135 // Register the broadcast receiver that informs when the trampoline activity is dismissed. 136 registerReceiver(mBroadcastReceiver, mIntentFilter, PERMISSION_SELF, null, 137 RECEIVER_NOT_EXPORTED); 138 139 Intent intent = getIntent(); 140 setUpUiLogging(intent); 141 mResultReceiver = intent.getParcelableExtra(EXTRA_RESULT_RECEIVER, ResultReceiver.class); 142 if (mResultReceiver == null) { 143 setErrorThenFinish(Intent.CAPTURE_CONTENT_FOR_NOTE_FAILED); 144 return; 145 } 146 147 // Inflate layout but don't add it yet as it should be added after the screenshot is ready 148 // for preview. 149 mLayout = getLayoutInflater().inflate(R.layout.app_clips_screenshot, null); 150 mRoot = mLayout.findViewById(R.id.root); 151 152 mSave = mLayout.findViewById(R.id.save); 153 mCancel = mLayout.findViewById(R.id.cancel); 154 mSave.setOnClickListener(this::onClick); 155 mCancel.setOnClickListener(this::onClick); 156 157 158 mCropView = mLayout.findViewById(R.id.crop_view); 159 160 mPreview = mLayout.findViewById(R.id.preview); 161 mPreview.addOnLayoutChangeListener( 162 (v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> 163 updateImageDimensions()); 164 165 mViewModel = new ViewModelProvider(this, mViewModelFactory).get(AppClipsViewModel.class); 166 mViewModel.getScreenshot().observe(this, this::setScreenshot); 167 mViewModel.getResultLiveData().observe(this, this::setResultThenFinish); 168 mViewModel.getErrorLiveData().observe(this, this::setErrorThenFinish); 169 170 if (savedInstanceState == null) { 171 mViewModel.performScreenshot(); 172 } 173 } 174 175 @Override finish()176 public void finish() { 177 super.finish(); 178 overridePendingTransition(0, 0); 179 } 180 181 @Override onDestroy()182 protected void onDestroy() { 183 super.onDestroy(); 184 185 unregisterReceiver(mBroadcastReceiver); 186 187 // If neither error nor result was set, it implies that the activity is finishing due to 188 // some other reason such as user dismissing this activity using back gesture. Inform error. 189 if (isFinishing() && mViewModel.getErrorLiveData().getValue() == null 190 && mViewModel.getResultLiveData().getValue() == null) { 191 // Set error but don't finish as the activity is already finishing. 192 setError(Intent.CAPTURE_CONTENT_FOR_NOTE_FAILED); 193 } 194 } 195 setUpUiLogging(Intent intent)196 private void setUpUiLogging(Intent intent) { 197 mCallingPackageName = intent.getStringExtra(EXTRA_CALLING_PACKAGE_NAME); 198 mCallingPackageUid = 0; 199 try { 200 mCallingPackageUid = mPackageManager.getApplicationInfoAsUser(mCallingPackageName, 201 APPLICATION_INFO_FLAGS, mUserTracker.getUserId()).uid; 202 } catch (NameNotFoundException e) { 203 Log.d(TAG, "Couldn't find notes app UID " + e); 204 } 205 } 206 setScreenshot(Bitmap screenshot)207 private void setScreenshot(Bitmap screenshot) { 208 // Set background, status and navigation bar colors as the activity is no longer 209 // translucent. 210 int colorBackgroundFloating = Utils.getColorAttr(this, 211 android.R.attr.colorBackgroundFloating).getDefaultColor(); 212 mRoot.setBackgroundColor(colorBackgroundFloating); 213 214 BitmapDrawable drawable = new BitmapDrawable(getResources(), screenshot); 215 mPreview.setImageDrawable(drawable); 216 mPreview.setAlpha(1f); 217 218 // Screenshot is now available so set content view. 219 setContentView(mLayout); 220 } 221 onClick(View view)222 private void onClick(View view) { 223 mSave.setEnabled(false); 224 mCancel.setEnabled(false); 225 226 int id = view.getId(); 227 if (id == R.id.save) { 228 saveScreenshotThenFinish(); 229 } else { 230 setErrorThenFinish(Intent.CAPTURE_CONTENT_FOR_NOTE_USER_CANCELED); 231 } 232 } 233 saveScreenshotThenFinish()234 private void saveScreenshotThenFinish() { 235 Drawable drawable = mPreview.getDrawable(); 236 if (drawable == null) { 237 setErrorThenFinish(Intent.CAPTURE_CONTENT_FOR_NOTE_FAILED); 238 return; 239 } 240 241 Rect bounds = mCropView.getCropBoundaries(drawable.getIntrinsicWidth(), 242 drawable.getIntrinsicHeight()); 243 244 if (bounds.isEmpty()) { 245 setErrorThenFinish(Intent.CAPTURE_CONTENT_FOR_NOTE_FAILED); 246 return; 247 } 248 249 updateImageDimensions(); 250 mViewModel.saveScreenshotThenFinish(drawable, bounds, getUser()); 251 } 252 setResultThenFinish(Uri uri)253 private void setResultThenFinish(Uri uri) { 254 if (mResultReceiver == null) { 255 return; 256 } 257 258 // Grant permission here instead of in the trampoline activity because this activity can run 259 // as work profile user so the URI can belong to the work profile user while the trampoline 260 // activity always runs as main user. 261 grantUriPermission(mCallingPackageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION); 262 263 Bundle data = new Bundle(); 264 data.putInt(Intent.EXTRA_CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE, 265 Intent.CAPTURE_CONTENT_FOR_NOTE_SUCCESS); 266 data.putParcelable(EXTRA_SCREENSHOT_URI, uri); 267 try { 268 mResultReceiver.send(Activity.RESULT_OK, data); 269 logUiEvent(SCREENSHOT_FOR_NOTE_ACCEPTED); 270 } catch (Exception e) { 271 // Do nothing. 272 } 273 274 // Nullify the ResultReceiver before finishing to avoid resending the result. 275 mResultReceiver = null; 276 finish(); 277 } 278 setErrorThenFinish(int errorCode)279 private void setErrorThenFinish(int errorCode) { 280 setError(errorCode); 281 finish(); 282 } 283 setError(int errorCode)284 private void setError(int errorCode) { 285 if (mResultReceiver == null) { 286 return; 287 } 288 289 Bundle data = new Bundle(); 290 data.putInt(Intent.EXTRA_CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE, errorCode); 291 try { 292 mResultReceiver.send(RESULT_OK, data); 293 if (errorCode == Intent.CAPTURE_CONTENT_FOR_NOTE_USER_CANCELED) { 294 logUiEvent(SCREENSHOT_FOR_NOTE_CANCELLED); 295 } 296 } catch (Exception e) { 297 // Do nothing. 298 } 299 300 // Nullify the ResultReceiver to avoid resending the result. 301 mResultReceiver = null; 302 } 303 logUiEvent(UiEventEnum uiEvent)304 private void logUiEvent(UiEventEnum uiEvent) { 305 mUiEventLogger.log(uiEvent, mCallingPackageUid, mCallingPackageName); 306 } 307 updateImageDimensions()308 private void updateImageDimensions() { 309 Drawable drawable = mPreview.getDrawable(); 310 if (drawable == null) { 311 return; 312 } 313 314 Rect bounds = drawable.getBounds(); 315 float imageRatio = bounds.width() / (float) bounds.height(); 316 int previewWidth = mPreview.getWidth() - mPreview.getPaddingLeft() 317 - mPreview.getPaddingRight(); 318 int previewHeight = mPreview.getHeight() - mPreview.getPaddingTop() 319 - mPreview.getPaddingBottom(); 320 float viewRatio = previewWidth / (float) previewHeight; 321 322 if (imageRatio > viewRatio) { 323 // Image is full width and height is constrained, compute extra padding to inform 324 // CropView. 325 int imageHeight = (int) (previewHeight * viewRatio / imageRatio); 326 int extraPadding = (previewHeight - imageHeight) / 2; 327 mCropView.setExtraPadding(extraPadding, extraPadding); 328 mCropView.setImageWidth(previewWidth); 329 } else { 330 // Image is full height. 331 mCropView.setExtraPadding(mPreview.getPaddingTop(), mPreview.getPaddingBottom()); 332 mCropView.setImageWidth((int) (previewHeight * imageRatio)); 333 } 334 } 335 } 336