/*
* 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.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 com.android.systemui.screenshot.appclips.AppClipsEvent.SCREENSHOT_FOR_NOTE_TRIGGERED;
import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.Intent.CaptureContentForNoteStatusCodes;
import android.content.pm.PackageManager;
import android.content.pm.PackageManager.ApplicationInfoFlags;
import android.content.pm.PackageManager.NameNotFoundException;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Parcel;
import android.os.ResultReceiver;
import android.os.UserHandle;
import android.util.Log;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import com.android.internal.infra.AndroidFuture;
import com.android.internal.infra.ServiceConnector;
import com.android.internal.logging.UiEventLogger;
import com.android.internal.statusbar.IAppClipsService;
import com.android.systemui.R;
import com.android.systemui.broadcast.BroadcastSender;
import com.android.systemui.dagger.qualifiers.Application;
import com.android.systemui.dagger.qualifiers.Background;
import com.android.systemui.dagger.qualifiers.Main;
import com.android.systemui.notetask.NoteTaskController;
import com.android.systemui.notetask.NoteTaskEntryPoint;
import java.util.concurrent.Executor;
import javax.inject.Inject;
/**
* A trampoline activity that is responsible for:
*
* - Performing precondition checks before starting the actual screenshot activity.
*
- Communicating with the screenshot activity and the calling activity.
*
*
* 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();
static final String PERMISSION_SELF = "com.android.systemui.permission.SELF";
static final String EXTRA_SCREENSHOT_URI = TAG + "SCREENSHOT_URI";
static final String ACTION_FINISH_FROM_TRAMPOLINE = TAG + "FINISH_FROM_TRAMPOLINE";
static final String EXTRA_RESULT_RECEIVER = TAG + "RESULT_RECEIVER";
static final String EXTRA_CALLING_PACKAGE_NAME = TAG + "CALLING_PACKAGE_NAME";
private static final ApplicationInfoFlags APPLICATION_INFO_FLAGS = ApplicationInfoFlags.of(0);
private final NoteTaskController mNoteTaskController;
private final PackageManager mPackageManager;
private final UiEventLogger mUiEventLogger;
private final BroadcastSender mBroadcastSender;
@Background
private final Executor mBgExecutor;
@Main
private final Executor mMainExecutor;
private final ResultReceiver mResultReceiver;
private final ServiceConnector mAppClipsServiceConnector;
private UserHandle mUserHandle;
private Intent mKillAppClipsBroadcastIntent;
@Inject
public AppClipsTrampolineActivity(@Application Context context,
NoteTaskController noteTaskController, PackageManager packageManager,
UiEventLogger uiEventLogger, BroadcastSender broadcastSender,
@Background Executor bgExecutor, @Main Executor mainExecutor,
@Main Handler mainHandler) {
mNoteTaskController = noteTaskController;
mPackageManager = packageManager;
mUiEventLogger = uiEventLogger;
mBroadcastSender = broadcastSender;
mBgExecutor = bgExecutor;
mMainExecutor = mainExecutor;
mResultReceiver = createResultReceiver(mainHandler);
mAppClipsServiceConnector = createServiceConnector(context);
}
/** A constructor used only for testing to verify interactions with {@link ServiceConnector}. */
@VisibleForTesting
AppClipsTrampolineActivity(ServiceConnector appClipsServiceConnector,
NoteTaskController noteTaskController, PackageManager packageManager,
UiEventLogger uiEventLogger, BroadcastSender broadcastSender,
@Background Executor bgExecutor, @Main Executor mainExecutor,
@Main Handler mainHandler) {
mAppClipsServiceConnector = appClipsServiceConnector;
mNoteTaskController = noteTaskController;
mPackageManager = packageManager;
mUiEventLogger = uiEventLogger;
mBroadcastSender = broadcastSender;
mBgExecutor = bgExecutor;
mMainExecutor = mainExecutor;
mResultReceiver = createResultReceiver(mainHandler);
}
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (savedInstanceState != null) {
return;
}
mUserHandle = getUser();
mBgExecutor.execute(() -> {
AndroidFuture statusCodeFuture = mAppClipsServiceConnector.postForResult(
service -> service.canLaunchCaptureContentActivityForNoteInternal(getTaskId()));
statusCodeFuture.whenCompleteAsync(this::handleAppClipsStatusCode, mMainExecutor);
});
}
@Override
protected void onDestroy() {
if (isFinishing() && mKillAppClipsBroadcastIntent != null) {
mBroadcastSender.sendBroadcast(mKillAppClipsBroadcastIntent, PERMISSION_SELF);
}
super.onDestroy();
}
private void handleAppClipsStatusCode(@CaptureContentForNoteStatusCodes int statusCode,
Throwable error) {
if (isFinishing()) {
// It's too late, trampoline activity is finishing or already finished. Return early.
return;
}
if (error != null) {
Log.d(TAG, "Error querying app clips service", error);
setErrorResultAndFinish(statusCode);
return;
}
switch (statusCode) {
case CAPTURE_CONTENT_FOR_NOTE_SUCCESS:
launchAppClipsActivity();
break;
case CAPTURE_CONTENT_FOR_NOTE_FAILED:
case CAPTURE_CONTENT_FOR_NOTE_WINDOW_MODE_UNSUPPORTED:
case CAPTURE_CONTENT_FOR_NOTE_BLOCKED_BY_ADMIN:
default:
setErrorResultAndFinish(statusCode);
}
}
private void launchAppClipsActivity() {
ComponentName componentName = ComponentName.unflattenFromString(
getString(R.string.config_screenshotAppClipsActivityComponent));
String callingPackageName = getCallingPackage();
Intent intent = new Intent()
.setComponent(componentName)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(EXTRA_RESULT_RECEIVER, mResultReceiver)
.putExtra(EXTRA_CALLING_PACKAGE_NAME, callingPackageName);
try {
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());
// Log successful triggering of screenshot for notes.
logScreenshotTriggeredUiEvent(callingPackageName);
} catch (ActivityNotFoundException e) {
setErrorResultAndFinish(CAPTURE_CONTENT_FOR_NOTE_FAILED);
}
}
private void setErrorResultAndFinish(int errorCode) {
setResult(RESULT_OK,
new Intent().putExtra(EXTRA_CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE, errorCode));
finish();
}
private void logScreenshotTriggeredUiEvent(@Nullable String callingPackageName) {
int callingPackageUid = 0;
try {
callingPackageUid = mPackageManager.getApplicationInfoAsUser(callingPackageName,
APPLICATION_INFO_FLAGS, mUserHandle.getIdentifier()).uid;
} catch (NameNotFoundException e) {
Log.d(TAG, "Couldn't find notes app UID " + e);
}
mUiEventLogger.log(SCREENSHOT_FOR_NOTE_TRIGGERED, callingPackageUid, callingPackageName);
}
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);
}
// Broadcast no longer required, setting it to null.
mKillAppClipsBroadcastIntent = null;
// Expand the note bubble before returning the result.
mNoteTaskController.showNoteTaskAsUser(NoteTaskEntryPoint.APP_CLIPS, mUserHandle);
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;
}
private ServiceConnector createServiceConnector(
@Application Context context) {
return new ServiceConnector.Impl<>(context, new Intent(context, AppClipsService.class),
Context.BIND_AUTO_CREATE | Context.BIND_WAIVE_PRIORITY | Context.BIND_NOT_VISIBLE,
UserHandle.USER_SYSTEM, IAppClipsService.Stub::asInterface);
}
/** This is a test only API for mocking response from {@link AppClipsActivity}. */
@VisibleForTesting
public ResultReceiver getResultReceiverForTest() {
return mResultReceiver;
}
}