blob: 2f96f6c656c4a03381cf90f82361c3ac66e29344 [file] [log] [blame]
/*
* Copyright (C) 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.screenshot.appclips;
import static com.android.systemui.screenshot.appclips.AppClipsEvent.SCREENSHOT_FOR_NOTE_ACCEPTED;
import static com.android.systemui.screenshot.appclips.AppClipsEvent.SCREENSHOT_FOR_NOTE_CANCELLED;
import static com.android.systemui.screenshot.appclips.AppClipsTrampolineActivity.ACTION_FINISH_FROM_TRAMPOLINE;
import static com.android.systemui.screenshot.appclips.AppClipsTrampolineActivity.EXTRA_CALLING_PACKAGE_NAME;
import static com.android.systemui.screenshot.appclips.AppClipsTrampolineActivity.EXTRA_RESULT_RECEIVER;
import static com.android.systemui.screenshot.appclips.AppClipsTrampolineActivity.EXTRA_SCREENSHOT_URI;
import static com.android.systemui.screenshot.appclips.AppClipsTrampolineActivity.PERMISSION_SELF;
import android.app.Activity;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.content.pm.PackageManager.ApplicationInfoFlags;
import android.content.pm.PackageManager.NameNotFoundException;
import android.graphics.Bitmap;
import android.graphics.Rect;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Bundle;
import android.os.ResultReceiver;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.ImageView;
import androidx.activity.ComponentActivity;
import androidx.annotation.Nullable;
import androidx.lifecycle.ViewModelProvider;
import com.android.internal.logging.UiEventLogger;
import com.android.internal.logging.UiEventLogger.UiEventEnum;
import com.android.settingslib.Utils;
import com.android.systemui.R;
import com.android.systemui.screenshot.CropView;
import com.android.systemui.settings.UserTracker;
import javax.inject.Inject;
/**
* An {@link Activity} to take a screenshot for the App Clips flow and presenting a screenshot
* editing tool.
*
* <p>An App Clips flow includes:
* <ul>
* <li>Checking if calling activity meets the prerequisites. This is done by
* {@link AppClipsTrampolineActivity}.
* <li>Performing the screenshot.
* <li>Showing a screenshot editing tool.
* <li>Returning the screenshot to the {@link AppClipsTrampolineActivity} so that it can return
* the screenshot to the calling activity after explicit user consent.
* </ul>
*
* <p>This {@link Activity} runs in its own separate process to isolate memory intensive image
* editing from SysUI process.
*
* TODO(b/267309532): Polish UI and animations.
*/
public class AppClipsActivity extends ComponentActivity {
private static final String TAG = AppClipsActivity.class.getSimpleName();
private static final ApplicationInfoFlags APPLICATION_INFO_FLAGS = ApplicationInfoFlags.of(0);
private final AppClipsViewModel.Factory mViewModelFactory;
private final PackageManager mPackageManager;
private final UserTracker mUserTracker;
private final UiEventLogger mUiEventLogger;
private final BroadcastReceiver mBroadcastReceiver;
private final IntentFilter mIntentFilter;
private View mLayout;
private View mRoot;
private ImageView mPreview;
private CropView mCropView;
private Button mSave;
private Button mCancel;
private AppClipsViewModel mViewModel;
private ResultReceiver mResultReceiver;
@Nullable
private String mCallingPackageName;
private int mCallingPackageUid;
@Inject
public AppClipsActivity(AppClipsViewModel.Factory viewModelFactory,
PackageManager packageManager, UserTracker userTracker, UiEventLogger uiEventLogger) {
mViewModelFactory = viewModelFactory;
mPackageManager = packageManager;
mUserTracker = userTracker;
mUiEventLogger = uiEventLogger;
mBroadcastReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
// Trampoline activity was dismissed so finish this activity.
if (ACTION_FINISH_FROM_TRAMPOLINE.equals(intent.getAction())) {
if (!isFinishing()) {
// Nullify the ResultReceiver so that result cannot be sent as trampoline
// activity is already finishing.
mResultReceiver = null;
finish();
}
}
}
};
mIntentFilter = new IntentFilter(ACTION_FINISH_FROM_TRAMPOLINE);
}
@Override
public void onCreate(Bundle savedInstanceState) {
overridePendingTransition(0, 0);
super.onCreate(savedInstanceState);
// Register the broadcast receiver that informs when the trampoline activity is dismissed.
registerReceiver(mBroadcastReceiver, mIntentFilter, PERMISSION_SELF, null,
RECEIVER_NOT_EXPORTED);
Intent intent = getIntent();
setUpUiLogging(intent);
mResultReceiver = intent.getParcelableExtra(EXTRA_RESULT_RECEIVER, ResultReceiver.class);
if (mResultReceiver == null) {
setErrorThenFinish(Intent.CAPTURE_CONTENT_FOR_NOTE_FAILED);
return;
}
// Inflate layout but don't add it yet as it should be added after the screenshot is ready
// for preview.
mLayout = getLayoutInflater().inflate(R.layout.app_clips_screenshot, null);
mRoot = mLayout.findViewById(R.id.root);
mSave = mLayout.findViewById(R.id.save);
mCancel = mLayout.findViewById(R.id.cancel);
mSave.setOnClickListener(this::onClick);
mCancel.setOnClickListener(this::onClick);
mCropView = mLayout.findViewById(R.id.crop_view);
mPreview = mLayout.findViewById(R.id.preview);
mPreview.addOnLayoutChangeListener(
(v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) ->
updateImageDimensions());
mViewModel = new ViewModelProvider(this, mViewModelFactory).get(AppClipsViewModel.class);
mViewModel.getScreenshot().observe(this, this::setScreenshot);
mViewModel.getResultLiveData().observe(this, this::setResultThenFinish);
mViewModel.getErrorLiveData().observe(this, this::setErrorThenFinish);
if (savedInstanceState == null) {
mViewModel.performScreenshot();
}
}
@Override
public void finish() {
super.finish();
overridePendingTransition(0, 0);
}
@Override
protected void onDestroy() {
super.onDestroy();
unregisterReceiver(mBroadcastReceiver);
// If neither error nor result was set, it implies that the activity is finishing due to
// some other reason such as user dismissing this activity using back gesture. Inform error.
if (isFinishing() && mViewModel.getErrorLiveData().getValue() == null
&& mViewModel.getResultLiveData().getValue() == null) {
// Set error but don't finish as the activity is already finishing.
setError(Intent.CAPTURE_CONTENT_FOR_NOTE_FAILED);
}
}
private void setUpUiLogging(Intent intent) {
mCallingPackageName = intent.getStringExtra(EXTRA_CALLING_PACKAGE_NAME);
mCallingPackageUid = 0;
try {
mCallingPackageUid = mPackageManager.getApplicationInfoAsUser(mCallingPackageName,
APPLICATION_INFO_FLAGS, mUserTracker.getUserId()).uid;
} catch (NameNotFoundException e) {
Log.d(TAG, "Couldn't find notes app UID " + e);
}
}
private void setScreenshot(Bitmap screenshot) {
// Set background, status and navigation bar colors as the activity is no longer
// translucent.
int colorBackgroundFloating = Utils.getColorAttr(this,
android.R.attr.colorBackgroundFloating).getDefaultColor();
mRoot.setBackgroundColor(colorBackgroundFloating);
BitmapDrawable drawable = new BitmapDrawable(getResources(), screenshot);
mPreview.setImageDrawable(drawable);
mPreview.setAlpha(1f);
// Screenshot is now available so set content view.
setContentView(mLayout);
}
private void onClick(View view) {
mSave.setEnabled(false);
mCancel.setEnabled(false);
int id = view.getId();
if (id == R.id.save) {
saveScreenshotThenFinish();
} else {
setErrorThenFinish(Intent.CAPTURE_CONTENT_FOR_NOTE_USER_CANCELED);
}
}
private void saveScreenshotThenFinish() {
Drawable drawable = mPreview.getDrawable();
if (drawable == null) {
setErrorThenFinish(Intent.CAPTURE_CONTENT_FOR_NOTE_FAILED);
return;
}
Rect bounds = mCropView.getCropBoundaries(drawable.getIntrinsicWidth(),
drawable.getIntrinsicHeight());
if (bounds.isEmpty()) {
setErrorThenFinish(Intent.CAPTURE_CONTENT_FOR_NOTE_FAILED);
return;
}
updateImageDimensions();
mViewModel.saveScreenshotThenFinish(drawable, bounds, getUser());
}
private void setResultThenFinish(Uri uri) {
if (mResultReceiver == null) {
return;
}
// Grant permission here instead of in the trampoline activity because this activity can run
// as work profile user so the URI can belong to the work profile user while the trampoline
// activity always runs as main user.
grantUriPermission(mCallingPackageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
Bundle data = new Bundle();
data.putInt(Intent.EXTRA_CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE,
Intent.CAPTURE_CONTENT_FOR_NOTE_SUCCESS);
data.putParcelable(EXTRA_SCREENSHOT_URI, uri);
try {
mResultReceiver.send(Activity.RESULT_OK, data);
logUiEvent(SCREENSHOT_FOR_NOTE_ACCEPTED);
} catch (Exception e) {
// Do nothing.
}
// Nullify the ResultReceiver before finishing to avoid resending the result.
mResultReceiver = null;
finish();
}
private void setErrorThenFinish(int errorCode) {
setError(errorCode);
finish();
}
private void setError(int errorCode) {
if (mResultReceiver == null) {
return;
}
Bundle data = new Bundle();
data.putInt(Intent.EXTRA_CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE, errorCode);
try {
mResultReceiver.send(RESULT_OK, data);
if (errorCode == Intent.CAPTURE_CONTENT_FOR_NOTE_USER_CANCELED) {
logUiEvent(SCREENSHOT_FOR_NOTE_CANCELLED);
}
} catch (Exception e) {
// Do nothing.
}
// Nullify the ResultReceiver to avoid resending the result.
mResultReceiver = null;
}
private void logUiEvent(UiEventEnum uiEvent) {
mUiEventLogger.log(uiEvent, mCallingPackageUid, mCallingPackageName);
}
private void updateImageDimensions() {
Drawable drawable = mPreview.getDrawable();
if (drawable == null) {
return;
}
Rect bounds = drawable.getBounds();
float imageRatio = bounds.width() / (float) bounds.height();
int previewWidth = mPreview.getWidth() - mPreview.getPaddingLeft()
- mPreview.getPaddingRight();
int previewHeight = mPreview.getHeight() - mPreview.getPaddingTop()
- mPreview.getPaddingBottom();
float viewRatio = previewWidth / (float) previewHeight;
if (imageRatio > viewRatio) {
// Image is full width and height is constrained, compute extra padding to inform
// CropView.
int imageHeight = (int) (previewHeight * viewRatio / imageRatio);
int extraPadding = (previewHeight - imageHeight) / 2;
mCropView.setExtraPadding(extraPadding, extraPadding);
mCropView.setImageWidth(previewWidth);
} else {
// Image is full height.
mCropView.setExtraPadding(mPreview.getPaddingTop(), mPreview.getPaddingBottom());
mCropView.setImageWidth((int) (previewHeight * imageRatio));
}
}
}