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