Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit bf590fe7 authored by Tyler Lacey's avatar Tyler Lacey
Browse files

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
parent b5956636
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -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);
  }
+145 −5
Original line number Diff line number Diff line
@@ -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
+2 −1
Original line number Diff line number Diff line
@@ -20,5 +20,6 @@ package android.service.games;
 * @hide
 */
oneway interface IGameSession {
    void destroy();
    void onDestroyed();
    void onTaskFocusChanged(boolean focused);
}
+52 −2
Original line number Diff line number Diff line
@@ -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<Void> 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);
            }
+202 −5
Original line number Diff line number Diff line
@@ -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<LifecycleMethodCall> 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);
            }
        }
    }
}
Loading