diff options
21 files changed, 1943 insertions, 0 deletions
diff --git a/packages/SystemUI/AndroidManifest.xml b/packages/SystemUI/AndroidManifest.xml index 8e98d2d00951..64e1bc22ea93 100644 --- a/packages/SystemUI/AndroidManifest.xml +++ b/packages/SystemUI/AndroidManifest.xml @@ -418,6 +418,34 @@ android:permission="com.android.systemui.permission.SELF" android:exported="false" /> + <activity android:name=".screenshot.AppClipsTrampolineActivity" + android:theme="@style/AppClipsTrampolineActivity" + android:label="@string/screenshot_preview_description" + android:permission="android.permission.LAUNCH_CAPTURE_CONTENT_ACTIVITY_FOR_NOTE" + android:exported="true"> + <intent-filter android:priority="1"> + <action android:name="android.intent.action.LAUNCH_CAPTURE_CONTENT_ACTIVITY_FOR_NOTE" /> + <category android:name="android.intent.category.DEFAULT" /> + </intent-filter> + </activity> + + <activity android:name=".screenshot.AppClipsActivity" + android:theme="@style/AppClipsActivity" + android:process=":appclips.screenshot" + android:label="@string/screenshot_preview_description" + android:permission="com.android.systemui.permission.SELF" + android:excludeFromRecents="true" + android:exported="false" + android:noHistory="true" /> + + <service android:name=".screenshot.appclips.AppClipsScreenshotHelperService" + android:permission="com.android.systemui.permission.SELF" + android:exported="false" /> + + <service android:name=".screenshot.appclips.AppClipsService" + android:permission="android.permission.LAUNCH_CAPTURE_CONTENT_ACTIVITY_FOR_NOTE" + android:exported="true" /> + <service android:name=".screenrecord.RecordingService" android:foregroundServiceType="systemExempted"/> diff --git a/packages/SystemUI/res/layout/app_clips_screenshot.xml b/packages/SystemUI/res/layout/app_clips_screenshot.xml new file mode 100644 index 000000000000..5155b77a6bee --- /dev/null +++ b/packages/SystemUI/res/layout/app_clips_screenshot.xml @@ -0,0 +1,105 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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. + --> +<androidx.constraintlayout.widget.ConstraintLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:background="@null" + android:id="@+id/root" + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <Button + android:id="@+id/save" + style="@android:style/Widget.DeviceDefault.Button.Colored" + android:layout_width="wrap_content" + android:layout_height="48dp" + android:text="@string/app_clips_save_add_to_note" + android:layout_marginStart="8dp" + android:background="@drawable/overlay_button_background" + android:textColor="?android:textColorSecondary" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toTopOf="@id/preview" /> + + <Button + android:id="@+id/cancel" + style="@android:style/Widget.DeviceDefault.Button.Colored" + android:layout_width="wrap_content" + android:layout_height="48dp" + android:text="@android:string/cancel" + android:layout_marginStart="6dp" + android:background="@drawable/overlay_button_background" + android:textColor="?android:textColorSecondary" + app:layout_constraintStart_toEndOf="@id/save" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toTopOf="@id/preview" /> + + <ImageView + android:id="@+id/preview" + android:layout_width="0px" + android:layout_height="0px" + android:paddingHorizontal="48dp" + android:paddingTop="8dp" + android:paddingBottom="42dp" + android:contentDescription="@string/screenshot_preview_description" + app:layout_constrainedHeight="true" + app:layout_constrainedWidth="true" + app:layout_constraintTop_toBottomOf="@id/save" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintBottom_toBottomOf="parent" + tools:background="?android:colorBackground" + tools:minHeight="100dp" + tools:minWidth="100dp" /> + + <com.android.systemui.screenshot.CropView + android:id="@+id/crop_view" + android:layout_width="0px" + android:layout_height="0px" + android:paddingTop="8dp" + android:paddingBottom="42dp" + app:layout_constrainedHeight="true" + app:layout_constrainedWidth="true" + app:layout_constraintTop_toTopOf="@id/preview" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintBottom_toBottomOf="parent" + app:handleThickness="@dimen/screenshot_crop_handle_thickness" + app:handleColor="?android:attr/colorAccent" + app:scrimColor="?android:colorBackgroundFloating" + app:scrimAlpha="128" + app:containerBackgroundColor="?android:colorBackgroundFloating" + tools:background="?android:colorBackground" + tools:minHeight="100dp" + tools:minWidth="100dp" /> + + <com.android.systemui.screenshot.MagnifierView + android:id="@+id/magnifier" + android:visibility="invisible" + android:layout_width="200dp" + android:layout_height="200dp" + android:elevation="2dp" + app:layout_constraintTop_toTopOf="@id/preview" + app:layout_constraintLeft_toLeftOf="parent" + app:handleThickness="@dimen/screenshot_crop_handle_thickness" + app:handleColor="?android:attr/colorAccent" + app:scrimColor="?android:colorBackgroundFloating" + app:scrimAlpha="128" + app:borderThickness="4dp" + app:borderColor="#fff" /> +</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/packages/SystemUI/res/values/config.xml b/packages/SystemUI/res/values/config.xml index f5db5eccc2e0..32ff65c391f9 100644 --- a/packages/SystemUI/res/values/config.xml +++ b/packages/SystemUI/res/values/config.xml @@ -445,6 +445,13 @@ screenshot has been saved to work profile. If blank, a default icon will be shown. --> <string name="config_sceenshotWorkProfileFilesApp" translatable="false"></string> + <!-- The component name of the screenshot editing activity that provides the App Clips flow. + The App Clips flow includes taking a screenshot, showing user screenshot cropping activity + and finally letting user send the screenshot to the calling notes app. This activity + should not send the screenshot to the calling activity without user consent. --> + <string name="config_screenshotAppClipsActivityComponent" translatable="false" + >com.android.systemui/com.android.systemui.screenshot.AppClipsActivity</string> + <!-- Remote copy default activity. Must handle REMOTE_COPY_ACTION intents. This name is in the ComponentName flattened format (package/class) --> <string name="config_remoteCopyPackage" translatable="false"></string> diff --git a/packages/SystemUI/res/values/strings.xml b/packages/SystemUI/res/values/strings.xml index 0f9462866cd4..da93a562c54d 100644 --- a/packages/SystemUI/res/values/strings.xml +++ b/packages/SystemUI/res/values/strings.xml @@ -247,6 +247,8 @@ <string name="screenshot_detected_template"><xliff:g id="appName" example="Google Chrome">%1$s</xliff:g> detected this screenshot.</string> <!-- A notice shown to the user to indicate that multiple apps have detected the screenshot that the user has just taken. [CHAR LIMIT=75] --> <string name="screenshot_detected_multiple_template"><xliff:g id="appName" example="Google Chrome">%1$s</xliff:g> and other open apps detected this screenshot.</string> + <!-- Add to note button used in App Clips flow to return the saved screenshot image to notes app. [CHAR LIMIT=NONE] --> + <string name="app_clips_save_add_to_note">Add to note</string> <!-- Notification title displayed for screen recording [CHAR LIMIT=50]--> <string name="screenrecord_name">Screen Recorder</string> diff --git a/packages/SystemUI/res/values/styles.xml b/packages/SystemUI/res/values/styles.xml index dd87e914eefe..4998d68e057b 100644 --- a/packages/SystemUI/res/values/styles.xml +++ b/packages/SystemUI/res/values/styles.xml @@ -758,6 +758,18 @@ </style> <!-- Screenshots --> + <style name="AppClipsTrampolineActivity"> + <item name="android:windowIsTranslucent">true</item> + <item name="android:windowNoTitle">true</item> + <item name="android:windowIsFloating">true</item> + <item name="android:backgroundDimEnabled">true</item> + </style> + + <style name="AppClipsActivity" parent="LongScreenshotActivity"> + <item name="android:windowBackground">@android:color/transparent</item> + <item name="android:windowIsTranslucent">true</item> + </style> + <style name="LongScreenshotActivity" parent="@android:style/Theme.DeviceDefault.DayNight"> <item name="android:windowNoTitle">true</item> <item name="android:windowLightStatusBar">true</item> diff --git a/packages/SystemUI/src/com/android/systemui/dagger/DefaultActivityBinder.java b/packages/SystemUI/src/com/android/systemui/dagger/DefaultActivityBinder.java index 4eb444e3f652..a5beb4e85058 100644 --- a/packages/SystemUI/src/com/android/systemui/dagger/DefaultActivityBinder.java +++ b/packages/SystemUI/src/com/android/systemui/dagger/DefaultActivityBinder.java @@ -23,6 +23,8 @@ import com.android.systemui.hdmi.HdmiCecSetMenuLanguageActivity; import com.android.systemui.keyguard.WorkLockActivity; import com.android.systemui.people.PeopleSpaceActivity; import com.android.systemui.people.widget.LaunchConversationActivity; +import com.android.systemui.screenshot.AppClipsActivity; +import com.android.systemui.screenshot.AppClipsTrampolineActivity; import com.android.systemui.screenshot.LongScreenshotActivity; import com.android.systemui.sensorprivacy.SensorUseStartedActivity; import com.android.systemui.sensorprivacy.television.TvSensorPrivacyChangedActivity; @@ -119,6 +121,18 @@ public abstract class DefaultActivityBinder { @ClassKey(LongScreenshotActivity.class) public abstract Activity bindLongScreenshotActivity(LongScreenshotActivity activity); + /** Inject into AppClipsTrampolineActivity. */ + @Binds + @IntoMap + @ClassKey(AppClipsTrampolineActivity.class) + public abstract Activity bindAppClipsTrampolineActivity(AppClipsTrampolineActivity activity); + + /** Inject into AppClipsActivity. */ + @Binds + @IntoMap + @ClassKey(AppClipsActivity.class) + public abstract Activity bindAppClipsActivity(AppClipsActivity activity); + /** Inject into LaunchConversationActivity. */ @Binds @IntoMap diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsActivity.java b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsActivity.java new file mode 100644 index 000000000000..f8d86a0367cf --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsActivity.java @@ -0,0 +1,289 @@ +/* + * 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; + +import static com.android.systemui.screenshot.AppClipsTrampolineActivity.ACTION_FINISH_FROM_TRAMPOLINE; +import static com.android.systemui.screenshot.AppClipsTrampolineActivity.EXTRA_RESULT_RECEIVER; +import static com.android.systemui.screenshot.AppClipsTrampolineActivity.EXTRA_SCREENSHOT_URI; +import static com.android.systemui.screenshot.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.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.view.View; +import android.widget.Button; +import android.widget.ImageView; + +import androidx.activity.ComponentActivity; +import androidx.lifecycle.ViewModelProvider; + +import com.android.settingslib.Utils; +import com.android.systemui.R; + +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 final class AppClipsActivity extends ComponentActivity { + + private final AppClipsViewModel.Factory mViewModelFactory; + private final BroadcastReceiver mBroadcastReceiver; + private final IntentFilter mIntentFilter; + + private View mLayout; + private View mRoot; + private ImageView mPreview; + private CropView mCropView; + private MagnifierView mMagnifierView; + private Button mSave; + private Button mCancel; + private AppClipsViewModel mViewModel; + + private ResultReceiver mResultReceiver; + + @Inject + public AppClipsActivity(AppClipsViewModel.Factory viewModelFactory) { + mViewModelFactory = viewModelFactory; + + 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(); + 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); + + mMagnifierView = mLayout.findViewById(R.id.magnifier); + mCropView = mLayout.findViewById(R.id.crop_view); + mCropView.setCropInteractionListener(mMagnifierView); + + 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 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); + + mMagnifierView.setDrawable(drawable, screenshot.getWidth(), screenshot.getHeight()); + + // 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); + } + + private void setResultThenFinish(Uri uri) { + if (mResultReceiver == null) { + return; + } + + 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); + } 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); + } catch (Exception e) { + // Do nothing. + } + + // Nullify the ResultReceiver to avoid resending the result. + mResultReceiver = null; + } + + 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)); + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsCrossProcessHelper.java b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsCrossProcessHelper.java new file mode 100644 index 000000000000..65fb4c9bfb0d --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsCrossProcessHelper.java @@ -0,0 +1,65 @@ +/* + * 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 android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.view.Display; + +import androidx.annotation.Nullable; + +import com.android.internal.infra.AndroidFuture; +import com.android.internal.infra.ServiceConnector; +import com.android.systemui.dagger.SysUISingleton; +import com.android.systemui.dagger.qualifiers.Application; + +import javax.inject.Inject; + +/** An intermediary singleton object to help communicating with the cross process service. */ +@SysUISingleton +public class AppClipsCrossProcessHelper { + + private final ServiceConnector<IAppClipsScreenshotHelperService> mProxyConnector; + + @Inject + public AppClipsCrossProcessHelper(@Application Context context) { + mProxyConnector = new ServiceConnector.Impl<IAppClipsScreenshotHelperService>(context, + new Intent(context, AppClipsScreenshotHelperService.class), + Context.BIND_AUTO_CREATE | Context.BIND_WAIVE_PRIORITY + | Context.BIND_NOT_VISIBLE, context.getUserId(), + IAppClipsScreenshotHelperService.Stub::asInterface); + } + + /** + * Returns a {@link Bitmap} captured in the SysUI process, {@code null} in case of an error. + * + * <p>Note: The SysUI process captures a {@link ScreenshotHardwareBufferInternal} which is ok to + * pass around but not a {@link Bitmap}. + */ + @Nullable + public Bitmap takeScreenshot() { + try { + AndroidFuture<ScreenshotHardwareBufferInternal> future = + mProxyConnector.postForResult( + service -> service.takeScreenshot(Display.DEFAULT_DISPLAY)); + return future.get().createBitmapThenCloseBuffer(); + } catch (Exception e) { + return null; + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsScreenshotHelperService.java b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsScreenshotHelperService.java new file mode 100644 index 000000000000..6f8c36595c74 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsScreenshotHelperService.java @@ -0,0 +1,69 @@ +/* + * 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 android.app.Service; +import android.content.Intent; +import android.os.IBinder; +import android.window.ScreenCapture.ScreenshotHardwareBuffer; +import android.window.ScreenCapture.ScreenshotSync; + +import androidx.annotation.Nullable; + +import com.android.systemui.screenshot.AppClipsActivity; +import com.android.wm.shell.bubbles.Bubbles; + +import java.util.Optional; + +import javax.inject.Inject; + +/** + * A helper service that runs in SysUI process and helps {@link AppClipsActivity} which runs in its + * own separate process take a screenshot. + */ +public class AppClipsScreenshotHelperService extends Service { + + private final Optional<Bubbles> mOptionalBubbles; + + @Inject + public AppClipsScreenshotHelperService(Optional<Bubbles> optionalBubbles) { + mOptionalBubbles = optionalBubbles; + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return new IAppClipsScreenshotHelperService.Stub() { + @Override + @Nullable + public ScreenshotHardwareBufferInternal takeScreenshot(int displayId) { + if (mOptionalBubbles.isEmpty()) { + return null; + } + + ScreenshotSync screenshotSync = + mOptionalBubbles.get().getScreenshotExcludingBubble(displayId); + ScreenshotHardwareBuffer screenshotHardwareBuffer = screenshotSync.get(); + if (screenshotHardwareBuffer == null) { + return null; + } + + return new ScreenshotHardwareBufferInternal(screenshotHardwareBuffer); + } + }; + } +} diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsService.java b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsService.java new file mode 100644 index 000000000000..d0b7ad3e9dd5 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsService.java @@ -0,0 +1,110 @@ +/* + * 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.flags.Flags.SCREENSHOT_APP_CLIPS; + +import android.app.Activity; +import android.app.Service; +import android.app.StatusBarManager; +import android.app.admin.DevicePolicyManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.os.IBinder; + +import androidx.annotation.Nullable; + +import com.android.internal.statusbar.IAppClipsService; +import com.android.systemui.R; +import com.android.systemui.dagger.qualifiers.Application; +import com.android.systemui.flags.FeatureFlags; +import com.android.wm.shell.bubbles.Bubbles; + +import java.util.Optional; + +import javax.inject.Inject; + +/** + * A service that communicates with {@link StatusBarManager} to support the + * {@link StatusBarManager#canLaunchCaptureContentActivityForNote(Activity)} API. + */ +public class AppClipsService extends Service { + + @Application private final Context mContext; + private final FeatureFlags mFeatureFlags; + private final Optional<Bubbles> mOptionalBubbles; + private final DevicePolicyManager mDevicePolicyManager; + private final boolean mAreTaskAndTimeIndependentPrerequisitesMet; + + @Inject + public AppClipsService(@Application Context context, FeatureFlags featureFlags, + Optional<Bubbles> optionalBubbles, DevicePolicyManager devicePolicyManager) { + mContext = context; + mFeatureFlags = featureFlags; + mOptionalBubbles = optionalBubbles; + mDevicePolicyManager = devicePolicyManager; + + mAreTaskAndTimeIndependentPrerequisitesMet = checkIndependentVariables(); + } + + private boolean checkIndependentVariables() { + if (!mFeatureFlags.isEnabled(SCREENSHOT_APP_CLIPS)) { + return false; + } + + if (mOptionalBubbles.isEmpty()) { + return false; + } + + return isComponentValid(); + } + + private boolean isComponentValid() { + ComponentName componentName; + try { + componentName = ComponentName.unflattenFromString( + mContext.getString(R.string.config_screenshotAppClipsActivityComponent)); + } catch (Resources.NotFoundException e) { + return false; + } + + return componentName != null + && !componentName.getPackageName().isEmpty() + && !componentName.getClassName().isEmpty(); + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return new IAppClipsService.Stub() { + @Override + public boolean canLaunchCaptureContentActivityForNote(int taskId) { + if (!mAreTaskAndTimeIndependentPrerequisitesMet) { + return false; + } + + if (!mOptionalBubbles.get().isAppBubbleTaskId(taskId)) { + return false; + } + + return !mDevicePolicyManager.getScreenCaptureDisabled(null); + } + }; + } +} diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsTrampolineActivity.java b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsTrampolineActivity.java new file mode 100644 index 000000000000..4759cc62e1f2 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsTrampolineActivity.java @@ -0,0 +1,233 @@ +/* + * 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; + +import static android.content.Intent.CAPTURE_CONTENT_FOR_NOTE_BLOCKED_BY_ADMIN; +import static android.content.Intent.CAPTURE_CONTENT_FOR_NOTE_FAILED; +import static android.content.Intent.CAPTURE_CONTENT_FOR_NOTE_SUCCESS; +import static android.content.Intent.CAPTURE_CONTENT_FOR_NOTE_WINDOW_MODE_UNSUPPORTED; +import static android.content.Intent.EXTRA_CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE; +import static android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION; + +import static com.android.systemui.flags.Flags.SCREENSHOT_APP_CLIPS; + +import android.app.Activity; +import android.app.admin.DevicePolicyManager; +import android.content.ActivityNotFoundException; +import android.content.ComponentName; +import android.content.Intent; +import android.content.res.Resources; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.Parcel; +import android.os.ResultReceiver; + +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import com.android.systemui.R; +import com.android.systemui.dagger.qualifiers.Main; +import com.android.systemui.flags.FeatureFlags; +import com.android.systemui.notetask.NoteTaskController; +import com.android.wm.shell.bubbles.Bubbles; + +import java.util.Optional; + +import javax.inject.Inject; + +/** + * A trampoline activity that is responsible for: + * <ul> + * <li>Performing precondition checks before starting the actual screenshot activity. + * <li>Communicating with the screenshot activity and the calling activity. + * </ul> + * + * <p>As this activity is started in a bubble app, the windowing for this activity is restricted + * to the parent bubble app. The screenshot editing activity, see {@link AppClipsActivity}, is + * started in a regular activity window using {@link Intent#FLAG_ACTIVITY_NEW_TASK}. However, + * {@link Activity#startActivityForResult(Intent, int)} is not compatible with + * {@link Intent#FLAG_ACTIVITY_NEW_TASK}. So, this activity acts as a trampoline activity to + * abstract the complexity of communication with the screenshot editing activity for a simpler + * developer experience. + * + * TODO(b/267309532): Polish UI and animations. + */ +public class AppClipsTrampolineActivity extends Activity { + + private static final String TAG = AppClipsTrampolineActivity.class.getSimpleName(); + public static final String PERMISSION_SELF = "com.android.systemui.permission.SELF"; + public static final String EXTRA_SCREENSHOT_URI = TAG + "SCREENSHOT_URI"; + public static final String ACTION_FINISH_FROM_TRAMPOLINE = TAG + "FINISH_FROM_TRAMPOLINE"; + static final String EXTRA_RESULT_RECEIVER = TAG + "RESULT_RECEIVER"; + + private final DevicePolicyManager mDevicePolicyManager; + private final FeatureFlags mFeatureFlags; + private final Optional<Bubbles> mOptionalBubbles; + private final NoteTaskController mNoteTaskController; + private final ResultReceiver mResultReceiver; + + private Intent mKillAppClipsBroadcastIntent; + + @Inject + public AppClipsTrampolineActivity(DevicePolicyManager devicePolicyManager, FeatureFlags flags, + Optional<Bubbles> optionalBubbles, NoteTaskController noteTaskController, + @Main Handler mainHandler) { + mDevicePolicyManager = devicePolicyManager; + mFeatureFlags = flags; + mOptionalBubbles = optionalBubbles; + mNoteTaskController = noteTaskController; + + mResultReceiver = createResultReceiver(mainHandler); + } + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (savedInstanceState != null) { + return; + } + + if (!mFeatureFlags.isEnabled(SCREENSHOT_APP_CLIPS)) { + finish(); + return; + } + + if (mOptionalBubbles.isEmpty()) { + setErrorResultAndFinish(CAPTURE_CONTENT_FOR_NOTE_FAILED); + return; + } + + if (!mOptionalBubbles.get().isAppBubbleTaskId(getTaskId())) { + setErrorResultAndFinish(CAPTURE_CONTENT_FOR_NOTE_WINDOW_MODE_UNSUPPORTED); + return; + } + + if (mDevicePolicyManager.getScreenCaptureDisabled(null)) { + setErrorResultAndFinish(CAPTURE_CONTENT_FOR_NOTE_BLOCKED_BY_ADMIN); + return; + } + + ComponentName componentName; + try { + componentName = ComponentName.unflattenFromString( + getString(R.string.config_screenshotAppClipsActivityComponent)); + } catch (Resources.NotFoundException e) { + setErrorResultAndFinish(CAPTURE_CONTENT_FOR_NOTE_FAILED); + return; + } + + if (componentName == null || componentName.getPackageName().isEmpty() + || componentName.getClassName().isEmpty()) { + setErrorResultAndFinish(CAPTURE_CONTENT_FOR_NOTE_FAILED); + return; + } + + Intent intent = new Intent().setComponent(componentName).addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK).putExtra(EXTRA_RESULT_RECEIVER, mResultReceiver); + try { + // Start the App Clips activity. + startActivity(intent); + + // Set up the broadcast intent that will inform the above App Clips activity to finish + // when this trampoline activity is finished. + mKillAppClipsBroadcastIntent = + new Intent(ACTION_FINISH_FROM_TRAMPOLINE) + .setComponent(componentName) + .setPackage(componentName.getPackageName()); + } catch (ActivityNotFoundException e) { + setErrorResultAndFinish(CAPTURE_CONTENT_FOR_NOTE_FAILED); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + + if (isFinishing() && mKillAppClipsBroadcastIntent != null) { + sendBroadcast(mKillAppClipsBroadcastIntent, PERMISSION_SELF); + } + } + + private void setErrorResultAndFinish(int errorCode) { + setResult(RESULT_OK, + new Intent().putExtra(EXTRA_CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE, errorCode)); + finish(); + } + + private class AppClipsResultReceiver extends ResultReceiver { + + AppClipsResultReceiver(Handler handler) { + super(handler); + } + + @Override + protected void onReceiveResult(int resultCode, Bundle resultData) { + if (isFinishing()) { + // It's too late, trampoline activity is finishing or already finished. + // Return early. + return; + } + + // Package the response that should be sent to the calling activity. + Intent convertedData = new Intent(); + int statusCode = CAPTURE_CONTENT_FOR_NOTE_FAILED; + if (resultData != null) { + statusCode = resultData.getInt(EXTRA_CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE, + CAPTURE_CONTENT_FOR_NOTE_FAILED); + } + convertedData.putExtra(EXTRA_CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE, statusCode); + + if (statusCode == CAPTURE_CONTENT_FOR_NOTE_SUCCESS) { + Uri uri = resultData.getParcelable(EXTRA_SCREENSHOT_URI, Uri.class); + convertedData.setData(uri).addFlags(FLAG_GRANT_READ_URI_PERMISSION); + } + + // Broadcast no longer required, setting it to null. + mKillAppClipsBroadcastIntent = null; + + // Expand the note bubble before returning the result. As App Clips API is only + // available when in a bubble, isInMultiWindowMode is always false below. + mNoteTaskController.showNoteTask(false); + setResult(RESULT_OK, convertedData); + finish(); + } + } + + /** + * @return a {@link ResultReceiver} by initializing an {@link AppClipsResultReceiver} and + * converting it into a generic {@link ResultReceiver} to pass across a different but trusted + * process. + */ + private ResultReceiver createResultReceiver(@Main Handler handler) { + AppClipsResultReceiver appClipsResultReceiver = new AppClipsResultReceiver(handler); + Parcel parcel = Parcel.obtain(); + appClipsResultReceiver.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + + ResultReceiver resultReceiver = ResultReceiver.CREATOR.createFromParcel(parcel); + parcel.recycle(); + return resultReceiver; + } + + /** This is a test only API for mocking response from {@link AppClipsActivity}. */ + @VisibleForTesting + public ResultReceiver getResultReceiverForTest() { + return mResultReceiver; + } +} diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsViewModel.java b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsViewModel.java new file mode 100644 index 000000000000..5a7b5f94ea4f --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsViewModel.java @@ -0,0 +1,184 @@ +/* + * 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; + +import static android.content.Intent.CAPTURE_CONTENT_FOR_NOTE_FAILED; + +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.HardwareRenderer; +import android.graphics.RecordingCanvas; +import android.graphics.Rect; +import android.graphics.RenderNode; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Process; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import com.android.systemui.dagger.qualifiers.Background; +import com.android.systemui.dagger.qualifiers.Main; +import com.android.systemui.screenshot.appclips.AppClipsCrossProcessHelper; + +import com.google.common.util.concurrent.ListenableFuture; + +import java.time.ZonedDateTime; +import java.util.UUID; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; + +import javax.inject.Inject; + +/** A {@link ViewModel} to help with the App Clips screenshot flow. */ +final class AppClipsViewModel extends ViewModel { + + private final AppClipsCrossProcessHelper mAppClipsCrossProcessHelper; + private final ImageExporter mImageExporter; + @Main + private final Executor mMainExecutor; + @Background + private final Executor mBgExecutor; + + private final MutableLiveData<Bitmap> mScreenshotLiveData; + private final MutableLiveData<Uri> mResultLiveData; + private final MutableLiveData<Integer> mErrorLiveData; + + AppClipsViewModel(AppClipsCrossProcessHelper appClipsCrossProcessHelper, + ImageExporter imageExporter, @Main Executor mainExecutor, + @Background Executor bgExecutor) { + mAppClipsCrossProcessHelper = appClipsCrossProcessHelper; + mImageExporter = imageExporter; + mMainExecutor = mainExecutor; + mBgExecutor = bgExecutor; + + mScreenshotLiveData = new MutableLiveData<>(); + mResultLiveData = new MutableLiveData<>(); + mErrorLiveData = new MutableLiveData<>(); + } + + /** Grabs a screenshot and updates the {@link Bitmap} set in screenshot {@link LiveData}. */ + void performScreenshot() { + mBgExecutor.execute(() -> { + Bitmap screenshot = mAppClipsCrossProcessHelper.takeScreenshot(); + mMainExecutor.execute(() -> { + if (screenshot == null) { + mErrorLiveData.setValue(CAPTURE_CONTENT_FOR_NOTE_FAILED); + } else { + mScreenshotLiveData.setValue(screenshot); + } + }); + }); + } + + /** Returns a {@link LiveData} that holds the captured screenshot. */ + LiveData<Bitmap> getScreenshot() { + return mScreenshotLiveData; + } + + /** Returns a {@link LiveData} that holds the {@link Uri} where screenshot is saved. */ + LiveData<Uri> getResultLiveData() { + return mResultLiveData; + } + + /** + * Returns a {@link LiveData} that holds the error codes for + * {@link Intent#EXTRA_CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE}. + */ + LiveData<Integer> getErrorLiveData() { + return mErrorLiveData; + } + + /** + * Saves the provided {@link Drawable} to storage then informs the result {@link Uri} to + * {@link LiveData}. + */ + void saveScreenshotThenFinish(Drawable screenshotDrawable, Rect bounds) { + mBgExecutor.execute(() -> { + // Render the screenshot bitmap in background. + Bitmap screenshotBitmap = renderBitmap(screenshotDrawable, bounds); + + // Export and save the screenshot in background. + // TODO(b/267310185): Save to work profile UserHandle. + ListenableFuture<ImageExporter.Result> exportFuture = mImageExporter.export( + mBgExecutor, UUID.randomUUID(), screenshotBitmap, ZonedDateTime.now(), + Process.myUserHandle()); + + // Get the result and update state on main thread. + exportFuture.addListener(() -> { + try { + ImageExporter.Result result = exportFuture.get(); + if (result.uri == null) { + mErrorLiveData.setValue(CAPTURE_CONTENT_FOR_NOTE_FAILED); + return; + } + + mResultLiveData.setValue(result.uri); + } catch (CancellationException | InterruptedException | ExecutionException e) { + mErrorLiveData.setValue(CAPTURE_CONTENT_FOR_NOTE_FAILED); + } + }, mMainExecutor); + }); + } + + private static Bitmap renderBitmap(Drawable drawable, Rect bounds) { + final RenderNode output = new RenderNode("Screenshot save"); + output.setPosition(0, 0, bounds.width(), bounds.height()); + RecordingCanvas canvas = output.beginRecording(); + canvas.translate(-bounds.left, -bounds.top); + canvas.clipRect(bounds); + drawable.draw(canvas); + output.endRecording(); + return HardwareRenderer.createHardwareBitmap(output, bounds.width(), bounds.height()); + } + + /** Helper factory to help with injecting {@link AppClipsViewModel}. */ + static final class Factory implements ViewModelProvider.Factory { + + private final AppClipsCrossProcessHelper mAppClipsCrossProcessHelper; + private final ImageExporter mImageExporter; + @Main + private final Executor mMainExecutor; + @Background + private final Executor mBgExecutor; + + @Inject + Factory(AppClipsCrossProcessHelper appClipsCrossProcessHelper, ImageExporter imageExporter, + @Main Executor mainExecutor, @Background Executor bgExecutor) { + mAppClipsCrossProcessHelper = appClipsCrossProcessHelper; + mImageExporter = imageExporter; + mMainExecutor = mainExecutor; + mBgExecutor = bgExecutor; + } + + @NonNull + @Override + public <T extends ViewModel> T create(@NonNull Class<T> modelClass) { + if (modelClass != AppClipsViewModel.class) { + throw new IllegalArgumentException(); + } + + //noinspection unchecked + return (T) new AppClipsViewModel(mAppClipsCrossProcessHelper, mImageExporter, + mMainExecutor, mBgExecutor); + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/appclips/IAppClipsScreenshotHelperService.aidl b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/IAppClipsScreenshotHelperService.aidl new file mode 100644 index 000000000000..640e7420cc2e --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/IAppClipsScreenshotHelperService.aidl @@ -0,0 +1,29 @@ +/** + * 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 android.os.Bundle; + +import com.android.systemui.screenshot.appclips.ScreenshotHardwareBufferInternal; + +/** + * A helper service that runs in SysUI process and helps {@link AppClipsActivity} which runs in its + * own separate process take a screenshot. + */ +interface IAppClipsScreenshotHelperService { + @nullable ScreenshotHardwareBufferInternal takeScreenshot(in int displayId); +} diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/appclips/ScreenshotHardwareBufferInternal.aidl b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/ScreenshotHardwareBufferInternal.aidl new file mode 100644 index 000000000000..3a7b94452687 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/ScreenshotHardwareBufferInternal.aidl @@ -0,0 +1,19 @@ +/** + * 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; + +parcelable ScreenshotHardwareBufferInternal; diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/appclips/ScreenshotHardwareBufferInternal.java b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/ScreenshotHardwareBufferInternal.java new file mode 100644 index 000000000000..3b107f101088 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/ScreenshotHardwareBufferInternal.java @@ -0,0 +1,96 @@ +/* + * 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 android.graphics.Bitmap; +import android.graphics.ColorSpace; +import android.graphics.ParcelableColorSpace; +import android.hardware.HardwareBuffer; +import android.os.Parcel; +import android.os.Parcelable; +import android.window.ScreenCapture.ScreenshotHardwareBuffer; + +/** + * An internal version of {@link ScreenshotHardwareBuffer} that helps with parceling the information + * necessary for creating a {@link Bitmap}. + */ +public class ScreenshotHardwareBufferInternal implements Parcelable { + + public static final Creator<ScreenshotHardwareBufferInternal> CREATOR = + new Creator<>() { + @Override + public ScreenshotHardwareBufferInternal createFromParcel(Parcel in) { + return new ScreenshotHardwareBufferInternal(in); + } + + @Override + public ScreenshotHardwareBufferInternal[] newArray(int size) { + return new ScreenshotHardwareBufferInternal[size]; + } + }; + private final HardwareBuffer mHardwareBuffer; + private final ParcelableColorSpace mParcelableColorSpace; + + public ScreenshotHardwareBufferInternal( + ScreenshotHardwareBuffer screenshotHardwareBuffer) { + mHardwareBuffer = screenshotHardwareBuffer.getHardwareBuffer(); + mParcelableColorSpace = new ParcelableColorSpace( + screenshotHardwareBuffer.getColorSpace()); + } + + private ScreenshotHardwareBufferInternal(Parcel in) { + mHardwareBuffer = in.readParcelable(HardwareBuffer.class.getClassLoader(), + HardwareBuffer.class); + mParcelableColorSpace = in.readParcelable(ParcelableColorSpace.class.getClassLoader(), + ParcelableColorSpace.class); + } + + /** + * Returns a {@link Bitmap} represented by the underlying data and successively closes the + * internal {@link HardwareBuffer}. See, + * {@link Bitmap#wrapHardwareBuffer(HardwareBuffer, ColorSpace)} and + * {@link HardwareBuffer#close()} for more information. + */ + public Bitmap createBitmapThenCloseBuffer() { + Bitmap bitmap = Bitmap.wrapHardwareBuffer(mHardwareBuffer, + mParcelableColorSpace.getColorSpace()); + mHardwareBuffer.close(); + return bitmap; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeParcelable(mHardwareBuffer, flags); + dest.writeParcelable(mParcelableColorSpace, flags); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof ScreenshotHardwareBufferInternal)) { + return false; + } + + ScreenshotHardwareBufferInternal other = (ScreenshotHardwareBufferInternal) obj; + return mHardwareBuffer.equals(other.mHardwareBuffer) && mParcelableColorSpace.equals( + other.mParcelableColorSpace); + } +} diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/dagger/ScreenshotModule.java b/packages/SystemUI/src/com/android/systemui/screenshot/dagger/ScreenshotModule.java index fdb01000b837..22e238c0f2ad 100644 --- a/packages/SystemUI/src/com/android/systemui/screenshot/dagger/ScreenshotModule.java +++ b/packages/SystemUI/src/com/android/systemui/screenshot/dagger/ScreenshotModule.java @@ -24,6 +24,8 @@ import com.android.systemui.screenshot.ScreenshotPolicy; import com.android.systemui.screenshot.ScreenshotPolicyImpl; import com.android.systemui.screenshot.ScreenshotProxyService; import com.android.systemui.screenshot.TakeScreenshotService; +import com.android.systemui.screenshot.appclips.AppClipsScreenshotHelperService; +import com.android.systemui.screenshot.appclips.AppClipsService; import dagger.Binds; import dagger.Module; @@ -52,4 +54,13 @@ public abstract class ScreenshotModule { @Binds abstract ImageCapture bindImageCaptureImpl(ImageCaptureImpl capture); + @Binds + @IntoMap + @ClassKey(AppClipsScreenshotHelperService.class) + abstract Service bindAppClipsScreenshotHelperService(AppClipsScreenshotHelperService service); + + @Binds + @IntoMap + @ClassKey(AppClipsService.class) + abstract Service bindAppClipsService(AppClipsService service); } diff --git a/packages/SystemUI/tests/AndroidManifest.xml b/packages/SystemUI/tests/AndroidManifest.xml index 2c1e68146cce..ed2772af4e3f 100644 --- a/packages/SystemUI/tests/AndroidManifest.xml +++ b/packages/SystemUI/tests/AndroidManifest.xml @@ -159,6 +159,12 @@ android:enabled="false" tools:replace="android:authorities" android:grantUriPermissions="true" /> + + <activity + android:name="com.android.systemui.screenshot.appclips.AppClipsTrampolineActivityTest$AppClipsTrampolineActivityTestable" + android:exported="false" + android:permission="com.android.systemui.permission.SELF" + android:excludeFromRecents="true" /> </application> <instrumentation android:name="android.testing.TestableInstrumentation" diff --git a/packages/SystemUI/tests/src/com/android/systemui/screenshot/appclips/AppClipsScreenshotHelperServiceTest.java b/packages/SystemUI/tests/src/com/android/systemui/screenshot/appclips/AppClipsScreenshotHelperServiceTest.java new file mode 100644 index 000000000000..6e8f5fe60774 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/screenshot/appclips/AppClipsScreenshotHelperServiceTest.java @@ -0,0 +1,105 @@ +/* + * 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.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.when; + +import android.content.Intent; +import android.graphics.ColorSpace; +import android.hardware.HardwareBuffer; +import android.os.RemoteException; +import android.view.Display; +import android.window.ScreenCapture.ScreenshotHardwareBuffer; +import android.window.ScreenCapture.ScreenshotSync; + +import androidx.test.runner.AndroidJUnit4; + +import com.android.systemui.SysuiTestCase; +import com.android.wm.shell.bubbles.Bubbles; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.Optional; + +@RunWith(AndroidJUnit4.class) +public final class AppClipsScreenshotHelperServiceTest extends SysuiTestCase { + + private static final Intent FAKE_INTENT = new Intent(); + private static final int DEFAULT_DISPLAY = Display.DEFAULT_DISPLAY; + private static final HardwareBuffer FAKE_HARDWARE_BUFFER = + HardwareBuffer.create(1, 1, HardwareBuffer.RGBA_8888, 1, + HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE); + private static final ColorSpace FAKE_COLOR_SPACE = ColorSpace.get(ColorSpace.Named.SRGB); + private static final ScreenshotHardwareBufferInternal EXPECTED_SCREENSHOT_BUFFER = + new ScreenshotHardwareBufferInternal( + new ScreenshotHardwareBuffer(FAKE_HARDWARE_BUFFER, FAKE_COLOR_SPACE, false, + false)); + + @Mock private Optional<Bubbles> mBubblesOptional; + @Mock private Bubbles mBubbles; + @Mock private ScreenshotHardwareBuffer mScreenshotHardwareBuffer; + @Mock private ScreenshotSync mScreenshotSync; + + private AppClipsScreenshotHelperService mAppClipsScreenshotHelperService; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + mAppClipsScreenshotHelperService = new AppClipsScreenshotHelperService(mBubblesOptional); + } + + @Test + public void emptyBubbles_shouldReturnNull() throws RemoteException { + when(mBubblesOptional.isEmpty()).thenReturn(true); + + assertThat(getInterface().takeScreenshot(DEFAULT_DISPLAY)).isNull(); + } + + @Test + public void bubblesPresent_screenshotFailed_ShouldReturnNull() throws RemoteException { + when(mBubblesOptional.isEmpty()).thenReturn(false); + when(mBubblesOptional.get()).thenReturn(mBubbles); + when(mBubbles.getScreenshotExcludingBubble(DEFAULT_DISPLAY)).thenReturn(mScreenshotSync); + when(mScreenshotSync.get()).thenReturn(null); + + assertThat(getInterface().takeScreenshot(DEFAULT_DISPLAY)).isNull(); + } + + @Test + public void bubblesPresent_screenshotSuccess_shouldReturnScreenshot() throws RemoteException { + when(mBubblesOptional.isEmpty()).thenReturn(false); + when(mBubblesOptional.get()).thenReturn(mBubbles); + when(mBubbles.getScreenshotExcludingBubble(DEFAULT_DISPLAY)).thenReturn(mScreenshotSync); + when(mScreenshotSync.get()).thenReturn(mScreenshotHardwareBuffer); + when(mScreenshotHardwareBuffer.getHardwareBuffer()).thenReturn(FAKE_HARDWARE_BUFFER); + when(mScreenshotHardwareBuffer.getColorSpace()).thenReturn(FAKE_COLOR_SPACE); + + assertThat(getInterface().takeScreenshot(DEFAULT_DISPLAY)).isEqualTo( + EXPECTED_SCREENSHOT_BUFFER); + } + + private IAppClipsScreenshotHelperService getInterface() { + return IAppClipsScreenshotHelperService.Stub.asInterface( + mAppClipsScreenshotHelperService.onBind(FAKE_INTENT)); + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/screenshot/appclips/AppClipsServiceTest.java b/packages/SystemUI/tests/src/com/android/systemui/screenshot/appclips/AppClipsServiceTest.java new file mode 100644 index 000000000000..b55fe3677256 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/screenshot/appclips/AppClipsServiceTest.java @@ -0,0 +1,148 @@ +/* + * 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.flags.Flags.SCREENSHOT_APP_CLIPS; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import android.app.admin.DevicePolicyManager; +import android.content.Context; +import android.content.Intent; +import android.os.IBinder; +import android.os.RemoteException; + +import androidx.test.runner.AndroidJUnit4; + +import com.android.internal.statusbar.IAppClipsService; +import com.android.systemui.SysuiTestCase; +import com.android.systemui.dagger.qualifiers.Application; +import com.android.systemui.flags.FeatureFlags; +import com.android.wm.shell.bubbles.Bubbles; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.Optional; + +@RunWith(AndroidJUnit4.class) +public final class AppClipsServiceTest extends SysuiTestCase { + + private static final Intent FAKE_INTENT = new Intent(); + private static final int FAKE_TASK_ID = 42; + private static final String EMPTY = ""; + + @Mock @Application private Context mMockContext; + @Mock private FeatureFlags mFeatureFlags; + @Mock private Optional<Bubbles> mOptionalBubbles; + @Mock private Bubbles mBubbles; + @Mock private DevicePolicyManager mDevicePolicyManager; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + } + + @Test + public void flagOff_shouldReturnFalse() throws RemoteException { + when(mFeatureFlags.isEnabled(SCREENSHOT_APP_CLIPS)).thenReturn(false); + + assertThat(getInterfaceWithRealContext() + .canLaunchCaptureContentActivityForNote(FAKE_TASK_ID)).isFalse(); + } + + @Test + public void emptyBubbles_shouldReturnFalse() throws RemoteException { + when(mFeatureFlags.isEnabled(SCREENSHOT_APP_CLIPS)).thenReturn(true); + when(mOptionalBubbles.isEmpty()).thenReturn(true); + + assertThat(getInterfaceWithRealContext() + .canLaunchCaptureContentActivityForNote(FAKE_TASK_ID)).isFalse(); + } + + @Test + public void taskIdNotAppBubble_shouldReturnFalse() throws RemoteException { + when(mFeatureFlags.isEnabled(SCREENSHOT_APP_CLIPS)).thenReturn(true); + when(mOptionalBubbles.isEmpty()).thenReturn(false); + when(mOptionalBubbles.get()).thenReturn(mBubbles); + when(mBubbles.isAppBubbleTaskId(eq((FAKE_TASK_ID)))).thenReturn(false); + + assertThat(getInterfaceWithRealContext() + .canLaunchCaptureContentActivityForNote(FAKE_TASK_ID)).isFalse(); + } + + @Test + public void dpmScreenshotBlocked_shouldReturnFalse() throws RemoteException { + when(mFeatureFlags.isEnabled(SCREENSHOT_APP_CLIPS)).thenReturn(true); + when(mOptionalBubbles.isEmpty()).thenReturn(false); + when(mOptionalBubbles.get()).thenReturn(mBubbles); + when(mBubbles.isAppBubbleTaskId(eq((FAKE_TASK_ID)))).thenReturn(true); + when(mDevicePolicyManager.getScreenCaptureDisabled(eq(null))).thenReturn(true); + + assertThat(getInterfaceWithRealContext() + .canLaunchCaptureContentActivityForNote(FAKE_TASK_ID)).isFalse(); + } + + @Test + public void configComponentNameNotValid_shouldReturnFalse() throws RemoteException { + when(mMockContext.getString(anyInt())).thenReturn(EMPTY); + when(mFeatureFlags.isEnabled(SCREENSHOT_APP_CLIPS)).thenReturn(true); + when(mOptionalBubbles.isEmpty()).thenReturn(false); + when(mOptionalBubbles.get()).thenReturn(mBubbles); + when(mBubbles.isAppBubbleTaskId(eq((FAKE_TASK_ID)))).thenReturn(true); + when(mDevicePolicyManager.getScreenCaptureDisabled(eq(null))).thenReturn(false); + + assertThat(getInterfaceWithMockContext() + .canLaunchCaptureContentActivityForNote(FAKE_TASK_ID)).isFalse(); + } + + @Test + public void allPrerequisitesSatisfy_shouldReturnTrue() throws RemoteException { + when(mFeatureFlags.isEnabled(SCREENSHOT_APP_CLIPS)).thenReturn(true); + when(mOptionalBubbles.isEmpty()).thenReturn(false); + when(mOptionalBubbles.get()).thenReturn(mBubbles); + when(mBubbles.isAppBubbleTaskId(eq((FAKE_TASK_ID)))).thenReturn(true); + when(mDevicePolicyManager.getScreenCaptureDisabled(eq(null))).thenReturn(false); + + assertThat(getInterfaceWithRealContext() + .canLaunchCaptureContentActivityForNote(FAKE_TASK_ID)).isTrue(); + } + + private IAppClipsService getInterfaceWithRealContext() { + AppClipsService appClipsService = new AppClipsService(getContext(), mFeatureFlags, + mOptionalBubbles, mDevicePolicyManager); + return getInterfaceFromService(appClipsService); + } + + private IAppClipsService getInterfaceWithMockContext() { + AppClipsService appClipsService = new AppClipsService(mMockContext, mFeatureFlags, + mOptionalBubbles, mDevicePolicyManager); + return getInterfaceFromService(appClipsService); + } + + private static IAppClipsService getInterfaceFromService(AppClipsService appClipsService) { + IBinder iBinder = appClipsService.onBind(FAKE_INTENT); + return IAppClipsService.Stub.asInterface(iBinder); + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/screenshot/appclips/AppClipsTrampolineActivityTest.java b/packages/SystemUI/tests/src/com/android/systemui/screenshot/appclips/AppClipsTrampolineActivityTest.java new file mode 100644 index 000000000000..295d127c2494 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/screenshot/appclips/AppClipsTrampolineActivityTest.java @@ -0,0 +1,269 @@ +/* + * 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 android.app.Instrumentation.ActivityResult; +import static android.content.Intent.CAPTURE_CONTENT_FOR_NOTE_BLOCKED_BY_ADMIN; +import static android.content.Intent.CAPTURE_CONTENT_FOR_NOTE_FAILED; +import static android.content.Intent.CAPTURE_CONTENT_FOR_NOTE_SUCCESS; +import static android.content.Intent.CAPTURE_CONTENT_FOR_NOTE_USER_CANCELED; +import static android.content.Intent.CAPTURE_CONTENT_FOR_NOTE_WINDOW_MODE_UNSUPPORTED; +import static android.content.Intent.EXTRA_CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE; + +import static com.android.systemui.flags.Flags.SCREENSHOT_APP_CLIPS; +import static com.android.systemui.screenshot.AppClipsTrampolineActivity.ACTION_FINISH_FROM_TRAMPOLINE; +import static com.android.systemui.screenshot.AppClipsTrampolineActivity.EXTRA_SCREENSHOT_URI; +import static com.android.systemui.screenshot.AppClipsTrampolineActivity.PERMISSION_SELF; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import android.app.Activity; +import android.app.admin.DevicePolicyManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.testing.AndroidTestingRunner; + +import androidx.test.rule.ActivityTestRule; +import androidx.test.runner.intercepting.SingleActivityFactory; + +import com.android.systemui.R; +import com.android.systemui.SysuiTestCase; +import com.android.systemui.dagger.qualifiers.Main; +import com.android.systemui.flags.FeatureFlags; +import com.android.systemui.notetask.NoteTaskController; +import com.android.systemui.screenshot.AppClipsTrampolineActivity; +import com.android.wm.shell.bubbles.Bubbles; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.Optional; + +@RunWith(AndroidTestingRunner.class) +public final class AppClipsTrampolineActivityTest extends SysuiTestCase { + + private static final String TEST_URI_STRING = "www.test-uri.com"; + private static final Uri TEST_URI = Uri.parse(TEST_URI_STRING); + private static final int TIME_OUT = 5000; + + @Mock + private DevicePolicyManager mDevicePolicyManager; + @Mock + private FeatureFlags mFeatureFlags; + @Mock + private Optional<Bubbles> mOptionalBubbles; + @Mock + private Bubbles mBubbles; + @Mock + private NoteTaskController mNoteTaskController; + @Main + private Handler mMainHandler; + + // Using the deprecated ActivityTestRule and SingleActivityFactory to help with injecting mocks + // and getting result from activity both of which are difficult to do in newer APIs. + private final SingleActivityFactory<AppClipsTrampolineActivityTestable> mFactory = + new SingleActivityFactory<>(AppClipsTrampolineActivityTestable.class) { + @Override + protected AppClipsTrampolineActivityTestable create(Intent unUsed) { + return new AppClipsTrampolineActivityTestable(mDevicePolicyManager, + mFeatureFlags, mOptionalBubbles, mNoteTaskController, mMainHandler); + } + }; + + @Rule + public final ActivityTestRule<AppClipsTrampolineActivityTestable> mActivityRule = + new ActivityTestRule<>(mFactory, false, false); + + private Context mContext; + private Intent mActivityIntent; + private ComponentName mExpectedComponentName; + private Intent mKillAppClipsActivityBroadcast; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + mContext = getContext(); + mMainHandler = mContext.getMainThreadHandler(); + + mActivityIntent = new Intent(mContext, AppClipsTrampolineActivityTestable.class); + mExpectedComponentName = ComponentName.unflattenFromString( + mContext.getString( + R.string.config_screenshotAppClipsActivityComponent)); + mKillAppClipsActivityBroadcast = new Intent(ACTION_FINISH_FROM_TRAMPOLINE) + .setComponent(mExpectedComponentName) + .setPackage(mExpectedComponentName.getPackageName()); + } + + @After + public void tearDown() { + mContext.sendBroadcast(mKillAppClipsActivityBroadcast, PERMISSION_SELF); + mActivityRule.finishActivity(); + } + + @Test + public void configComponentName_shouldResolve() { + // Verify component name is setup - has package and class name. + assertThat(mExpectedComponentName).isNotNull(); + assertThat(mExpectedComponentName.getPackageName()).isNotEmpty(); + assertThat(mExpectedComponentName.getClassName()).isNotEmpty(); + + // Verify an intent when launched with above component resolves to the same component to + // confirm that component from above is available in framework. + Intent appClipsActivityIntent = new Intent(); + appClipsActivityIntent.setComponent(mExpectedComponentName); + ResolveInfo resolveInfo = getContext().getPackageManager().resolveActivity( + appClipsActivityIntent, PackageManager.ResolveInfoFlags.of(0)); + ActivityInfo activityInfo = resolveInfo.activityInfo; + + assertThat(activityInfo.packageName).isEqualTo( + mExpectedComponentName.getPackageName()); + assertThat(activityInfo.name).isEqualTo(mExpectedComponentName.getClassName()); + } + + @Test + public void flagOff_shouldFinishWithResultCancel() { + when(mFeatureFlags.isEnabled(SCREENSHOT_APP_CLIPS)).thenReturn(false); + + mActivityRule.launchActivity(mActivityIntent); + + assertThat(mActivityRule.getActivityResult().getResultCode()) + .isEqualTo(Activity.RESULT_CANCELED); + } + + @Test + public void bubblesEmpty_shouldFinishWithFailed() { + when(mFeatureFlags.isEnabled(SCREENSHOT_APP_CLIPS)).thenReturn(true); + when(mOptionalBubbles.isEmpty()).thenReturn(true); + + mActivityRule.launchActivity(mActivityIntent); + + ActivityResult actualResult = mActivityRule.getActivityResult(); + assertThat(actualResult.getResultCode()).isEqualTo(Activity.RESULT_OK); + assertThat(getStatusCodeExtra(actualResult.getResultData())) + .isEqualTo(CAPTURE_CONTENT_FOR_NOTE_FAILED); + } + + @Test + public void taskIdNotAppBubble_shouldFinishWithWindowModeUnsupported() { + when(mFeatureFlags.isEnabled(SCREENSHOT_APP_CLIPS)).thenReturn(true); + when(mOptionalBubbles.isEmpty()).thenReturn(false); + when(mOptionalBubbles.get()).thenReturn(mBubbles); + when(mBubbles.isAppBubbleTaskId(anyInt())).thenReturn(false); + + mActivityRule.launchActivity(mActivityIntent); + + ActivityResult actualResult = mActivityRule.getActivityResult(); + assertThat(actualResult.getResultCode()).isEqualTo(Activity.RESULT_OK); + assertThat(getStatusCodeExtra(actualResult.getResultData())) + .isEqualTo(CAPTURE_CONTENT_FOR_NOTE_WINDOW_MODE_UNSUPPORTED); + } + + @Test + public void dpmScreenshotBlocked_shouldFinishWithBlockedByAdmin() { + when(mFeatureFlags.isEnabled(SCREENSHOT_APP_CLIPS)).thenReturn(true); + when(mOptionalBubbles.isEmpty()).thenReturn(false); + when(mOptionalBubbles.get()).thenReturn(mBubbles); + when(mBubbles.isAppBubbleTaskId(anyInt())).thenReturn(true); + when(mDevicePolicyManager.getScreenCaptureDisabled(eq(null))).thenReturn(true); + + mActivityRule.launchActivity(mActivityIntent); + + ActivityResult actualResult = mActivityRule.getActivityResult(); + assertThat(actualResult.getResultCode()).isEqualTo(Activity.RESULT_OK); + assertThat(getStatusCodeExtra(actualResult.getResultData())) + .isEqualTo(CAPTURE_CONTENT_FOR_NOTE_BLOCKED_BY_ADMIN); + } + + @Test + public void startAppClipsActivity_userCanceled_shouldReturnUserCanceled() { + mockToSatisfyAllPrerequisites(); + + AppClipsTrampolineActivityTestable activity = mActivityRule.launchActivity(mActivityIntent); + waitForIdleSync(); + + Bundle bundle = new Bundle(); + bundle.putInt(EXTRA_CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE, + CAPTURE_CONTENT_FOR_NOTE_USER_CANCELED); + activity.getResultReceiverForTest().send(Activity.RESULT_OK, bundle); + waitForIdleSync(); + + ActivityResult actualResult = mActivityRule.getActivityResult(); + assertThat(actualResult.getResultCode()).isEqualTo(Activity.RESULT_OK); + assertThat(getStatusCodeExtra(actualResult.getResultData())) + .isEqualTo(CAPTURE_CONTENT_FOR_NOTE_USER_CANCELED); + } + + @Test + public void startAppClipsActivity_shouldReturnSuccess() { + mockToSatisfyAllPrerequisites(); + + AppClipsTrampolineActivityTestable activity = mActivityRule.launchActivity(mActivityIntent); + waitForIdleSync(); + + Bundle bundle = new Bundle(); + bundle.putParcelable(EXTRA_SCREENSHOT_URI, TEST_URI); + bundle.putInt(EXTRA_CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE, CAPTURE_CONTENT_FOR_NOTE_SUCCESS); + activity.getResultReceiverForTest().send(Activity.RESULT_OK, bundle); + waitForIdleSync(); + + ActivityResult actualResult = mActivityRule.getActivityResult(); + assertThat(actualResult.getResultCode()).isEqualTo(Activity.RESULT_OK); + assertThat(getStatusCodeExtra(actualResult.getResultData())) + .isEqualTo(CAPTURE_CONTENT_FOR_NOTE_SUCCESS); + assertThat(actualResult.getResultData().getData()).isEqualTo(TEST_URI); + } + + private void mockToSatisfyAllPrerequisites() { + when(mFeatureFlags.isEnabled(SCREENSHOT_APP_CLIPS)).thenReturn(true); + when(mOptionalBubbles.isEmpty()).thenReturn(false); + when(mOptionalBubbles.get()).thenReturn(mBubbles); + when(mBubbles.isAppBubbleTaskId(anyInt())).thenReturn(true); + when(mDevicePolicyManager.getScreenCaptureDisabled(eq(null))).thenReturn(false); + } + + public static final class AppClipsTrampolineActivityTestable extends + AppClipsTrampolineActivity { + + public AppClipsTrampolineActivityTestable(DevicePolicyManager devicePolicyManager, + FeatureFlags flags, + Optional<Bubbles> optionalBubbles, + NoteTaskController noteTaskController, + @Main Handler mainHandler) { + super(devicePolicyManager, flags, optionalBubbles, noteTaskController, mainHandler); + } + } + + private static int getStatusCodeExtra(Intent intent) { + return intent.getIntExtra(EXTRA_CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE, -100); + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/screenshot/appclips/AppClipsViewModelTest.java b/packages/SystemUI/tests/src/com/android/systemui/screenshot/appclips/AppClipsViewModelTest.java new file mode 100644 index 000000000000..d5af7ce1d346 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/screenshot/appclips/AppClipsViewModelTest.java @@ -0,0 +1,142 @@ +/* + * 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; + +import static android.content.Intent.CAPTURE_CONTENT_FOR_NOTE_FAILED; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.graphics.Bitmap; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.ShapeDrawable; +import android.net.Uri; +import android.os.UserHandle; + +import androidx.test.runner.AndroidJUnit4; + +import com.android.systemui.SysuiTestCase; +import com.android.systemui.screenshot.appclips.AppClipsCrossProcessHelper; + +import com.google.common.util.concurrent.Futures; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.time.ZonedDateTime; +import java.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; + +@RunWith(AndroidJUnit4.class) +public final class AppClipsViewModelTest extends SysuiTestCase { + + private static final Bitmap FAKE_BITMAP = Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888); + private static final Drawable FAKE_DRAWABLE = new ShapeDrawable(); + private static final Rect FAKE_RECT = new Rect(); + private static final Uri FAKE_URI = Uri.parse("www.test-uri.com"); + + @Mock private AppClipsCrossProcessHelper mAppClipsCrossProcessHelper; + @Mock private ImageExporter mImageExporter; + + private com.android.systemui.screenshot.AppClipsViewModel mViewModel; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + + mViewModel = new AppClipsViewModel.Factory(mAppClipsCrossProcessHelper, mImageExporter, + getContext().getMainExecutor(), directExecutor()).create(AppClipsViewModel.class); + } + + @Test + public void performScreenshot_fails_shouldUpdateErrorWithFailed() { + when(mAppClipsCrossProcessHelper.takeScreenshot()).thenReturn(null); + + mViewModel.performScreenshot(); + waitForIdleSync(); + + verify(mAppClipsCrossProcessHelper).takeScreenshot(); + assertThat(mViewModel.getErrorLiveData().getValue()) + .isEqualTo(CAPTURE_CONTENT_FOR_NOTE_FAILED); + assertThat(mViewModel.getResultLiveData().getValue()).isNull(); + } + + @Test + public void performScreenshot_succeeds_shouldUpdateScreenshotWithBitmap() { + when(mAppClipsCrossProcessHelper.takeScreenshot()).thenReturn(FAKE_BITMAP); + + mViewModel.performScreenshot(); + waitForIdleSync(); + + verify(mAppClipsCrossProcessHelper).takeScreenshot(); + assertThat(mViewModel.getErrorLiveData().getValue()).isNull(); + assertThat(mViewModel.getScreenshot().getValue()).isEqualTo(FAKE_BITMAP); + } + + @Test + public void saveScreenshot_throwsError_shouldUpdateErrorWithFailed() { + when(mImageExporter.export(any(Executor.class), any(UUID.class), eq(null), any( + ZonedDateTime.class), any(UserHandle.class))).thenReturn( + Futures.immediateFailedFuture(new ExecutionException(new Throwable()))); + + mViewModel.saveScreenshotThenFinish(FAKE_DRAWABLE, FAKE_RECT); + waitForIdleSync(); + + assertThat(mViewModel.getErrorLiveData().getValue()) + .isEqualTo(CAPTURE_CONTENT_FOR_NOTE_FAILED); + assertThat(mViewModel.getResultLiveData().getValue()).isNull(); + } + + @Test + public void saveScreenshot_failsSilently_shouldUpdateErrorWithFailed() { + when(mImageExporter.export(any(Executor.class), any(UUID.class), eq(null), any( + ZonedDateTime.class), any(UserHandle.class))).thenReturn( + Futures.immediateFuture(new ImageExporter.Result())); + + mViewModel.saveScreenshotThenFinish(FAKE_DRAWABLE, FAKE_RECT); + waitForIdleSync(); + + assertThat(mViewModel.getErrorLiveData().getValue()) + .isEqualTo(CAPTURE_CONTENT_FOR_NOTE_FAILED); + assertThat(mViewModel.getResultLiveData().getValue()).isNull(); + } + + @Test + public void saveScreenshot_succeeds_shouldUpdateResultWithUri() { + ImageExporter.Result result = new ImageExporter.Result(); + result.uri = FAKE_URI; + when(mImageExporter.export(any(Executor.class), any(UUID.class), eq(null), any( + ZonedDateTime.class), any(UserHandle.class))).thenReturn( + Futures.immediateFuture(result)); + + mViewModel.saveScreenshotThenFinish(FAKE_DRAWABLE, FAKE_RECT); + waitForIdleSync(); + + assertThat(mViewModel.getErrorLiveData().getValue()).isNull(); + assertThat(mViewModel.getResultLiveData().getValue()).isEqualTo(FAKE_URI); + } +} |