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