Introduce a skeleton PlatformAnimationLib-core lib for origin transition.

See go/origin-transition-foundation-lib-impl for the design.
This change creates a new PlatformAnimationLib-core lib which is written
in pure java so that it can be depended by sdk library to support origin
tramsitions.

The main class is OriginTransitionSession, which is a session class
maintaining states for launch/return origin transitions. Clients need
to supply an intent and IRemoteTransition interfaces, and the session
object is responsible for plugging the transition into shell transitions
and calling the corresponding IRemoteTransition to play the animation.

Flag: com.google.android.clockwork.systemui.flags.transitions_enable_origin_transitions_backend
Bug: 347060315
Test: manual test. See a demo in b/347060315#comment6
      atest PlatformAnimationLibCoreTests
Change-Id: I84cbac5959ed83b52434a86acf3155e48583a930
diff --git a/packages/SystemUI/animation/lib/Android.bp b/packages/SystemUI/animation/lib/Android.bp
index 4324d463..d9230ec 100644
--- a/packages/SystemUI/animation/lib/Android.bp
+++ b/packages/SystemUI/animation/lib/Android.bp
@@ -33,6 +33,20 @@
     ],
 }
 
+// This is the core animation library written in java and can be depended by java sdk libraries.
+// Please don't introduce kotlin code in this target since kotlin is incompatible with sdk
+// libraries.
+java_library {
+    name: "PlatformAnimationLib-core",
+    srcs: [
+        "src/com/android/systemui/animation/*.java",
+        ":PlatformAnimationLib-aidl",
+    ],
+    static_libs: [
+        "WindowManager-Shell-shared",
+    ],
+}
+
 filegroup {
     name: "PlatformAnimationLib-aidl",
     srcs: [
diff --git a/packages/SystemUI/animation/lib/src/com/android/systemui/animation/OriginTransitionSession.java b/packages/SystemUI/animation/lib/src/com/android/systemui/animation/OriginTransitionSession.java
new file mode 100644
index 0000000..64bedd3
--- /dev/null
+++ b/packages/SystemUI/animation/lib/src/com/android/systemui/animation/OriginTransitionSession.java
@@ -0,0 +1,281 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.animation;
+
+import android.annotation.IntDef;
+import android.annotation.Nullable;
+import android.app.ActivityOptions;
+import android.app.ActivityOptions.LaunchCookie;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build;
+import android.os.RemoteException;
+import android.util.Log;
+import android.window.IRemoteTransition;
+import android.window.RemoteTransition;
+
+import com.android.systemui.animation.shared.IOriginTransitions;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+
+/**
+ * A session object that holds origin transition states for starting an activity from an on-screen
+ * UI component and smoothly transitioning back from the activity to the same UI component.
+ */
+public class OriginTransitionSession {
+    private static final String TAG = "OriginTransitionSession";
+    static final boolean DEBUG = Build.IS_USERDEBUG || Log.isLoggable(TAG, Log.DEBUG);
+
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(value = {NOT_STARTED, STARTED, CANCELLED})
+    private @interface State {}
+
+    @State private static final int NOT_STARTED = 0;
+    @State private static final int STARTED = 1;
+    @State private static final int CANCELLED = 5;
+
+    private final String mName;
+    @Nullable private final IOriginTransitions mOriginTransitions;
+    private final Predicate<RemoteTransition> mIntentStarter;
+    @Nullable private final IRemoteTransition mEntryTransition;
+    @Nullable private final IRemoteTransition mExitTransition;
+    private final AtomicInteger mState = new AtomicInteger(NOT_STARTED);
+
+    @Nullable private RemoteTransition mOriginTransition;
+
+    private OriginTransitionSession(
+            String name,
+            @Nullable IOriginTransitions originTransitions,
+            Predicate<RemoteTransition> intentStarter,
+            @Nullable IRemoteTransition entryTransition,
+            @Nullable IRemoteTransition exitTransition) {
+        mName = name;
+        mOriginTransitions = originTransitions;
+        mIntentStarter = intentStarter;
+        mEntryTransition = entryTransition;
+        mExitTransition = exitTransition;
+        if (hasExitTransition() && !hasEntryTransition()) {
+            throw new IllegalArgumentException(
+                    "Entry transition must be supplied if you want to play an exit transition!");
+        }
+    }
+
+    /**
+     * Launch the target intent with the supplied entry transition. After this method, the entry
+     * transition is expected to receive callbacks. The exit transition will be registered and
+     * triggered when the system detects a return from the launched activity to the launching
+     * activity.
+     */
+    public boolean start() {
+        if (!mState.compareAndSet(NOT_STARTED, STARTED)) {
+            logE("start: illegal state - " + stateToString(mState.get()));
+            return false;
+        }
+
+        RemoteTransition remoteTransition = null;
+        if (hasEntryTransition() && hasExitTransition()) {
+            logD("start: starting with entry and exit transition.");
+            try {
+                remoteTransition =
+                        mOriginTransition =
+                                mOriginTransitions.makeOriginTransition(
+                                        new RemoteTransition(mEntryTransition, mName + "-entry"),
+                                        new RemoteTransition(mExitTransition, mName + "-exit"));
+            } catch (RemoteException e) {
+                logE("Unable to create origin transition!", e);
+            }
+        } else if (hasEntryTransition()) {
+            logD("start: starting with entry transition.");
+            remoteTransition = new RemoteTransition(mEntryTransition, mName + "-entry");
+
+        } else {
+            logD("start: starting without transition.");
+        }
+        if (mIntentStarter.test(remoteTransition)) {
+            return true;
+        } else {
+            // Animation is cancelled by intent starter.
+            logD("start: cancelled by intent starter!");
+            cancel();
+            return false;
+        }
+    }
+
+    /**
+     * Cancel the current transition and the registered exit transition if it exists. After this
+     * method, this session object can no longer be used. Clients need to create a new session
+     * object if they want to launch another intent with origin transition.
+     */
+    public void cancel() {
+        final int lastState = mState.getAndSet(CANCELLED);
+        if (lastState == CANCELLED || lastState == NOT_STARTED) {
+            return;
+        }
+        logD("cancel: cancelled transition. Last state: " + stateToString(lastState));
+        if (mOriginTransition != null) {
+            try {
+                mOriginTransitions.cancelOriginTransition(mOriginTransition);
+                mOriginTransition = null;
+            } catch (RemoteException e) {
+                logE("Unable to cancel origin transition!", e);
+            }
+        }
+    }
+
+    private boolean hasEntryTransition() {
+        return mEntryTransition != null;
+    }
+
+    private boolean hasExitTransition() {
+        return mOriginTransitions != null && mExitTransition != null;
+    }
+
+    private void logD(String msg) {
+        if (DEBUG) {
+            Log.d(TAG, "Session[" + mName + "] - " + msg);
+        }
+    }
+
+    private void logE(String msg) {
+        Log.e(TAG, "Session[" + mName + "] - " + msg);
+    }
+
+    private void logE(String msg, Throwable e) {
+        Log.e(TAG, "Session[" + mName + "] - " + msg, e);
+    }
+
+    private static String stateToString(@State int state) {
+        switch (state) {
+            case NOT_STARTED:
+                return "NOT_STARTED";
+            case STARTED:
+                return "STARTED";
+            case CANCELLED:
+                return "CANCELLED";
+            default:
+                return "UNKNOWN(" + state + ")";
+        }
+    }
+
+    /** A builder to build a {@link OriginTransitionSession}. */
+    public static class Builder {
+        private final Context mContext;
+        @Nullable private final IOriginTransitions mOriginTransitions;
+        @Nullable private Supplier<IRemoteTransition> mEntryTransitionSupplier;
+        @Nullable private Supplier<IRemoteTransition> mExitTransitionSupplier;
+        private String mName;
+        @Nullable private Predicate<RemoteTransition> mIntentStarter;
+
+        /** Create a builder that only supports entry transition. */
+        public Builder(Context context) {
+            this(context, /* originTransitions= */ null);
+        }
+
+        /** Create a builder that supports both entry and return transition. */
+        public Builder(Context context, @Nullable IOriginTransitions originTransitions) {
+            mContext = context;
+            mOriginTransitions = originTransitions;
+            mName = context.getPackageName();
+        }
+
+        /** Specify a name that is used in logging. */
+        public Builder withName(String name) {
+            mName = name;
+            return this;
+        }
+
+        /** Specify an intent that will be launched when the session started. */
+        public Builder withIntent(Intent intent) {
+            return withIntentStarter(
+                    transition -> {
+                        mContext.startActivity(
+                                intent, createDefaultActivityOptions(transition).toBundle());
+                        return true;
+                    });
+        }
+
+        /** Specify a pending intent that will be launched when the session started. */
+        public Builder withPendingIntent(PendingIntent pendingIntent) {
+            return withIntentStarter(
+                    transition -> {
+                        try {
+                            pendingIntent.send(createDefaultActivityOptions(transition).toBundle());
+                            return true;
+                        } catch (PendingIntent.CanceledException e) {
+                            Log.e(TAG, "Failed to launch pending intent!", e);
+                            return false;
+                        }
+                    });
+        }
+
+        private static ActivityOptions createDefaultActivityOptions(
+                @Nullable RemoteTransition transition) {
+            ActivityOptions options =
+                    transition == null
+                            ? ActivityOptions.makeBasic()
+                            : ActivityOptions.makeRemoteTransition(transition);
+            LaunchCookie cookie = new LaunchCookie();
+            options.setLaunchCookie(cookie);
+            return options;
+        }
+
+        /**
+         * Specify an intent starter function that will be called to start an activity. The function
+         * accepts an optional {@link RemoteTransition} object which can be used to create an {@link
+         * ActivityOptions} for the activity launch. The function can also return a {@code false}
+         * result to cancel the session.
+         *
+         * <p>Note: it's encouraged to use {@link #withIntent(Intent)} or {@link
+         * #withPendingIntent(PendingIntent)} instead of this method. Use it only if the default
+         * activity launch code doesn't satisfy your requirement.
+         */
+        public Builder withIntentStarter(Predicate<RemoteTransition> intentStarter) {
+            mIntentStarter = intentStarter;
+            return this;
+        }
+
+        /** Add an entry transition to the builder. */
+        public Builder withEntryTransition(IRemoteTransition transition) {
+            mEntryTransitionSupplier = () -> transition;
+            return this;
+        }
+
+        /** Add an exit transition to the builder. */
+        public Builder withExitTransition(IRemoteTransition transition) {
+            mExitTransitionSupplier = () -> transition;
+            return this;
+        }
+
+        /** Build an {@link OriginTransitionSession}. */
+        public OriginTransitionSession build() {
+            if (mIntentStarter == null) {
+                throw new IllegalArgumentException("No intent, pending intent, or intent starter!");
+            }
+            return new OriginTransitionSession(
+                    mName,
+                    mOriginTransitions,
+                    mIntentStarter,
+                    mEntryTransitionSupplier == null ? null : mEntryTransitionSupplier.get(),
+                    mExitTransitionSupplier == null ? null : mExitTransitionSupplier.get());
+        }
+    }
+}
diff --git a/packages/SystemUI/animation/lib/tests/Android.bp b/packages/SystemUI/animation/lib/tests/Android.bp
new file mode 100644
index 0000000..c1a3e84
--- /dev/null
+++ b/packages/SystemUI/animation/lib/tests/Android.bp
@@ -0,0 +1,47 @@
+// Copyright (C) 2024 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 {
+    default_team: "trendy_team_system_ui_please_use_a_more_specific_subteam_if_possible_",
+    default_applicable_licenses: ["frameworks_base_packages_SystemUI_license"],
+}
+
+android_test {
+    name: "PlatformAnimationLibCoreTests",
+
+    defaults: [
+        "platform_app_defaults",
+    ],
+    srcs: [
+        "src/**/*.java",
+    ],
+
+    dxflags: ["--multi-dex"],
+    platform_apis: true,
+    test_suites: ["device-tests"],
+    static_libs: [
+        "PlatformAnimationLib-core",
+        "platform-test-rules",
+        "testables",
+    ],
+    compile_multilib: "both",
+    libs: [
+        "android.test.runner.stubs.system",
+        "android.test.base.stubs.system",
+    ],
+
+    certificate: "platform",
+
+    manifest: "AndroidManifest.xml",
+}
diff --git a/packages/SystemUI/animation/lib/tests/AndroidManifest.xml b/packages/SystemUI/animation/lib/tests/AndroidManifest.xml
new file mode 100644
index 0000000..1788abf
--- /dev/null
+++ b/packages/SystemUI/animation/lib/tests/AndroidManifest.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2024 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.
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:sharedUserId="android.uid.system"
+    package="com.android.systemui.animation.core.tests" >
+
+     <application android:debuggable="true" android:testOnly="true">
+         <uses-library android:name="android.test.runner" />
+     </application>
+
+    <instrumentation android:name="android.testing.TestableInstrumentation"
+        android:targetPackage="com.android.systemui.animation.core.tests"
+        android:label="Tests for PlatformAnimationLib-core" />
+</manifest>
diff --git a/packages/SystemUI/animation/lib/tests/AndroidTest.xml b/packages/SystemUI/animation/lib/tests/AndroidTest.xml
new file mode 100644
index 0000000..0f37d7a
--- /dev/null
+++ b/packages/SystemUI/animation/lib/tests/AndroidTest.xml
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2024 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.
+  -->
+<configuration description="Runs Tests for PlatformAnimationLib-core.">
+    <target_preparer class="com.android.tradefed.targetprep.TestAppInstallSetup">
+        <option name="test-file-name" value="PlatformAnimationLibCoreTests.apk" />
+        <option name="install-arg" value="-t" />
+    </target_preparer>
+
+    <!-- Among other reasons, root access is needed for screen recording artifacts. -->
+    <target_preparer class="com.android.tradefed.targetprep.RootTargetPreparer">
+        <option name="force-root" value="true" />
+    </target_preparer>
+
+    <test class="com.android.tradefed.testtype.AndroidJUnitTest" >
+        <option name="package" value="com.android.systemui.animation.core.tests" />
+        <option name="runner" value="android.testing.TestableInstrumentation" />
+        <option name="test-filter-dir" value="/data/data/com.android.systemui.animation.core.tests" />
+        <option name="hidden-api-checks" value="false"/>
+    </test>
+
+    <metrics_collector class="com.android.tradefed.device.metric.FilePullerLogCollector">
+        <option name="directory-keys" value="/data/user/0/com.android.systemui.animation.core.tests/files" />
+        <option name="collect-on-run-ended-only" value="false" />
+    </metrics_collector>
+</configuration>
diff --git a/packages/SystemUI/animation/lib/tests/src/com/android/systemui/animation/OriginTransitionSessionTest.java b/packages/SystemUI/animation/lib/tests/src/com/android/systemui/animation/OriginTransitionSessionTest.java
new file mode 100644
index 0000000..287e53b
--- /dev/null
+++ b/packages/SystemUI/animation/lib/tests/src/com/android/systemui/animation/OriginTransitionSessionTest.java
@@ -0,0 +1,406 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.animation;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.annotation.Nullable;
+import android.app.Instrumentation;
+import android.content.ComponentName;
+import android.content.Context;
+import android.os.Binder;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.util.ArrayMap;
+import android.view.SurfaceControl;
+import android.view.WindowManager;
+import android.window.IRemoteTransition;
+import android.window.IRemoteTransitionFinishedCallback;
+import android.window.RemoteTransition;
+import android.window.TransitionInfo;
+import android.window.WindowAnimationState;
+import android.window.WindowContainerTransaction;
+
+import androidx.test.filters.SmallTest;
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import com.android.systemui.animation.shared.IOriginTransitions;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.Map;
+import java.util.function.Predicate;
+
+/** Unit tests for {@link OriginTransitionSession}. */
+@SmallTest
+@RunWith(JUnit4.class)
+public final class OriginTransitionSessionTest {
+    private static final ComponentName TEST_ACTIVITY_1 = new ComponentName("test", "Activity1");
+    private static final ComponentName TEST_ACTIVITY_2 = new ComponentName("test", "Activity2");
+    private static final ComponentName TEST_ACTIVITY_3 = new ComponentName("test", "Activity3");
+
+    private FakeIOriginTransitions mIOriginTransitions;
+    private Instrumentation mInstrumentation;
+    private FakeIntentStarter mIntentStarter;
+    private Context mContext;
+
+    @Before
+    public void setUp() {
+        mInstrumentation = InstrumentationRegistry.getInstrumentation();
+        mContext = mInstrumentation.getTargetContext();
+        mIOriginTransitions = new FakeIOriginTransitions();
+        mIntentStarter = new FakeIntentStarter(TEST_ACTIVITY_1, TEST_ACTIVITY_2);
+    }
+
+    @Test
+    public void sessionStart_withEntryAndExitTransition_transitionsPlayed() {
+        FakeRemoteTransition entry = new FakeRemoteTransition();
+        FakeRemoteTransition exit = new FakeRemoteTransition();
+        OriginTransitionSession session =
+                new OriginTransitionSession.Builder(mContext, mIOriginTransitions)
+                        .withIntentStarter(mIntentStarter)
+                        .withEntryTransition(entry)
+                        .withExitTransition(exit)
+                        .build();
+
+        session.start();
+
+        assertThat(mIntentStarter.hasLaunched()).isTrue();
+        assertThat(entry.started()).isTrue();
+
+        runReturnTransition(mIntentStarter);
+
+        assertThat(exit.started()).isTrue();
+    }
+
+    @Test
+    public void sessionStart_withEntryTransition_transitionPlayed() {
+        FakeRemoteTransition entry = new FakeRemoteTransition();
+        OriginTransitionSession session =
+                new OriginTransitionSession.Builder(mContext, mIOriginTransitions)
+                        .withIntentStarter(mIntentStarter)
+                        .withEntryTransition(entry)
+                        .build();
+
+        session.start();
+
+        assertThat(mIntentStarter.hasLaunched()).isTrue();
+        assertThat(entry.started()).isTrue();
+        assertThat(mIOriginTransitions.hasPendingReturnTransitions()).isFalse();
+    }
+
+    @Test
+    public void sessionStart_withoutTransition_launchedIntent() {
+        OriginTransitionSession session =
+                new OriginTransitionSession.Builder(mContext, mIOriginTransitions)
+                        .withIntentStarter(mIntentStarter)
+                        .build();
+
+        session.start();
+
+        assertThat(mIntentStarter.hasLaunched()).isTrue();
+        assertThat(mIOriginTransitions.hasPendingReturnTransitions()).isFalse();
+    }
+
+    @Test
+    public void sessionStart_cancelledByIntentStarter_transitionNotPlayed() {
+        FakeRemoteTransition entry = new FakeRemoteTransition();
+        FakeRemoteTransition exit = new FakeRemoteTransition();
+        mIntentStarter =
+                new FakeIntentStarter(TEST_ACTIVITY_1, TEST_ACTIVITY_2, /* result= */ false);
+        OriginTransitionSession session =
+                new OriginTransitionSession.Builder(mContext, mIOriginTransitions)
+                        .withIntentStarter(mIntentStarter)
+                        .withEntryTransition(entry)
+                        .withExitTransition(exit)
+                        .build();
+
+        session.start();
+
+        assertThat(mIntentStarter.hasLaunched()).isFalse();
+        assertThat(entry.started()).isFalse();
+        assertThat(exit.started()).isFalse();
+        assertThat(mIOriginTransitions.hasPendingReturnTransitions()).isFalse();
+    }
+
+    @Test
+    public void sessionStart_alreadyStarted_noOp() {
+        FakeRemoteTransition entry = new FakeRemoteTransition();
+        FakeRemoteTransition exit = new FakeRemoteTransition();
+        OriginTransitionSession session =
+                new OriginTransitionSession.Builder(mContext, mIOriginTransitions)
+                        .withIntentStarter(mIntentStarter)
+                        .withEntryTransition(entry)
+                        .withExitTransition(exit)
+                        .build();
+        session.start();
+        entry.reset();
+        mIntentStarter.reset();
+
+        session.start();
+
+        assertThat(mIntentStarter.hasLaunched()).isFalse();
+        assertThat(entry.started()).isFalse();
+    }
+
+    @Test
+    public void sessionStart_alreadyCancelled_noOp() {
+        FakeRemoteTransition entry = new FakeRemoteTransition();
+        FakeRemoteTransition exit = new FakeRemoteTransition();
+        OriginTransitionSession session =
+                new OriginTransitionSession.Builder(mContext, mIOriginTransitions)
+                        .withIntentStarter(mIntentStarter)
+                        .withEntryTransition(entry)
+                        .withExitTransition(exit)
+                        .build();
+        session.cancel();
+
+        session.start();
+
+        assertThat(mIntentStarter.hasLaunched()).isFalse();
+        assertThat(entry.started()).isFalse();
+        assertThat(mIOriginTransitions.hasPendingReturnTransitions()).isFalse();
+    }
+
+    @Test
+    public void sessionCancelled_returnTransitionNotPlayed() {
+        FakeRemoteTransition entry = new FakeRemoteTransition();
+        FakeRemoteTransition exit = new FakeRemoteTransition();
+        OriginTransitionSession session =
+                new OriginTransitionSession.Builder(mContext, mIOriginTransitions)
+                        .withIntentStarter(mIntentStarter)
+                        .withEntryTransition(entry)
+                        .withExitTransition(exit)
+                        .build();
+
+        session.start();
+        session.cancel();
+
+        assertThat(mIOriginTransitions.hasPendingReturnTransitions()).isFalse();
+    }
+
+    @Test
+    public void multipleSessionsStarted_allTransitionsPlayed() {
+        FakeRemoteTransition entry1 = new FakeRemoteTransition();
+        FakeRemoteTransition exit1 = new FakeRemoteTransition();
+        FakeIntentStarter starter1 = mIntentStarter;
+        OriginTransitionSession session1 =
+                new OriginTransitionSession.Builder(mContext, mIOriginTransitions)
+                        .withIntentStarter(starter1)
+                        .withEntryTransition(entry1)
+                        .withExitTransition(exit1)
+                        .build();
+        FakeRemoteTransition entry2 = new FakeRemoteTransition();
+        FakeRemoteTransition exit2 = new FakeRemoteTransition();
+        FakeIntentStarter starter2 = new FakeIntentStarter(TEST_ACTIVITY_2, TEST_ACTIVITY_3);
+        OriginTransitionSession session2 =
+                new OriginTransitionSession.Builder(mContext, mIOriginTransitions)
+                        .withIntentStarter(starter2)
+                        .withEntryTransition(entry2)
+                        .withExitTransition(exit2)
+                        .build();
+
+        session1.start();
+
+        assertThat(starter1.hasLaunched()).isTrue();
+        assertThat(entry1.started()).isTrue();
+
+        session2.start();
+
+        assertThat(starter2.hasLaunched()).isTrue();
+        assertThat(entry2.started()).isTrue();
+
+        runReturnTransition(starter2);
+
+        assertThat(exit2.started()).isTrue();
+
+        runReturnTransition(starter1);
+
+        assertThat(exit1.started()).isTrue();
+    }
+
+    private void runReturnTransition(FakeIntentStarter intentStarter) {
+        TransitionInfo info =
+                buildTransitionInfo(intentStarter.getToActivity(), intentStarter.getFromActivity());
+        mIOriginTransitions.runReturnTransition(intentStarter.getTransitionOfLastLaunch(), info);
+    }
+
+    private static TransitionInfo buildTransitionInfo(ComponentName from, ComponentName to) {
+        TransitionInfo info = new TransitionInfo(WindowManager.TRANSIT_OPEN, /* flags= */ 0);
+        TransitionInfo.Change c1 =
+                new TransitionInfo.Change(/* container= */ null, /* leash= */ null);
+        c1.setMode(WindowManager.TRANSIT_OPEN);
+        c1.setActivityComponent(to);
+        TransitionInfo.Change c2 =
+                new TransitionInfo.Change(/* container= */ null, /* leash= */ null);
+        c2.setMode(WindowManager.TRANSIT_CLOSE);
+        c2.setActivityComponent(from);
+        info.addChange(c2);
+        info.addChange(c1);
+        return info;
+    }
+
+    private static class FakeIntentStarter implements Predicate<RemoteTransition> {
+        private final ComponentName mFromActivity;
+        private final ComponentName mToActivity;
+        private final boolean mResult;
+
+        @Nullable private RemoteTransition mTransition;
+        private boolean mLaunched;
+
+        FakeIntentStarter(ComponentName from, ComponentName to) {
+            this(from, to, /* result= */ true);
+        }
+
+        FakeIntentStarter(ComponentName from, ComponentName to, boolean result) {
+            mFromActivity = from;
+            mToActivity = to;
+            mResult = result;
+        }
+
+        @Override
+        public boolean test(RemoteTransition transition) {
+            if (mResult) {
+                mLaunched = true;
+                mTransition = transition;
+                if (mTransition != null) {
+                    TransitionInfo info = buildTransitionInfo(mFromActivity, mToActivity);
+                    try {
+                        transition
+                                .getRemoteTransition()
+                                .startAnimation(
+                                        new Binder(),
+                                        info,
+                                        new SurfaceControl.Transaction(),
+                                        new FakeFinishCallback());
+                    } catch (RemoteException e) {
+
+                    }
+                }
+            }
+            return mResult;
+        }
+
+        @Nullable
+        public RemoteTransition getTransitionOfLastLaunch() {
+            return mTransition;
+        }
+
+        public ComponentName getFromActivity() {
+            return mFromActivity;
+        }
+
+        public ComponentName getToActivity() {
+            return mToActivity;
+        }
+
+        public boolean hasLaunched() {
+            return mLaunched;
+        }
+
+        public void reset() {
+            mTransition = null;
+            mLaunched = false;
+        }
+    }
+
+    private static class FakeIOriginTransitions extends IOriginTransitions.Stub {
+        private final Map<RemoteTransition, RemoteTransition> mRecords = new ArrayMap<>();
+
+        @Override
+        public RemoteTransition makeOriginTransition(
+                RemoteTransition launchTransition, RemoteTransition returnTransition) {
+            mRecords.put(launchTransition, returnTransition);
+            return launchTransition;
+        }
+
+        @Override
+        public void cancelOriginTransition(RemoteTransition originTransition) {
+            mRecords.remove(originTransition);
+        }
+
+        public void runReturnTransition(RemoteTransition originTransition, TransitionInfo info) {
+            RemoteTransition transition = mRecords.remove(originTransition);
+            try {
+                transition
+                        .getRemoteTransition()
+                        .startAnimation(
+                                new Binder(),
+                                info,
+                                new SurfaceControl.Transaction(),
+                                new FakeFinishCallback());
+            } catch (RemoteException e) {
+
+            }
+        }
+
+        public boolean hasPendingReturnTransitions() {
+            return !mRecords.isEmpty();
+        }
+    }
+
+    private static class FakeFinishCallback extends IRemoteTransitionFinishedCallback.Stub {
+        @Override
+        public void onTransitionFinished(
+                WindowContainerTransaction wct, SurfaceControl.Transaction sct) {}
+    }
+
+    private static class FakeRemoteTransition extends IRemoteTransition.Stub {
+        private boolean mStarted;
+
+        @Override
+        public void startAnimation(
+                IBinder token,
+                TransitionInfo info,
+                SurfaceControl.Transaction t,
+                IRemoteTransitionFinishedCallback finishCallback)
+                throws RemoteException {
+            mStarted = true;
+            finishCallback.onTransitionFinished(null, null);
+        }
+
+        @Override
+        public void mergeAnimation(
+                IBinder transition,
+                TransitionInfo info,
+                SurfaceControl.Transaction t,
+                IBinder mergeTarget,
+                IRemoteTransitionFinishedCallback finishCallback) {}
+
+        @Override
+        public void takeOverAnimation(
+                IBinder transition,
+                TransitionInfo info,
+                SurfaceControl.Transaction t,
+                IRemoteTransitionFinishedCallback finishCallback,
+                WindowAnimationState[] states) {}
+
+        @Override
+        public void onTransitionConsumed(IBinder transition, boolean aborted) {}
+
+        public boolean started() {
+            return mStarted;
+        }
+
+        public void reset() {
+            mStarted = false;
+        }
+    }
+}