From 197ecb2557ba015c6fc6db8d992f2b4d02c82167 Mon Sep 17 00:00:00 2001 From: Tyler Lacey Date: Wed, 29 Dec 2021 17:06:25 +0000 Subject: [PATCH] Add GameSession#takeScreenshot API The screenshot is taken using a new method added to the WindowManagerService: captureTaskSnapshot. The existing WindowManagerService methods for getting a TaskSnapshot are not suitable because they rely on previously cached snapshots (e.g., taken when the task is put into the background). To access the WindowManagerService functionality from the GameSessionService, an IBinder is passed from the GameServiceProviderInstanceImpl, which is running on the system server side (and thus can call the new method) to the GameSessionService when it is created. The GameSessionService then makes this reference available to each GameSession when the GameSession is created via the new GameSession#attach method. The GameSession can then use the IBinder reference to make an IPC back to the GameServiceProviderInstanceImpl instance which hosts the GameSessionService. This reference is then used by the GameSession to request a screenshot. By using the IBinder in this way, only GameSessions which are created via the system GameService can access the sensitive screenshot functionality implemented by GameServiceProviderInstanceImpl via the new captureTaskSnapshot method. Test: Manual e2e testing Bug: 210119689 Bug: 202414447 Bug: 202417255 CTS-Coverage-Bug: 206128693 Change-Id: If42dc9a5a5b6068db8670666a371117cf5865f20 --- core/api/system-current.txt | 7 + .../service/games/GameScreenshotResult.java | 181 ++++++++++++++++ .../android/service/games/GameSession.java | 127 +++++++++++- .../service/games/GameSessionService.java | 11 +- .../service/games/IGameSessionController.aidl | 26 +++ .../service/games/IGameSessionService.aidl | 2 + ...ameServiceProviderInstanceFactoryImpl.java | 3 + .../app/GameServiceProviderInstanceImpl.java | 44 +++- .../server/wm/WindowManagerService.java | 34 +++ .../service/games/GameSessionTest.java | 194 ++++++++++++++++++ .../GameServiceProviderInstanceImplTest.java | 52 ++++- 11 files changed, 673 insertions(+), 8 deletions(-) create mode 100644 core/java/android/service/games/GameScreenshotResult.java create mode 100644 core/java/android/service/games/IGameSessionController.aidl create mode 100644 services/tests/mockingservicestests/src/android/service/games/GameSessionTest.java diff --git a/core/api/system-current.txt b/core/api/system-current.txt index 465c1f82ead0e..291841d732645 100644 --- a/core/api/system-current.txt +++ b/core/api/system-current.txt @@ -10929,6 +10929,13 @@ package android.service.games { method public void onCreate(); method public void onDestroy(); method public void setTaskOverlayView(@NonNull android.view.View, @NonNull android.view.ViewGroup.LayoutParams); + method public void takeScreenshot(@NonNull java.util.concurrent.Executor, @NonNull android.service.games.GameSession.ScreenshotCallback); + } + + public static interface GameSession.ScreenshotCallback { + method public void onFailure(int); + method public void onSuccess(@NonNull android.graphics.Bitmap); + field public static final int ERROR_TAKE_SCREENSHOT_INTERNAL_ERROR = 0; // 0x0 } public abstract class GameSessionService extends android.app.Service { diff --git a/core/java/android/service/games/GameScreenshotResult.java b/core/java/android/service/games/GameScreenshotResult.java new file mode 100644 index 0000000000000..ae76e08c7971d --- /dev/null +++ b/core/java/android/service/games/GameScreenshotResult.java @@ -0,0 +1,181 @@ +/* + * Copyright (C) 2022 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 android.service.games; + +import android.annotation.IntDef; +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.graphics.Bitmap; +import android.os.Parcel; +import android.os.Parcelable; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Objects; + +/** + * Result object for calls to {@link IGameSessionController#takeScreenshot}. + * + * It includes a status (see {@link #getStatus}) and, if the status is + * {@link #GAME_SCREENSHOT_SUCCESS} an {@link android.graphics.Bitmap} result (see {@link + * #getBitmap}). + * + * @hide + */ +public final class GameScreenshotResult implements Parcelable { + + /** + * The status of a call to {@link IGameSessionController#takeScreenshot} will be represented by + * one of these values. + * + * @hide + */ + @IntDef(flag = false, prefix = {"GAME_SCREENSHOT_"}, value = { + GAME_SCREENSHOT_SUCCESS, // 0 + GAME_SCREENSHOT_ERROR_INTERNAL_ERROR, // 1 + }) + @Retention(RetentionPolicy.SOURCE) + public @interface GameScreenshotStatus { + } + + /** + * Indicates that the result of a call to {@link IGameSessionController#takeScreenshot} was + * successful and an {@link android.graphics.Bitmap} result should be available by calling + * {@link #getBitmap}. + * + * @hide + */ + public static final int GAME_SCREENSHOT_SUCCESS = 0; + + /** + * Indicates that the result of a call to {@link IGameSessionController#takeScreenshot} failed + * due to an internal error. + * + * This error may occur if the device is not in a suitable state for a screenshot to be taken + * (e.g., the screen is off) or if the game task is not in a suitable state for a screenshot + * to be taken (e.g., the task is not visible). To make sure that the device and game are + * in a suitable state, the caller can monitor the lifecycle methods for the {@link + * GameSession} to make sure that the game task is focused. If the conditions are met, then the + * caller may try again immediately. + * + * @hide + */ + public static final int GAME_SCREENSHOT_ERROR_INTERNAL_ERROR = 1; + + @NonNull + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + @Override + public GameScreenshotResult createFromParcel(Parcel source) { + return new GameScreenshotResult( + source.readInt(), + source.readParcelable(null, Bitmap.class)); + } + + @Override + public GameScreenshotResult[] newArray(int size) { + return new GameScreenshotResult[0]; + } + }; + + @GameScreenshotStatus + private final int mStatus; + + @Nullable + private final Bitmap mBitmap; + + /** + * Creates a successful {@link GameScreenshotResult} with the provided bitmap. + */ + public static GameScreenshotResult createSuccessResult(@NonNull Bitmap bitmap) { + return new GameScreenshotResult(GAME_SCREENSHOT_SUCCESS, bitmap); + } + + /** + * Creates a failed {@link GameScreenshotResult} with an + * {@link #GAME_SCREENSHOT_ERROR_INTERNAL_ERROR} status. + */ + public static GameScreenshotResult createInternalErrorResult() { + return new GameScreenshotResult(GAME_SCREENSHOT_ERROR_INTERNAL_ERROR, null); + } + + private GameScreenshotResult(@GameScreenshotStatus int status, @Nullable Bitmap bitmap) { + this.mStatus = status; + this.mBitmap = bitmap; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + dest.writeInt(mStatus); + dest.writeParcelable(mBitmap, flags); + } + + @GameScreenshotStatus + public int getStatus() { + return mStatus; + } + + /** + * Gets the {@link Bitmap} result from a successful screenshot attempt. + * + * @return The bitmap. + * @throws IllegalStateException if this method is called when {@link #getStatus} does not + * return {@link #GAME_SCREENSHOT_SUCCESS}. + */ + @NonNull + public Bitmap getBitmap() { + if (mBitmap == null) { + throw new IllegalStateException("Bitmap not available for failed screenshot result"); + } + return mBitmap; + } + + @Override + public String toString() { + return "GameScreenshotResult{" + + "mStatus=" + + mStatus + + ", has bitmap='" + + mBitmap != null ? "yes" : "no" + + "\'}"; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (!(o instanceof GameScreenshotResult)) { + return false; + } + + GameScreenshotResult that = (GameScreenshotResult) o; + return mStatus == that.mStatus + && Objects.equals(mBitmap, that.mBitmap); + } + + @Override + public int hashCode() { + return Objects.hash(mStatus, mBitmap); + } +} diff --git a/core/java/android/service/games/GameSession.java b/core/java/android/service/games/GameSession.java index 1a5331f105257..b6fe067cfc71e 100644 --- a/core/java/android/service/games/GameSession.java +++ b/core/java/android/service/games/GameSession.java @@ -17,19 +17,29 @@ package android.service.games; import android.annotation.Hide; +import android.annotation.IntDef; import android.annotation.NonNull; import android.annotation.SystemApi; import android.content.Context; import android.content.res.Configuration; +import android.graphics.Bitmap; import android.graphics.Rect; import android.os.Handler; +import android.os.RemoteException; +import android.util.Slog; import android.view.SurfaceControlViewHost; import android.view.View; import android.view.ViewGroup; import android.widget.FrameLayout; +import com.android.internal.annotations.VisibleForTesting; +import com.android.internal.infra.AndroidFuture; import com.android.internal.util.function.pooled.PooledLambda; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.concurrent.Executor; + /** * An active game session, providing a facility for the implementation to interact with the game. * @@ -42,6 +52,8 @@ import com.android.internal.util.function.pooled.PooledLambda; @SystemApi public abstract class GameSession { + private static final String TAG = "GameSession"; + final IGameSession mInterface = new IGameSession.Stub() { @Override public void destroy() { @@ -50,15 +62,24 @@ public abstract class GameSession { } }; + private IGameSessionController mGameSessionController; + private int mTaskId; private GameSessionRootView mGameSessionRootView; private SurfaceControlViewHost mSurfaceControlViewHost; - @Hide - void attach( + /** + * @hide + */ + @VisibleForTesting + public void attach( + IGameSessionController gameSessionController, + int taskId, @NonNull Context context, @NonNull SurfaceControlViewHost surfaceControlViewHost, int widthPx, int heightPx) { + mGameSessionController = gameSessionController; + mTaskId = taskId; mSurfaceControlViewHost = surfaceControlViewHost; mGameSessionRootView = new GameSessionRootView(context, mSurfaceControlViewHost); surfaceControlViewHost.setView(mGameSessionRootView, widthPx, heightPx); @@ -133,4 +154,106 @@ public abstract class GameSession { mSurfaceControlViewHost.relayout(bounds.width(), bounds.height()); } } + + /** + * Interface for returning screenshot outcome from calls to {@link #takeScreenshot}. + */ + public interface ScreenshotCallback { + + /** + * The status of a failed screenshot attempt provided by {@link #onFailure}. + * + * @hide + */ + @IntDef(flag = false, prefix = {"ERROR_TAKE_SCREENSHOT_"}, value = { + ERROR_TAKE_SCREENSHOT_INTERNAL_ERROR, // 0 + }) + @Retention(RetentionPolicy.SOURCE) + @interface ScreenshotFailureStatus { + } + + /** + * An error code indicating that an internal error occurred when attempting to take a + * screenshot of the game task. If this code is returned, the caller should verify that the + * conditions for taking a screenshot are met (device screen is on and the game task is + * visible). To do so, the caller can monitor the lifecycle methods for this session to + * make sure that the game task is focused. If the conditions are met, then the caller may + * try again immediately. + */ + int ERROR_TAKE_SCREENSHOT_INTERNAL_ERROR = 0; + + /** + * Called when taking the screenshot failed. + * @param statusCode Indicates the reason for failure. + */ + void onFailure(@ScreenshotFailureStatus int statusCode); + + /** + * Called when taking the screenshot succeeded. + * @param bitmap The screenshot. + */ + void onSuccess(@NonNull Bitmap bitmap); + } + + /** + * Takes a screenshot of the associated game. For this call to succeed, the device screen + * must be turned on and the game task must be visible. + * + * If the callback is called with {@link ScreenshotCallback#onSuccess}, the provided {@link + * Bitmap} may be used. + * + * If the callback is called with {@link ScreenshotCallback#onFailure}, the provided status + * code should be checked. + * + * If the status code is {@link ScreenshotCallback#ERROR_TAKE_SCREENSHOT_INTERNAL_ERROR}, + * then the caller should verify that the conditions for calling this method are met (device + * screen is on and the game task is visible). To do so, the caller can monitor the lifecycle + * methods for this session to make sure that the game task is focused. If the conditions are + * met, then the caller may try again immediately. + * + * @param executor Executor on which to run the callback. + * @param callback The callback invoked when taking screenshot has succeeded + * or failed. + * @throws IllegalStateException if this method is called prior to {@link #onCreate}. + */ + public void takeScreenshot(@NonNull Executor executor, @NonNull ScreenshotCallback callback) { + if (mGameSessionController == null) { + throw new IllegalStateException("Can not call before onCreate()"); + } + + AndroidFuture takeScreenshotResult = + new AndroidFuture().whenCompleteAsync((result, error) -> { + handleScreenshotResult(callback, result, error); + }, executor); + + try { + mGameSessionController.takeScreenshot(mTaskId, takeScreenshotResult); + } catch (RemoteException ex) { + takeScreenshotResult.completeExceptionally(ex); + } + } + + private void handleScreenshotResult( + @NonNull ScreenshotCallback callback, + @NonNull GameScreenshotResult result, + @NonNull Throwable error) { + if (error != null) { + Slog.w(TAG, error.getMessage(), error.getCause()); + callback.onFailure( + ScreenshotCallback.ERROR_TAKE_SCREENSHOT_INTERNAL_ERROR); + return; + } + + @GameScreenshotResult.GameScreenshotStatus int status = result.getStatus(); + switch (status) { + case GameScreenshotResult.GAME_SCREENSHOT_SUCCESS: + callback.onSuccess(result.getBitmap()); + break; + case GameScreenshotResult.GAME_SCREENSHOT_ERROR_INTERNAL_ERROR: + Slog.w(TAG, "Error taking screenshot"); + callback.onFailure( + ScreenshotCallback.ERROR_TAKE_SCREENSHOT_INTERNAL_ERROR); + break; + } + } } diff --git a/core/java/android/service/games/GameSessionService.java b/core/java/android/service/games/GameSessionService.java index 195a0f233307b..df5bad5c53b25 100644 --- a/core/java/android/service/games/GameSessionService.java +++ b/core/java/android/service/games/GameSessionService.java @@ -52,8 +52,6 @@ import java.util.Objects; */ @SystemApi public abstract class GameSessionService extends Service { - private static final String TAG = "GameSessionService"; - /** * The {@link Intent} action used when binding to the service. * To be supported, the service must require the @@ -67,11 +65,13 @@ public abstract class GameSessionService extends Service { private final IGameSessionService mInterface = new IGameSessionService.Stub() { @Override public void create( + IGameSessionController gameSessionController, CreateGameSessionRequest createGameSessionRequest, GameSessionViewHostConfiguration gameSessionViewHostConfiguration, AndroidFuture gameSessionFuture) { Handler.getMain().post(PooledLambda.obtainRunnable( GameSessionService::doCreate, GameSessionService.this, + gameSessionController, createGameSessionRequest, gameSessionViewHostConfiguration, gameSessionFuture)); @@ -101,6 +101,7 @@ public abstract class GameSessionService extends Service { } private void doCreate( + IGameSessionController gameSessionController, CreateGameSessionRequest createGameSessionRequest, GameSessionViewHostConfiguration gameSessionViewHostConfiguration, AndroidFuture createGameSessionResultFuture) { @@ -119,7 +120,10 @@ public abstract class GameSessionService extends Service { SurfaceControlViewHost surfaceControlViewHost = new SurfaceControlViewHost(this, display, hostToken); - gameSession.attach(this, + gameSession.attach( + gameSessionController, + createGameSessionRequest.getTaskId(), + this, surfaceControlViewHost, gameSessionViewHostConfiguration.mWidthPx, gameSessionViewHostConfiguration.mHeightPx); @@ -130,7 +134,6 @@ public abstract class GameSessionService extends Service { createGameSessionResultFuture.complete(createGameSessionResult); - gameSession.doCreate(); } diff --git a/core/java/android/service/games/IGameSessionController.aidl b/core/java/android/service/games/IGameSessionController.aidl new file mode 100644 index 0000000000000..fe1d3629918e8 --- /dev/null +++ b/core/java/android/service/games/IGameSessionController.aidl @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2022 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 android.service.games; + +import com.android.internal.infra.AndroidFuture; + +/** + * @hide + */ +oneway interface IGameSessionController { + void takeScreenshot(int taskId, in AndroidFuture gameScreenshotResultFuture); +} \ No newline at end of file diff --git a/core/java/android/service/games/IGameSessionService.aidl b/core/java/android/service/games/IGameSessionService.aidl index dcbcbc16a3744..37cde561f549d 100644 --- a/core/java/android/service/games/IGameSessionService.aidl +++ b/core/java/android/service/games/IGameSessionService.aidl @@ -16,6 +16,7 @@ package android.service.games; +import android.service.games.IGameSessionController; import android.service.games.IGameSession; import android.service.games.CreateGameSessionRequest; import android.service.games.GameSessionViewHostConfiguration; @@ -28,6 +29,7 @@ import com.android.internal.infra.AndroidFuture; */ oneway interface IGameSessionService { void create( + in IGameSessionController gameSessionController, in CreateGameSessionRequest createGameSessionRequest, in GameSessionViewHostConfiguration gameSessionViewHostConfiguration, in AndroidFuture /* T=CreateGameSessionResult */ createGameSessionResultFuture); diff --git a/services/core/java/com/android/server/app/GameServiceProviderInstanceFactoryImpl.java b/services/core/java/com/android/server/app/GameServiceProviderInstanceFactoryImpl.java index 48e66b6c6aeb5..b4c43f6f1b932 100644 --- a/services/core/java/com/android/server/app/GameServiceProviderInstanceFactoryImpl.java +++ b/services/core/java/com/android/server/app/GameServiceProviderInstanceFactoryImpl.java @@ -20,6 +20,7 @@ import android.annotation.NonNull; import android.app.ActivityTaskManager; import android.content.Context; import android.content.Intent; +import android.os.ServiceManager; import android.service.games.GameService; import android.service.games.GameSessionService; import android.service.games.IGameService; @@ -29,6 +30,7 @@ import com.android.internal.infra.ServiceConnector; import com.android.internal.os.BackgroundThread; import com.android.server.LocalServices; import com.android.server.wm.WindowManagerInternal; +import com.android.server.wm.WindowManagerService; final class GameServiceProviderInstanceFactoryImpl implements GameServiceProviderInstanceFactory { private final Context mContext; @@ -46,6 +48,7 @@ final class GameServiceProviderInstanceFactoryImpl implements GameServiceProvide BackgroundThread.getExecutor(), new GameClassifierImpl(mContext.getPackageManager()), ActivityTaskManager.getService(), + (WindowManagerService) ServiceManager.getService(Context.WINDOW_SERVICE), LocalServices.getService(WindowManagerInternal.class), new GameServiceConnector(mContext, gameServiceProviderConfiguration), new GameSessionServiceConnector(mContext, gameServiceProviderConfiguration)); diff --git a/services/core/java/com/android/server/app/GameServiceProviderInstanceImpl.java b/services/core/java/com/android/server/app/GameServiceProviderInstanceImpl.java index 31eb8c1c9429b..8996140256d1e 100644 --- a/services/core/java/com/android/server/app/GameServiceProviderInstanceImpl.java +++ b/services/core/java/com/android/server/app/GameServiceProviderInstanceImpl.java @@ -22,24 +22,29 @@ import android.app.ActivityManager.RunningTaskInfo; import android.app.IActivityTaskManager; import android.app.TaskStackListener; import android.content.ComponentName; +import android.graphics.Bitmap; import android.graphics.Rect; import android.os.RemoteException; import android.os.UserHandle; import android.service.games.CreateGameSessionRequest; import android.service.games.CreateGameSessionResult; +import android.service.games.GameScreenshotResult; import android.service.games.GameSessionViewHostConfiguration; import android.service.games.GameStartedEvent; import android.service.games.IGameService; import android.service.games.IGameServiceController; import android.service.games.IGameSession; +import android.service.games.IGameSessionController; import android.service.games.IGameSessionService; import android.util.Slog; import android.view.SurfaceControlViewHost.SurfacePackage; import com.android.internal.annotations.GuardedBy; +import com.android.internal.annotations.VisibleForTesting; import com.android.internal.infra.AndroidFuture; import com.android.internal.infra.ServiceConnector; import com.android.server.wm.WindowManagerInternal; +import com.android.server.wm.WindowManagerService; import java.util.List; import java.util.concurrent.ConcurrentHashMap; @@ -87,11 +92,24 @@ final class GameServiceProviderInstanceImpl implements GameServiceProviderInstan } }; + private final IGameSessionController mGameSessionController = + new IGameSessionController.Stub() { + @Override + public void takeScreenshot(int taskId, + @NonNull AndroidFuture gameScreenshotResultFuture) { + mBackgroundExecutor.execute(() -> { + GameServiceProviderInstanceImpl.this.takeScreenshot(taskId, + gameScreenshotResultFuture); + }); + } + }; + private final Object mLock = new Object(); private final UserHandle mUserHandle; private final Executor mBackgroundExecutor; private final GameClassifier mGameClassifier; private final IActivityTaskManager mActivityTaskManager; + private final WindowManagerService mWindowManagerService; private final WindowManagerInternal mWindowManagerInternal; private final ServiceConnector mGameServiceConnector; private final ServiceConnector mGameSessionServiceConnector; @@ -107,6 +125,7 @@ final class GameServiceProviderInstanceImpl implements GameServiceProviderInstan @NonNull Executor backgroundExecutor, @NonNull GameClassifier gameClassifier, @NonNull IActivityTaskManager activityTaskManager, + @NonNull WindowManagerService windowManagerService, @NonNull WindowManagerInternal windowManagerInternal, @NonNull ServiceConnector gameServiceConnector, @NonNull ServiceConnector gameSessionServiceConnector) { @@ -114,6 +133,7 @@ final class GameServiceProviderInstanceImpl implements GameServiceProviderInstan mBackgroundExecutor = backgroundExecutor; mGameClassifier = gameClassifier; mActivityTaskManager = activityTaskManager; + mWindowManagerService = windowManagerService; mWindowManagerInternal = windowManagerInternal; mGameServiceConnector = gameServiceConnector; mGameSessionServiceConnector = gameSessionServiceConnector; @@ -300,6 +320,7 @@ final class GameServiceProviderInstanceImpl implements GameServiceProviderInstan taskId, existingGameSessionRecord.getComponentName().getPackageName()); gameService.create( + mGameSessionController, createGameSessionRequest, gameSessionViewHostConfiguration, createGameSessionResultFuture); @@ -404,7 +425,6 @@ final class GameServiceProviderInstanceImpl implements GameServiceProviderInstan } } - @Nullable private GameSessionViewHostConfiguration createViewHostConfigurationForTask(int taskId) { RunningTaskInfo runningTaskInfo = getRunningTaskInfoForTask(taskId); @@ -440,4 +460,26 @@ final class GameServiceProviderInstanceImpl implements GameServiceProviderInstan return null; } + + @VisibleForTesting + void takeScreenshot(int taskId, @NonNull AndroidFuture callback) { + synchronized (mLock) { + boolean isTaskAssociatedWithGameSession = mGameSessions.containsKey(taskId); + if (!isTaskAssociatedWithGameSession) { + Slog.w(TAG, "No game session found for id: " + taskId); + callback.complete(GameScreenshotResult.createInternalErrorResult()); + return; + } + } + + mBackgroundExecutor.execute(() -> { + final Bitmap bitmap = mWindowManagerService.captureTaskBitmap(taskId); + if (bitmap == null) { + Slog.w(TAG, "Could not get bitmap for id: " + taskId); + callback.complete(GameScreenshotResult.createInternalErrorResult()); + } else { + callback.complete(GameScreenshotResult.createSuccessResult(bitmap)); + } + }); + } } diff --git a/services/core/java/com/android/server/wm/WindowManagerService.java b/services/core/java/com/android/server/wm/WindowManagerService.java index 2f0ef4a8ee1b4..1b47602163169 100644 --- a/services/core/java/com/android/server/wm/WindowManagerService.java +++ b/services/core/java/com/android/server/wm/WindowManagerService.java @@ -3837,6 +3837,40 @@ public class WindowManagerService extends IWindowManager.Stub isLowResolution); } + /** + * Generates and returns an up-to-date {@link Bitmap} for the specified taskId. The returned + * bitmap will be full size and will not include any secure content. + * + * @param taskId The task ID of the task for which a snapshot is requested. + * @return The Bitmap, or null if no task with the specified ID can be found or the bitmap could + * not be generated. + */ + @Nullable public Bitmap captureTaskBitmap(int taskId) { + if (mTaskSnapshotController.shouldDisableSnapshots()) { + return null; + } + + synchronized (mGlobalLock) { + final Task task = mRoot.anyTaskForId(taskId); + if (task == null) { + return null; + } + + task.getBounds(mTmpRect); + final SurfaceControl sc = task.getSurfaceControl(); + final SurfaceControl.ScreenshotHardwareBuffer buffer = SurfaceControl.captureLayers( + new SurfaceControl.LayerCaptureArgs.Builder(sc) + .setSourceCrop(mTmpRect) + .build()); + if (buffer == null) { + Slog.w(TAG, "Could not get screenshot buffer for taskId: " + taskId); + return null; + } + + return buffer.asBitmap(); + } + } + /** * In case a task write/delete operation was lost because the system crashed, this makes sure to * clean up the directory to remove obsolete files. diff --git a/services/tests/mockingservicestests/src/android/service/games/GameSessionTest.java b/services/tests/mockingservicestests/src/android/service/games/GameSessionTest.java new file mode 100644 index 0000000000000..fe6af949e2193 --- /dev/null +++ b/services/tests/mockingservicestests/src/android/service/games/GameSessionTest.java @@ -0,0 +1,194 @@ +/* + * Copyright (C) 2022 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 android.service.games; + +import static com.android.dx.mockito.inline.extended.ExtendedMockito.doAnswer; +import static com.android.dx.mockito.inline.extended.ExtendedMockito.mockitoSession; +import static com.android.internal.util.ConcurrentUtils.DIRECT_EXECUTOR; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; + +import android.graphics.Bitmap; +import android.platform.test.annotations.Presubmit; +import android.service.games.GameSession.ScreenshotCallback; +import android.view.SurfaceControlViewHost; + +import androidx.test.InstrumentationRegistry; +import androidx.test.filters.SmallTest; +import androidx.test.runner.AndroidJUnit4; + +import com.android.internal.infra.AndroidFuture; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoSession; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * Unit tests for the {@link android.service.games.GameSession}. + */ +@RunWith(AndroidJUnit4.class) +@SmallTest +@Presubmit +public final class GameSessionTest { + private static final long WAIT_FOR_CALLBACK_TIMEOUT_MS = TimeUnit.SECONDS.toMillis(1); + private static final Bitmap TEST_BITMAP = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888); + + @Mock + private IGameSessionController mMockGameSessionController; + @Mock + SurfaceControlViewHost mSurfaceControlViewHost; + private GameSession mGameSession; + + private MockitoSession mMockitoSession; + + @Before + public void setUp() { + mMockitoSession = mockitoSession() + .initMocks(this) + .startMocking(); + + mGameSession = new GameSession() {}; + mGameSession.attach(mMockGameSessionController, /* taskId= */ 10, + InstrumentationRegistry.getContext(), + mSurfaceControlViewHost, + /* widthPx= */ 0, /* heightPx= */0); + } + + @After + public void tearDown() { + mMockitoSession.finishMocking(); + } + + @Test + public void takeScreenshot_attachNotCalled_throwsIllegalStateException() throws Exception { + GameSession gameSession = new GameSession() {}; + + try { + gameSession.takeScreenshot(DIRECT_EXECUTOR, + new ScreenshotCallback() { + @Override + public void onFailure(int statusCode) { + fail(); + } + + @Override + public void onSuccess(Bitmap bitmap) { + fail(); + } + }); + fail(); + } catch (IllegalStateException expected) { + + } + } + + @Test + public void takeScreenshot_gameManagerException_returnsInternalError() throws Exception { + doAnswer(invocation -> { + AndroidFuture result = invocation.getArgument(1); + result.completeExceptionally(new Exception()); + return null; + }).when(mMockGameSessionController).takeScreenshot(anyInt(), any()); + + CountDownLatch countDownLatch = new CountDownLatch(1); + + mGameSession.takeScreenshot(DIRECT_EXECUTOR, + new ScreenshotCallback() { + @Override + public void onFailure(int statusCode) { + assertEquals(ScreenshotCallback.ERROR_TAKE_SCREENSHOT_INTERNAL_ERROR, + statusCode); + countDownLatch.countDown(); + } + + @Override + public void onSuccess(Bitmap bitmap) { + fail(); + } + }); + + assertTrue(countDownLatch.await( + WAIT_FOR_CALLBACK_TIMEOUT_MS, TimeUnit.MILLISECONDS)); + } + + @Test + public void takeScreenshot_gameManagerError_returnsInternalError() throws Exception { + doAnswer(invocation -> { + AndroidFuture result = invocation.getArgument(1); + result.complete(GameScreenshotResult.createInternalErrorResult()); + return null; + }).when(mMockGameSessionController).takeScreenshot(anyInt(), any()); + + CountDownLatch countDownLatch = new CountDownLatch(1); + + mGameSession.takeScreenshot(DIRECT_EXECUTOR, + new ScreenshotCallback() { + @Override + public void onFailure(int statusCode) { + assertEquals(ScreenshotCallback.ERROR_TAKE_SCREENSHOT_INTERNAL_ERROR, + statusCode); + countDownLatch.countDown(); + } + + @Override + public void onSuccess(Bitmap bitmap) { + fail(); + } + }); + + assertTrue(countDownLatch.await( + WAIT_FOR_CALLBACK_TIMEOUT_MS, TimeUnit.MILLISECONDS)); + } + + @Test + public void takeScreenshot_gameManagerSuccess_returnsBitmap() throws Exception { + doAnswer(invocation -> { + AndroidFuture result = invocation.getArgument(1); + result.complete(GameScreenshotResult.createSuccessResult(TEST_BITMAP)); + return null; + }).when(mMockGameSessionController).takeScreenshot(anyInt(), any()); + + CountDownLatch countDownLatch = new CountDownLatch(1); + + mGameSession.takeScreenshot(DIRECT_EXECUTOR, + new ScreenshotCallback() { + @Override + public void onFailure(int statusCode) { + fail(); + } + + @Override + public void onSuccess(Bitmap bitmap) { + assertEquals(TEST_BITMAP, bitmap); + countDownLatch.countDown(); + } + }); + + assertTrue(countDownLatch.await( + WAIT_FOR_CALLBACK_TIMEOUT_MS, TimeUnit.MILLISECONDS)); + } +} diff --git a/services/tests/mockingservicestests/src/com/android/server/app/GameServiceProviderInstanceImplTest.java b/services/tests/mockingservicestests/src/com/android/server/app/GameServiceProviderInstanceImplTest.java index 167090693a101..aacd015a04e1c 100644 --- a/services/tests/mockingservicestests/src/com/android/server/app/GameServiceProviderInstanceImplTest.java +++ b/services/tests/mockingservicestests/src/com/android/server/app/GameServiceProviderInstanceImplTest.java @@ -23,6 +23,7 @@ import static com.android.server.app.GameServiceProviderInstanceImplTest.FakeGam import static com.google.common.collect.Iterables.getOnlyElement; import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyInt; @@ -37,17 +38,20 @@ import android.app.IActivityTaskManager; import android.app.ITaskStackListener; import android.content.ComponentName; import android.content.pm.PackageManager; +import android.graphics.Bitmap; import android.graphics.Rect; import android.os.RemoteException; import android.os.UserHandle; import android.platform.test.annotations.Presubmit; import android.service.games.CreateGameSessionRequest; import android.service.games.CreateGameSessionResult; +import android.service.games.GameScreenshotResult; import android.service.games.GameSessionViewHostConfiguration; import android.service.games.GameStartedEvent; import android.service.games.IGameService; import android.service.games.IGameServiceController; import android.service.games.IGameSession; +import android.service.games.IGameSessionController; import android.service.games.IGameSessionService; import android.view.SurfaceControlViewHost.SurfacePackage; @@ -59,6 +63,7 @@ import com.android.internal.util.ConcurrentUtils; import com.android.internal.util.FunctionalUtils.ThrowingConsumer; import com.android.internal.util.Preconditions; import com.android.server.wm.WindowManagerInternal; +import com.android.server.wm.WindowManagerService; import org.junit.After; import org.junit.Before; @@ -93,11 +98,15 @@ public final class GameServiceProviderInstanceImplTest { private static final ComponentName GAME_A_MAIN_ACTIVITY = new ComponentName(GAME_A_PACKAGE, "com.package.game.a.MainActivity"); + private static final Bitmap TEST_BITMAP = Bitmap.createBitmap(32, 32, Bitmap.Config.ARGB_8888); + private MockitoSession mMockingSession; private GameServiceProviderInstance mGameServiceProviderInstance; @Mock private IActivityTaskManager mMockActivityTaskManager; @Mock + private WindowManagerService mMockWindowManagerService; + @Mock private WindowManagerInternal mMockWindowManagerInternal; private FakeGameClassifier mFakeGameClassifier; private FakeGameService mFakeGameService; @@ -142,6 +151,7 @@ public final class GameServiceProviderInstanceImplTest { ConcurrentUtils.DIRECT_EXECUTOR, mFakeGameClassifier, mMockActivityTaskManager, + mMockWindowManagerService, mMockWindowManagerInternal, mFakeGameServiceConnector, mFakeGameSessionServiceConnector); @@ -574,6 +584,41 @@ public final class GameServiceProviderInstanceImplTest { assertThat(mFakeGameSessionServiceConnector.getIsConnected()).isFalse(); } + @Test + public void takeScreenshot_failureNoBitmapCaptured() throws Exception { + mGameServiceProviderInstance.start(); + startTask(10, GAME_A_MAIN_ACTIVITY); + mFakeGameService.requestCreateGameSession(10); + + IGameSessionController gameSessionController = getOnlyElement( + mFakeGameSessionService.getCapturedCreateInvocations()).mGameSessionController; + AndroidFuture resultFuture = new AndroidFuture<>(); + gameSessionController.takeScreenshot(10, resultFuture); + + GameScreenshotResult result = resultFuture.get(); + assertEquals(GameScreenshotResult.GAME_SCREENSHOT_ERROR_INTERNAL_ERROR, + result.getStatus()); + verify(mMockWindowManagerService).captureTaskBitmap(10); + } + + @Test + public void takeScreenshot_success() throws Exception { + when(mMockWindowManagerService.captureTaskBitmap(10)).thenReturn(TEST_BITMAP); + + mGameServiceProviderInstance.start(); + startTask(10, GAME_A_MAIN_ACTIVITY); + mFakeGameService.requestCreateGameSession(10); + + IGameSessionController gameSessionController = getOnlyElement( + mFakeGameSessionService.getCapturedCreateInvocations()).mGameSessionController; + AndroidFuture resultFuture = new AndroidFuture<>(); + gameSessionController.takeScreenshot(10, resultFuture); + + GameScreenshotResult result = resultFuture.get(); + assertEquals(GameScreenshotResult.GAME_SCREENSHOT_SUCCESS, result.getStatus()); + assertEquals(TEST_BITMAP, result.getBitmap()); + } + private void startTask(int taskId, ComponentName componentName) { RunningTaskInfo runningTaskInfo = new RunningTaskInfo(); runningTaskInfo.taskId = taskId; @@ -677,12 +722,15 @@ public final class GameServiceProviderInstanceImplTest { new HashMap<>(); public static final class CapturedCreateInvocation { + private final IGameSessionController mGameSessionController; private final CreateGameSessionRequest mCreateGameSessionRequest; private final GameSessionViewHostConfiguration mGameSessionViewHostConfiguration; CapturedCreateInvocation( + IGameSessionController gameSessionController, CreateGameSessionRequest createGameSessionRequest, GameSessionViewHostConfiguration gameSessionViewHostConfiguration) { + mGameSessionController = gameSessionController; mCreateGameSessionRequest = createGameSessionRequest; mGameSessionViewHostConfiguration = gameSessionViewHostConfiguration; } @@ -698,12 +746,14 @@ public final class GameServiceProviderInstanceImplTest { @Override public void create( + IGameSessionController gameSessionController, CreateGameSessionRequest createGameSessionRequest, GameSessionViewHostConfiguration gameSessionViewHostConfiguration, AndroidFuture createGameSessionResultFuture) { mCapturedCreateInvocations.add( new CapturedCreateInvocation( + gameSessionController, createGameSessionRequest, gameSessionViewHostConfiguration)); @@ -723,4 +773,4 @@ public final class GameServiceProviderInstanceImplTest { mIsDestroyed = true; } } -} +} \ No newline at end of file