From bf590fe7c9e35d51292f18e2fb55cd1795fe3a98 Mon Sep 17 00:00:00 2001 From: Tyler Lacey Date: Wed, 12 Jan 2022 22:11:23 +0000 Subject: Add GameSession game task focus lifecycle methods. Introduce GameSession#onGameTaskFocusChanged method that is called to inform GameSession implementations abouts when their game task goes into our out of focus. Also introduce a new LifecycleState to the abstract GameSession class to ensure that transitions follow the following rules: - All GameSessions start in the INITIALIZED state - All GameSessions will then transition to the CREATED state [onCreate() is called]. If a GameSession transitions from INITIALIZED directly to DESTOYED, onCreate() and onDestroy() will both be called. - A GameSession in the CREATED state may transition to either the TASK_FOCUSED state [onGameTaskFocusChanged(true) is called] or the DESTROYED state [onDestroy() is called]. If a game task starts in a non-focused state and never comes into focus, the GameSession will never transition to the TASK_FOCUSED or TASK_UNFOCUSED states. - A GameSession in the TASK_FOCUSED state may only transition to the TASK_UNFOCUSED state [onGameTaskFocusChanged(false) is called]. If a GameSession transitions from TASK_FOCUSED to DESTROYED, it will call onGameTaskFocusChanged(false) to complete that part of the lifecycle before transitioning to the DESTROYED state. - A GameSession in the TASK_UNFOCUSED state may transition back to the TASK_FOCUSED state [onGameTaskFocusChanged(false) is called again] or the DESTROYED state [onDestroy() is called]. In this way, all GameSessions are guaranteed to have onGameTaskFocusChanged(true) be called if they are ever in focus before being destroyed (generally as soon as the GameSession is created) and to have onGameTaskFocusChanged(false) be called before they are destroyed. GameSessions are also guarnateed to have onCreate() be called before onDestroy(). Test: Manual e2e testing Bug: 214104366 Bug: 202414447 Bug: 202417255 CTS-Coverage-Bug: 206128693 Change-Id: Id4e1eec50eb891ffb6df74650c27ea3a27a8b9c3 --- core/api/system-current.txt | 1 + core/java/android/service/games/GameSession.java | 150 ++++++++++++++- core/java/android/service/games/IGameSession.aidl | 3 +- .../app/GameServiceProviderInstanceImpl.java | 54 +++++- .../src/android/service/games/GameSessionTest.java | 207 ++++++++++++++++++++- .../app/GameServiceProviderInstanceImplTest.java | 55 +++++- 6 files changed, 456 insertions(+), 14 deletions(-) diff --git a/core/api/system-current.txt b/core/api/system-current.txt index f554e1f83cbd..f2494281bbd9 100644 --- a/core/api/system-current.txt +++ b/core/api/system-current.txt @@ -10962,6 +10962,7 @@ package android.service.games { ctor public GameSession(); method public void onCreate(); method public void onDestroy(); + method public void onGameTaskFocusChanged(boolean); 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); } diff --git a/core/java/android/service/games/GameSession.java b/core/java/android/service/games/GameSession.java index b6fe067cfc71..cb5c19b72bd0 100644 --- a/core/java/android/service/games/GameSession.java +++ b/core/java/android/service/games/GameSession.java @@ -18,6 +18,7 @@ package android.service.games; import android.annotation.Hide; import android.annotation.IntDef; +import android.annotation.MainThread; import android.annotation.NonNull; import android.annotation.SystemApi; import android.content.Context; @@ -25,6 +26,7 @@ import android.content.res.Configuration; import android.graphics.Bitmap; import android.graphics.Rect; import android.os.Handler; +import android.os.Looper; import android.os.RemoteException; import android.util.Slog; import android.view.SurfaceControlViewHost; @@ -47,21 +49,65 @@ import java.util.concurrent.Executor; * which is then returned when a game session is created via * {@link GameSessionService#onNewSession(CreateGameSessionRequest)}. * + * This class exposes various lifecycle methods which are guaranteed to be called in the following + * fashion: + * + * {@link #onCreate()}: Will always be the first lifecycle method to be called, once the game + * session is created. + * + * {@link #onGameTaskFocusChanged(boolean)}: Will be called after {@link #onCreate()} with + * focused=true when the game task first comes into focus (if it does). If the game task is focused + * when the game session is created, this method will be called immediately after + * {@link #onCreate()} with focused=true. After this method is called with focused=true, it will be + * called again with focused=false when the task goes out of focus. If this method is ever called + * with focused=true, it is guaranteed to be called again with focused=false before + * {@link #onDestroy()} is called. If the game task never comes into focus during the session + * lifetime, this method will never be called. + * + * {@link #onDestroy()}: Will always be called after {@link #onCreate()}. If the game task ever + * comes into focus before the game session is destroyed, then this method will be called after one + * or more pairs of calls to {@link #onGameTaskFocusChanged(boolean)}. + * * @hide */ @SystemApi public abstract class GameSession { - private static final String TAG = "GameSession"; + private static final boolean DEBUG = false; final IGameSession mInterface = new IGameSession.Stub() { @Override - public void destroy() { + public void onDestroyed() { Handler.getMain().executeOrSendMessage(PooledLambda.obtainMessage( GameSession::doDestroy, GameSession.this)); } + + @Override + public void onTaskFocusChanged(boolean focused) { + Handler.getMain().executeOrSendMessage(PooledLambda.obtainMessage( + GameSession::moveToState, GameSession.this, + focused ? LifecycleState.TASK_FOCUSED : LifecycleState.TASK_UNFOCUSED)); + } }; + /** + * @hide + */ + @VisibleForTesting + public enum LifecycleState { + // Initial state; may transition to CREATED. + INITIALIZED, + // May transition to TASK_FOCUSED or DESTROYED. + CREATED, + // May transition to TASK_UNFOCUSED. + TASK_FOCUSED, + // May transition to TASK_FOCUSED or DESTROYED. + TASK_UNFOCUSED, + // May not transition once reached. + DESTROYED + } + + private LifecycleState mLifecycleState = LifecycleState.INITIALIZED; private IGameSessionController mGameSessionController; private int mTaskId; private GameSessionRootView mGameSessionRootView; @@ -87,13 +133,93 @@ public abstract class GameSession { @Hide void doCreate() { - onCreate(); + moveToState(LifecycleState.CREATED); } @Hide void doDestroy() { - onDestroy(); mSurfaceControlViewHost.release(); + moveToState(LifecycleState.DESTROYED); + } + + /** + * @hide + */ + @VisibleForTesting + @MainThread + public void moveToState(LifecycleState newLifecycleState) { + if (DEBUG) { + Slog.d(TAG, "moveToState: " + mLifecycleState + " -> " + newLifecycleState); + } + + if (Looper.myLooper() != Looper.getMainLooper()) { + throw new RuntimeException("moveToState should be used only from the main thread"); + } + + if (mLifecycleState == newLifecycleState) { + // Nothing to do. + return; + } + + switch (mLifecycleState) { + case INITIALIZED: + if (newLifecycleState == LifecycleState.CREATED) { + onCreate(); + } else if (newLifecycleState == LifecycleState.DESTROYED) { + onCreate(); + onDestroy(); + } else { + if (DEBUG) { + Slog.d(TAG, "Ignoring moveToState: INITIALIZED -> " + newLifecycleState); + } + return; + } + break; + case CREATED: + if (newLifecycleState == LifecycleState.TASK_FOCUSED) { + onGameTaskFocusChanged(/*focused=*/ true); + } else if (newLifecycleState == LifecycleState.DESTROYED) { + onDestroy(); + } else { + if (DEBUG) { + Slog.d(TAG, "Ignoring moveToState: CREATED -> " + newLifecycleState); + } + return; + } + break; + case TASK_FOCUSED: + if (newLifecycleState == LifecycleState.TASK_UNFOCUSED) { + onGameTaskFocusChanged(/*focused=*/ false); + } else if (newLifecycleState == LifecycleState.DESTROYED) { + onGameTaskFocusChanged(/*focused=*/ false); + onDestroy(); + } else { + if (DEBUG) { + Slog.d(TAG, "Ignoring moveToState: TASK_FOCUSED -> " + newLifecycleState); + } + return; + } + break; + case TASK_UNFOCUSED: + if (newLifecycleState == LifecycleState.TASK_FOCUSED) { + onGameTaskFocusChanged(/*focused=*/ true); + } else if (newLifecycleState == LifecycleState.DESTROYED) { + onDestroy(); + } else { + if (DEBUG) { + Slog.d(TAG, "Ignoring moveToState: TASK_UNFOCUSED -> " + newLifecycleState); + } + return; + } + break; + case DESTROYED: + if (DEBUG) { + Slog.d(TAG, "Ignoring moveToState: DESTROYED -> " + newLifecycleState); + } + return; + } + + mLifecycleState = newLifecycleState; } /** @@ -105,13 +231,27 @@ public abstract class GameSession { } /** - * Finalizer called when the game session is ending. + * Finalizer called when the game session is ending. This method will always be called after a + * call to {@link #onCreate()}. If the game task is ever in focus, this method will be called + * after one or more pairs of calls to {@link #onGameTaskFocusChanged(boolean)}. * * This should be used to perform any cleanup before the game session is destroyed. */ public void onDestroy() { } + /** + * Called when the game task for this session is or unfocused. The initial call to this method + * will always come after a call to {@link #onCreate()} with focused=true (when the game task + * first comes into focus after the session is created, or immediately after the session is + * created if the game task is already focused). + * + * This should be used to perform any setup required when the game task comes into focus or any + * cleanup that is required when the game task goes out of focus. + * + * @param focused True if the game task is focused, false if the game task is unfocused. + */ + public void onGameTaskFocusChanged(boolean focused) {} /** * Sets the task overlay content to an explicit view. This view is placed directly into the game diff --git a/core/java/android/service/games/IGameSession.aidl b/core/java/android/service/games/IGameSession.aidl index b2e9f1d21f6e..71da6302b63d 100644 --- a/core/java/android/service/games/IGameSession.aidl +++ b/core/java/android/service/games/IGameSession.aidl @@ -20,5 +20,6 @@ package android.service.games; * @hide */ oneway interface IGameSession { - void destroy(); + void onDestroyed(); + void onTaskFocusChanged(boolean focused); } diff --git a/services/core/java/com/android/server/app/GameServiceProviderInstanceImpl.java b/services/core/java/com/android/server/app/GameServiceProviderInstanceImpl.java index 8996140256d1..43c9839a04d8 100644 --- a/services/core/java/com/android/server/app/GameServiceProviderInstanceImpl.java +++ b/services/core/java/com/android/server/app/GameServiceProviderInstanceImpl.java @@ -19,6 +19,7 @@ package com.android.server.app; import android.annotation.NonNull; import android.annotation.Nullable; import android.app.ActivityManager.RunningTaskInfo; +import android.app.ActivityTaskManager; import android.app.IActivityTaskManager; import android.app.TaskStackListener; import android.content.ComponentName; @@ -75,6 +76,13 @@ final class GameServiceProviderInstanceImpl implements GameServiceProviderInstan }); } + @Override + public void onTaskFocusChanged(int taskId, boolean focused) { + mBackgroundExecutor.execute(() -> { + GameServiceProviderInstanceImpl.this.onTaskFocusChanged(taskId, focused); + }); + } + // TODO(b/204503192): Limit the lifespan of the game session in the Game Service provider // to only when the associated task is running. Right now it is possible for a task to // move into the background and for all associated processes to die and for the Game Session @@ -212,6 +220,30 @@ final class GameServiceProviderInstanceImpl implements GameServiceProviderInstan } } + private void onTaskFocusChanged(int taskId, boolean focused) { + synchronized (mLock) { + onTaskFocusChangedLocked(taskId, focused); + } + } + + @GuardedBy("mLock") + private void onTaskFocusChangedLocked(int taskId, boolean focused) { + if (DEBUG) { + Slog.d(TAG, "onTaskFocusChangedLocked() id: " + taskId + " focused: " + focused); + } + + final GameSessionRecord gameSessionRecord = mGameSessions.get(taskId); + if (gameSessionRecord == null || gameSessionRecord.getGameSession() == null) { + return; + } + + try { + gameSessionRecord.getGameSession().onTaskFocusChanged(focused); + } catch (RemoteException ex) { + Slog.w(TAG, "Failed to notify session of task focus change: " + gameSessionRecord); + } + } + @GuardedBy("mLock") private void gameTaskStartedLocked(int taskId, @NonNull ComponentName componentName) { if (DEBUG) { @@ -311,6 +343,12 @@ final class GameServiceProviderInstanceImpl implements GameServiceProviderInstan synchronized (mLock) { attachGameSessionLocked(taskId, createGameSessionResult); } + + // The TaskStackListener may have made its task focused call for the + // game session's task before the game session was created, so check if + // the task is already focused so that the game session can be notified. + setGameSessionFocusedIfNecessary(taskId, + createGameSessionResult.getGameSession()); }, mBackgroundExecutor); AndroidFuture unusedPostCreateGameSessionFuture = @@ -327,6 +365,18 @@ final class GameServiceProviderInstanceImpl implements GameServiceProviderInstan }); } + private void setGameSessionFocusedIfNecessary(int taskId, IGameSession gameSession) { + try { + final ActivityTaskManager.RootTaskInfo rootTaskInfo = + mActivityTaskManager.getFocusedRootTaskInfo(); + if (rootTaskInfo != null && rootTaskInfo.taskId == taskId) { + gameSession.onTaskFocusChanged(true); + } + } catch (RemoteException ex) { + Slog.w(TAG, "Failed to set task focused for ID: " + taskId); + } + } + @GuardedBy("mLock") private void attachGameSessionLocked( int taskId, @@ -368,7 +418,7 @@ final class GameServiceProviderInstanceImpl implements GameServiceProviderInstan int taskId, CreateGameSessionResult createGameSessionResult) { try { - createGameSessionResult.getGameSession().destroy(); + createGameSessionResult.getGameSession().onDestroyed(); } catch (RemoteException ex) { Slog.w(TAG, "Failed to destroy session: " + taskId); } @@ -408,7 +458,7 @@ final class GameServiceProviderInstanceImpl implements GameServiceProviderInstan IGameSession gameSession = gameSessionRecord.getGameSession(); if (gameSession != null) { try { - gameSession.destroy(); + gameSession.onDestroyed(); } catch (RemoteException ex) { Slog.w(TAG, "Failed to destroy session: " + gameSessionRecord, ex); } diff --git a/services/tests/mockingservicestests/src/android/service/games/GameSessionTest.java b/services/tests/mockingservicestests/src/android/service/games/GameSessionTest.java index fe6af949e219..fec9b1249d17 100644 --- a/services/tests/mockingservicestests/src/android/service/games/GameSessionTest.java +++ b/services/tests/mockingservicestests/src/android/service/games/GameSessionTest.java @@ -20,6 +20,8 @@ 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 com.google.common.truth.Truth.assertThat; + import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -29,11 +31,12 @@ import static org.mockito.ArgumentMatchers.anyInt; import android.graphics.Bitmap; import android.platform.test.annotations.Presubmit; import android.service.games.GameSession.ScreenshotCallback; +import android.testing.AndroidTestingRunner; +import android.testing.TestableLooper; import android.view.SurfaceControlViewHost; import androidx.test.InstrumentationRegistry; import androidx.test.filters.SmallTest; -import androidx.test.runner.AndroidJUnit4; import com.android.internal.infra.AndroidFuture; @@ -44,15 +47,18 @@ import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.MockitoSession; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; /** * Unit tests for the {@link android.service.games.GameSession}. */ -@RunWith(AndroidJUnit4.class) +@RunWith(AndroidTestingRunner.class) @SmallTest @Presubmit +@TestableLooper.RunWithLooper(setAsMainLooper = true) 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); @@ -61,8 +67,7 @@ public final class GameSessionTest { private IGameSessionController mMockGameSessionController; @Mock SurfaceControlViewHost mSurfaceControlViewHost; - private GameSession mGameSession; - + private LifecycleTrackingGameSession mGameSession; private MockitoSession mMockitoSession; @Before @@ -71,7 +76,7 @@ public final class GameSessionTest { .initMocks(this) .startMocking(); - mGameSession = new GameSession() {}; + mGameSession = new LifecycleTrackingGameSession() {}; mGameSession.attach(mMockGameSessionController, /* taskId= */ 10, InstrumentationRegistry.getContext(), mSurfaceControlViewHost, @@ -191,4 +196,196 @@ public final class GameSessionTest { assertTrue(countDownLatch.await( WAIT_FOR_CALLBACK_TIMEOUT_MS, TimeUnit.MILLISECONDS)); } + + @Test + public void moveState_InitializedToInitialized_noLifecycleCalls() throws Exception { + mGameSession.moveToState(GameSession.LifecycleState.INITIALIZED); + + assertThat(mGameSession.mLifecycleMethodCalls.isEmpty()).isTrue(); + } + + @Test + public void moveState_FullLifecycle_ExpectedLifecycleCalls() throws Exception { + mGameSession.moveToState(GameSession.LifecycleState.CREATED); + mGameSession.moveToState(GameSession.LifecycleState.TASK_FOCUSED); + mGameSession.moveToState(GameSession.LifecycleState.CREATED); + mGameSession.moveToState(GameSession.LifecycleState.DESTROYED); + + assertThat(mGameSession.mLifecycleMethodCalls).containsExactly( + LifecycleTrackingGameSession.LifecycleMethodCall.ON_CREATE, + LifecycleTrackingGameSession.LifecycleMethodCall.ON_GAME_TASK_FOCUSED, + LifecycleTrackingGameSession.LifecycleMethodCall.ON_GAME_TASK_UNFOCUSED, + LifecycleTrackingGameSession.LifecycleMethodCall.ON_DESTROY).inOrder(); + } + + @Test + public void moveState_DestroyedWhenInitialized_ExpectedLifecycleCalls() throws Exception { + mGameSession.moveToState(GameSession.LifecycleState.DESTROYED); + + // ON_CREATE is always called before ON_DESTROY. + assertThat(mGameSession.mLifecycleMethodCalls).containsExactly( + LifecycleTrackingGameSession.LifecycleMethodCall.ON_CREATE, + LifecycleTrackingGameSession.LifecycleMethodCall.ON_DESTROY).inOrder(); + } + + @Test + public void moveState_DestroyedWhenFocused_ExpectedLifecycleCalls() throws Exception { + mGameSession.moveToState(GameSession.LifecycleState.CREATED); + mGameSession.moveToState(GameSession.LifecycleState.TASK_FOCUSED); + mGameSession.moveToState(GameSession.LifecycleState.DESTROYED); + + // The ON_GAME_TASK_UNFOCUSED lifecycle event is implied because the session is destroyed + // while in focus. + assertThat(mGameSession.mLifecycleMethodCalls).containsExactly( + LifecycleTrackingGameSession.LifecycleMethodCall.ON_CREATE, + LifecycleTrackingGameSession.LifecycleMethodCall.ON_GAME_TASK_FOCUSED, + LifecycleTrackingGameSession.LifecycleMethodCall.ON_GAME_TASK_UNFOCUSED, + LifecycleTrackingGameSession.LifecycleMethodCall.ON_DESTROY).inOrder(); + } + + @Test + public void moveState_FocusCycled_ExpectedLifecycleCalls() throws Exception { + mGameSession.moveToState(GameSession.LifecycleState.CREATED); + mGameSession.moveToState(GameSession.LifecycleState.TASK_FOCUSED); + mGameSession.moveToState(GameSession.LifecycleState.TASK_UNFOCUSED); + mGameSession.moveToState(GameSession.LifecycleState.TASK_FOCUSED); + mGameSession.moveToState(GameSession.LifecycleState.TASK_UNFOCUSED); + + // Both cycles from focus and unfocus are captured. + assertThat(mGameSession.mLifecycleMethodCalls).containsExactly( + LifecycleTrackingGameSession.LifecycleMethodCall.ON_CREATE, + LifecycleTrackingGameSession.LifecycleMethodCall.ON_GAME_TASK_FOCUSED, + LifecycleTrackingGameSession.LifecycleMethodCall.ON_GAME_TASK_UNFOCUSED, + LifecycleTrackingGameSession.LifecycleMethodCall.ON_GAME_TASK_FOCUSED, + LifecycleTrackingGameSession.LifecycleMethodCall.ON_GAME_TASK_UNFOCUSED).inOrder(); + } + + @Test + public void moveState_MultipleFocusAndUnfocusCalls_ExpectedLifecycleCalls() throws Exception { + mGameSession.moveToState(GameSession.LifecycleState.CREATED); + mGameSession.moveToState(GameSession.LifecycleState.TASK_FOCUSED); + mGameSession.moveToState(GameSession.LifecycleState.TASK_FOCUSED); + mGameSession.moveToState(GameSession.LifecycleState.TASK_UNFOCUSED); + mGameSession.moveToState(GameSession.LifecycleState.TASK_UNFOCUSED); + + // The second TASK_FOCUSED call and the second TASK_UNFOCUSED call are ignored. + assertThat(mGameSession.mLifecycleMethodCalls).containsExactly( + LifecycleTrackingGameSession.LifecycleMethodCall.ON_CREATE, + LifecycleTrackingGameSession.LifecycleMethodCall.ON_GAME_TASK_FOCUSED, + LifecycleTrackingGameSession.LifecycleMethodCall.ON_GAME_TASK_UNFOCUSED).inOrder(); + } + + @Test + public void moveState_CreatedAfterFocused_ExpectedLifecycleCalls() throws Exception { + mGameSession.moveToState(GameSession.LifecycleState.CREATED); + mGameSession.moveToState(GameSession.LifecycleState.TASK_FOCUSED); + mGameSession.moveToState(GameSession.LifecycleState.CREATED); + + // The second CREATED call is ignored. + assertThat(mGameSession.mLifecycleMethodCalls).containsExactly( + LifecycleTrackingGameSession.LifecycleMethodCall.ON_CREATE, + LifecycleTrackingGameSession.LifecycleMethodCall.ON_GAME_TASK_FOCUSED).inOrder(); + } + + @Test + public void moveState_UnfocusedWithoutFocused_ExpectedLifecycleCalls() throws Exception { + mGameSession.moveToState(GameSession.LifecycleState.CREATED); + mGameSession.moveToState(GameSession.LifecycleState.TASK_UNFOCUSED); + + // The TASK_UNFOCUSED call without an earlier TASK_FOCUSED call is ignored. + assertThat(mGameSession.mLifecycleMethodCalls).containsExactly( + LifecycleTrackingGameSession.LifecycleMethodCall.ON_CREATE).inOrder(); + } + + @Test + public void moveState_NeverFocused_ExpectedLifecycleCalls() throws Exception { + mGameSession.moveToState(GameSession.LifecycleState.CREATED); + mGameSession.moveToState(GameSession.LifecycleState.DESTROYED); + + assertThat(mGameSession.mLifecycleMethodCalls).containsExactly( + LifecycleTrackingGameSession.LifecycleMethodCall.ON_CREATE, + LifecycleTrackingGameSession.LifecycleMethodCall.ON_DESTROY).inOrder(); + } + + @Test + public void moveState_MultipleFocusCalls_ExpectedLifecycleCalls() throws Exception { + mGameSession.moveToState(GameSession.LifecycleState.CREATED); + mGameSession.moveToState(GameSession.LifecycleState.TASK_FOCUSED); + mGameSession.moveToState(GameSession.LifecycleState.TASK_FOCUSED); + mGameSession.moveToState(GameSession.LifecycleState.TASK_FOCUSED); + + // The extra TASK_FOCUSED moves are ignored. + assertThat(mGameSession.mLifecycleMethodCalls).containsExactly( + LifecycleTrackingGameSession.LifecycleMethodCall.ON_CREATE, + LifecycleTrackingGameSession.LifecycleMethodCall.ON_GAME_TASK_FOCUSED).inOrder(); + } + + @Test + public void moveState_MultipleCreateCalls_ExpectedLifecycleCalls() throws Exception { + mGameSession.moveToState(GameSession.LifecycleState.CREATED); + mGameSession.moveToState(GameSession.LifecycleState.CREATED); + mGameSession.moveToState(GameSession.LifecycleState.CREATED); + + // The extra CREATE moves are ignored. + assertThat(mGameSession.mLifecycleMethodCalls).containsExactly( + LifecycleTrackingGameSession.LifecycleMethodCall.ON_CREATE).inOrder(); + } + + @Test + public void moveState_FocusBeforeCreate_ExpectedLifecycleCalls() throws Exception { + mGameSession.moveToState(GameSession.LifecycleState.TASK_FOCUSED); + + // The TASK_FOCUSED move before CREATE is ignored. + assertThat(mGameSession.mLifecycleMethodCalls.isEmpty()).isTrue(); + } + + @Test + public void moveState_UnfocusBeforeCreate_ExpectedLifecycleCalls() throws Exception { + mGameSession.moveToState(GameSession.LifecycleState.TASK_UNFOCUSED); + + // The TASK_UNFOCUSED move before CREATE is ignored. + assertThat(mGameSession.mLifecycleMethodCalls.isEmpty()).isTrue(); + } + + @Test + public void moveState_FocusWhenDestroyed_ExpectedLifecycleCalls() throws Exception { + mGameSession.moveToState(GameSession.LifecycleState.CREATED); + mGameSession.moveToState(GameSession.LifecycleState.DESTROYED); + mGameSession.moveToState(GameSession.LifecycleState.TASK_FOCUSED); + + // The TASK_FOCUSED move after DESTROYED is ignored. + assertThat(mGameSession.mLifecycleMethodCalls).containsExactly( + LifecycleTrackingGameSession.LifecycleMethodCall.ON_CREATE, + LifecycleTrackingGameSession.LifecycleMethodCall.ON_DESTROY).inOrder(); + } + + private static class LifecycleTrackingGameSession extends GameSession { + private enum LifecycleMethodCall { + ON_CREATE, + ON_DESTROY, + ON_GAME_TASK_FOCUSED, + ON_GAME_TASK_UNFOCUSED + } + + final List mLifecycleMethodCalls = new ArrayList<>(); + + @Override + public void onCreate() { + mLifecycleMethodCalls.add(LifecycleMethodCall.ON_CREATE); + } + + @Override + public void onDestroy() { + mLifecycleMethodCalls.add(LifecycleMethodCall.ON_DESTROY); + } + + @Override + public void onGameTaskFocusChanged(boolean focused) { + if (focused) { + mLifecycleMethodCalls.add(LifecycleMethodCall.ON_GAME_TASK_FOCUSED); + } else { + mLifecycleMethodCalls.add(LifecycleMethodCall.ON_GAME_TASK_UNFOCUSED); + } + } + } } 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 aacd015a04e1..bdfa3bfedb55 100644 --- a/services/tests/mockingservicestests/src/com/android/server/app/GameServiceProviderInstanceImplTest.java +++ b/services/tests/mockingservicestests/src/com/android/server/app/GameServiceProviderInstanceImplTest.java @@ -34,6 +34,7 @@ import static org.mockito.Mockito.when; import android.annotation.Nullable; import android.app.ActivityManager.RunningTaskInfo; +import android.app.ActivityTaskManager; import android.app.IActivityTaskManager; import android.app.ITaskStackListener; import android.content.ComponentName; @@ -331,6 +332,7 @@ public final class GameServiceProviderInstanceImplTest { .complete(new CreateGameSessionResult(gameSession10, mockSurfacePackage10)); assertThat(gameSession10.mIsDestroyed).isFalse(); + assertThat(gameSession10.mIsFocused).isFalse(); } @Test @@ -364,6 +366,45 @@ public final class GameServiceProviderInstanceImplTest { verifyNoMoreInteractions(mMockWindowManagerInternal); } + @Test + public void gameTaskFocused_propagatedToGameSession() throws Exception { + mGameServiceProviderInstance.start(); + startTask(10, GAME_A_MAIN_ACTIVITY); + mFakeGameService.requestCreateGameSession(10); + + FakeGameSession gameSession10 = new FakeGameSession(); + SurfacePackage mockSurfacePackage10 = Mockito.mock(SurfacePackage.class); + mFakeGameSessionService.removePendingFutureForTaskId(10) + .complete(new CreateGameSessionResult(gameSession10, mockSurfacePackage10)); + + assertThat(gameSession10.mIsFocused).isFalse(); + + dispatchTaskFocused(10, /*focused=*/ true); + assertThat(gameSession10.mIsFocused).isTrue(); + + dispatchTaskFocused(10, /*focused=*/ false); + assertThat(gameSession10.mIsFocused).isFalse(); + } + + @Test + public void gameTaskAlreadyFocusedWhenGameSessionCreated_propagatedToGameSession() + throws Exception { + ActivityTaskManager.RootTaskInfo gameATaskInfo = new ActivityTaskManager.RootTaskInfo(); + gameATaskInfo.taskId = 10; + when(mMockActivityTaskManager.getFocusedRootTaskInfo()).thenReturn(gameATaskInfo); + + mGameServiceProviderInstance.start(); + startTask(10, GAME_A_MAIN_ACTIVITY); + mFakeGameService.requestCreateGameSession(10); + + FakeGameSession gameSession10 = new FakeGameSession(); + SurfacePackage mockSurfacePackage10 = Mockito.mock(SurfacePackage.class); + mFakeGameSessionService.removePendingFutureForTaskId(10) + .complete(new CreateGameSessionResult(gameSession10, mockSurfacePackage10)); + + assertThat(gameSession10.mIsFocused).isTrue(); + } + @Test public void gameTaskRemoved_whileAwaitingGameSessionAttached_destroysGameSession() throws Exception { @@ -647,6 +688,12 @@ public final class GameServiceProviderInstanceImplTest { }); } + private void dispatchTaskFocused(int taskId, boolean focused) { + dispatchTaskChangeEvent(taskStackListener -> { + taskStackListener.onTaskFocusChanged(taskId, focused); + }); + } + private void dispatchTaskChangeEvent( ThrowingConsumer taskStackListenerConsumer) { for (ITaskStackListener taskStackListener : mTaskStackListeners) { @@ -767,10 +814,16 @@ public final class GameServiceProviderInstanceImplTest { private static class FakeGameSession extends IGameSession.Stub { boolean mIsDestroyed = false; + boolean mIsFocused = false; @Override - public void destroy() { + public void onDestroyed() { mIsDestroyed = true; } + + @Override + public void onTaskFocusChanged(boolean focused) { + mIsFocused = focused; + } } } \ No newline at end of file -- cgit v1.2.3-59-g8ed1b