builder = ImmutableList.builder();
+
+ int index = 0;
+ int replyCode = REPLY_CONTINUE;
+
+ while (replyCode != REPLY_END_OF_LIST) {
+ Parcel data = Parcel.obtain();
+ Parcel reply = Parcel.obtain();
+ try {
+ data.writeInt(index);
+ try {
+ binder.transact(FIRST_CALL_TRANSACTION, data, reply, /* flags= */ 0);
+ } catch (RemoteException e) {
+ throw new RuntimeException(e);
+ }
+ while ((replyCode = reply.readInt()) == REPLY_CONTINUE) {
+ builder.add(checkNotNull(reply.readBundle()));
+ index++;
+ }
+ } finally {
+ reply.recycle();
+ data.recycle();
+ }
+ }
+
+ return builder.build();
+ }
+}
diff --git a/library/common/src/main/java/com/google/android/exoplayer2/Timeline.java b/library/common/src/main/java/com/google/android/exoplayer2/Timeline.java
index 5b7a4fbca5..4fced8fff8 100644
--- a/library/common/src/main/java/com/google/android/exoplayer2/Timeline.java
+++ b/library/common/src/main/java/com/google/android/exoplayer2/Timeline.java
@@ -19,16 +19,21 @@ import static com.google.android.exoplayer2.util.Assertions.checkState;
import android.net.Uri;
import android.os.Bundle;
+import android.os.IBinder;
import android.os.SystemClock;
import android.util.Pair;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
+import androidx.core.app.BundleCompat;
import com.google.android.exoplayer2.source.ads.AdPlaybackState;
import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.Util;
+import com.google.common.collect.ImmutableList;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.List;
/**
* A flexible representation of the structure of media. A timeline is able to represent the
@@ -124,7 +129,7 @@ import java.lang.annotation.RetentionPolicy;
* This case includes mid-roll ad groups, which are defined as part of the timeline's single
* period. The period can be queried for information about the ad groups and the ads they contain.
*/
-public abstract class Timeline {
+public abstract class Timeline implements Bundleable {
/**
* Holds information about a window in a {@link Timeline}. A window usually corresponds to one
@@ -1245,4 +1250,148 @@ public abstract class Timeline {
}
return result;
}
+
+ // Bundleable implementation.
+
+ @Documented
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({FIELD_WINDOWS, FIELD_PERIODS})
+ private @interface FieldNumber {}
+
+ private static final int FIELD_WINDOWS = 0;
+ private static final int FIELD_PERIODS = 1;
+
+ /**
+ * {@inheritDoc}
+ *
+ *
The {@link #getWindow(int, Window)} windows} and {@link #getPeriod(int, Period) periods} of
+ * an instance restored by {@link #CREATOR} may have missing fields as described in {@link
+ * Window#toBundle()} and {@link Period#toBundle()}.
+ */
+ @Override
+ public final Bundle toBundle() {
+ List windowBundles = new ArrayList<>();
+ int windowCount = getWindowCount();
+ for (int i = 0; i < windowCount; i++) {
+ Window window = new Window();
+ getWindow(i, window, /* defaultPositionProjectionUs= */ 0);
+ windowBundles.add(window.toBundle());
+ }
+
+ List periodBundles = new ArrayList<>();
+ int periodCount = getPeriodCount();
+ for (int i = 0; i < periodCount; i++) {
+ Period period = new Period();
+ getPeriod(i, period, /* setIds= */ false);
+ periodBundles.add(period.toBundle());
+ }
+
+ Bundle bundle = new Bundle();
+ BundleCompat.putBinder(
+ bundle, keyForField(FIELD_WINDOWS), new BundleListRetriever(windowBundles));
+ BundleCompat.putBinder(
+ bundle, keyForField(FIELD_PERIODS), new BundleListRetriever(periodBundles));
+ return bundle;
+ }
+
+ /**
+ * Object that can restore a {@link Timeline} from a {@link Bundle}.
+ *
+ * The {@link #getWindow(int, Window)} windows} and {@link #getPeriod(int, Period) periods} of
+ * a restored instance may have missing fields as described in {@link Window#CREATOR} and {@link
+ * Period#CREATOR}.
+ */
+ public static final Creator CREATOR = Timeline::fromBundle;
+
+ private static Timeline fromBundle(Bundle bundle) {
+ ImmutableList windows =
+ fromBundleListRetriever(
+ Window.CREATOR, BundleCompat.getBinder(bundle, keyForField(FIELD_WINDOWS)));
+ ImmutableList periods =
+ fromBundleListRetriever(
+ Period.CREATOR, BundleCompat.getBinder(bundle, keyForField(FIELD_PERIODS)));
+ return new RemotableTimeline(windows, periods);
+ }
+
+ private static ImmutableList fromBundleListRetriever(
+ Creator creator, @Nullable IBinder binder) {
+ if (binder == null) {
+ return ImmutableList.of();
+ }
+ ImmutableList.Builder builder = new ImmutableList.Builder<>();
+ List bundleList = BundleListRetriever.getList(binder);
+ for (int i = 0; i < bundleList.size(); i++) {
+ builder.add(creator.fromBundle(bundleList.get(i)));
+ }
+ return builder.build();
+ }
+
+ private static String keyForField(@FieldNumber int field) {
+ return Integer.toString(field, Character.MAX_RADIX);
+ }
+
+ /**
+ * A concrete class of {@link Timeline} to restore a {@link Timeline} instance from a {@link
+ * Bundle} sent by another process via {@link IBinder}.
+ */
+ private static final class RemotableTimeline extends Timeline {
+
+ private final ImmutableList windows;
+ private final ImmutableList periods;
+
+ public RemotableTimeline(ImmutableList windows, ImmutableList periods) {
+ this.windows = windows;
+ this.periods = periods;
+ }
+
+ @Override
+ public int getWindowCount() {
+ return windows.size();
+ }
+
+ @Override
+ public Window getWindow(
+ int windowIndex, Window window, long ignoredDefaultPositionProjectionUs) {
+ Window w = windows.get(windowIndex);
+ window.set(
+ w.uid,
+ w.mediaItem,
+ w.manifest,
+ w.presentationStartTimeMs,
+ w.windowStartTimeMs,
+ w.elapsedRealtimeEpochOffsetMs,
+ w.isSeekable,
+ w.isDynamic,
+ w.liveConfiguration,
+ w.defaultPositionUs,
+ w.durationUs,
+ w.firstPeriodIndex,
+ w.lastPeriodIndex,
+ w.positionInFirstPeriodUs);
+ window.isPlaceholder = w.isPlaceholder;
+ return window;
+ }
+
+ @Override
+ public int getPeriodCount() {
+ return periods.size();
+ }
+
+ @Override
+ public Period getPeriod(int periodIndex, Period period, boolean ignoredSetIds) {
+ Period p = periods.get(periodIndex);
+ return period.set(
+ p.id, p.uid, p.windowIndex, p.durationUs, p.positionInWindowUs, p.adPlaybackState);
+ }
+
+ @Override
+ public int getIndexOfPeriod(Object uid) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Object getUidOfPeriod(int periodIndex) {
+ throw new UnsupportedOperationException();
+ }
+ }
}
diff --git a/library/common/src/test/java/com/google/android/exoplayer2/BundleListRetrieverTest.java b/library/common/src/test/java/com/google/android/exoplayer2/BundleListRetrieverTest.java
new file mode 100644
index 0000000000..e74c95f0f3
--- /dev/null
+++ b/library/common/src/test/java/com/google/android/exoplayer2/BundleListRetrieverTest.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2021 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.google.android.exoplayer2;
+
+import android.os.Bundle;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.ext.truth.os.BundleSubject;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Tests for {@link BundleListRetriever}. */
+@RunWith(AndroidJUnit4.class)
+public class BundleListRetrieverTest {
+
+ @Test
+ public void getList_preservedLargeList() {
+ int count = 100_000;
+ List listBefore = new ArrayList<>();
+ for (int i = 0; i < count; i++) {
+ Bundle bundle = new Bundle();
+ bundle.putInt("i", i);
+ listBefore.add(bundle);
+ }
+
+ List listAfter = BundleListRetriever.getList(new BundleListRetriever(listBefore));
+
+ for (int i = 0; i < count; i++) {
+ Bundle bundle = listAfter.get(i);
+ BundleSubject.assertThat(bundle).integer("i").isEqualTo(i);
+ }
+ }
+}
diff --git a/library/common/src/test/java/com/google/android/exoplayer2/TimelineTest.java b/library/common/src/test/java/com/google/android/exoplayer2/TimelineTest.java
index 45fde51eda..502f01ccc8 100644
--- a/library/common/src/test/java/com/google/android/exoplayer2/TimelineTest.java
+++ b/library/common/src/test/java/com/google/android/exoplayer2/TimelineTest.java
@@ -20,6 +20,7 @@ import static com.google.common.truth.Truth.assertThat;
import androidx.annotation.Nullable;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.android.exoplayer2.MediaItem.LiveConfiguration;
+import com.google.android.exoplayer2.source.ads.AdPlaybackState;
import com.google.android.exoplayer2.testutil.FakeTimeline;
import com.google.android.exoplayer2.testutil.FakeTimeline.TimelineWindowDefinition;
import com.google.android.exoplayer2.testutil.TimelineAsserts;
@@ -201,6 +202,41 @@ public class TimelineTest {
assertThat(period.hashCode()).isEqualTo(otherPeriod.hashCode());
}
+ @Test
+ public void roundtripViaBundle_ofTimeline_yieldsEqualInstanceExceptIdsAndManifest() {
+ Timeline timeline =
+ new FakeTimeline(
+ new TimelineWindowDefinition(
+ /* periodCount= */ 2,
+ /* id= */ new Object(),
+ /* isSeekable= */ true,
+ /* isDynamic= */ true,
+ /* isLive= */ true,
+ /* isPlaceholder= */ false,
+ /* durationUs= */ 2,
+ /* defaultPositionUs= */ 22,
+ /* windowOffsetInFirstPeriodUs= */ 222,
+ AdPlaybackState.NONE,
+ new MediaItem.Builder().setMediaId("mediaId2").build()),
+ new TimelineWindowDefinition(
+ /* periodCount= */ 3,
+ /* id= */ new Object(),
+ /* isSeekable= */ true,
+ /* isDynamic= */ true,
+ /* isLive= */ true,
+ /* isPlaceholder= */ false,
+ /* durationUs= */ 3,
+ /* defaultPositionUs= */ 33,
+ /* windowOffsetInFirstPeriodUs= */ 333,
+ AdPlaybackState.NONE,
+ new MediaItem.Builder().setMediaId("mediaId3").build()));
+
+ Timeline restoredTimeline = Timeline.CREATOR.fromBundle(timeline.toBundle());
+
+ TimelineAsserts.assertEqualsExceptIdsAndManifest(
+ /* expectedTimeline= */ timeline, /* actualTimeline= */ restoredTimeline);
+ }
+
@Test
public void roundtripViaBundle_ofWindow_yieldsEqualInstanceExceptUidAndManifest() {
Timeline.Window window = new Timeline.Window();
@@ -229,9 +265,8 @@ public class TimelineTest {
Timeline.Window restoredWindow = Timeline.Window.CREATOR.fromBundle(window.toBundle());
assertThat(restoredWindow.manifest).isNull();
- window.uid = restoredWindow.uid;
- window.manifest = null;
- assertThat(restoredWindow).isEqualTo(window);
+ TimelineAsserts.assertWindowEqualsExceptUidAndManifest(
+ /* expectedWindow= */ window, /* actualWindow= */ restoredWindow);
}
@Test
@@ -245,9 +280,10 @@ public class TimelineTest {
Timeline.Period restoredPeriod = Timeline.Period.CREATOR.fromBundle(period.toBundle());
- period.id = null;
- period.uid = null;
- assertThat(restoredPeriod).isEqualTo(period);
+ assertThat(restoredPeriod.id).isNull();
+ assertThat(restoredPeriod.uid).isNull();
+ TimelineAsserts.assertPeriodEqualsExceptIds(
+ /* expectedPeriod= */ period, /* actualPeriod= */ restoredPeriod);
}
@SuppressWarnings("deprecation") // Populates the deprecated window.tag property.
diff --git a/testutils/build.gradle b/testutils/build.gradle
index cc72126ba3..a0dc2b4cb5 100644
--- a/testutils/build.gradle
+++ b/testutils/build.gradle
@@ -17,6 +17,7 @@ dependencies {
api 'org.mockito:mockito-core:' + mockitoVersion
api 'androidx.test:core:' + androidxTestCoreVersion
api 'androidx.test.ext:junit:' + androidxTestJUnitVersion
+ api 'androidx.test.ext:truth:' + androidxTestTruthVersion
api 'junit:junit:' + junitVersion
api 'com.google.truth:truth:' + truthVersion
compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TimelineAsserts.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TimelineAsserts.java
index efcca8cd92..913e154c64 100644
--- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TimelineAsserts.java
+++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TimelineAsserts.java
@@ -17,6 +17,7 @@ package com.google.android.exoplayer2.testutil;
import static com.google.common.truth.Truth.assertThat;
+import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.MediaItem;
import com.google.android.exoplayer2.Player;
@@ -27,15 +28,13 @@ import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.Util;
import org.checkerframework.checker.nullness.compatqual.NullableType;
-/** Unit test for {@link Timeline}. */
+/** Assertion methods for {@link Timeline}. */
public final class TimelineAsserts {
private static final int[] REPEAT_MODES = {
Player.REPEAT_MODE_OFF, Player.REPEAT_MODE_ONE, Player.REPEAT_MODE_ALL
};
- private TimelineAsserts() {}
-
/** Assert that timeline is empty (i.e. has no windows or periods). */
public static void assertEmpty(Timeline timeline) {
assertWindowTags(timeline);
@@ -173,4 +172,64 @@ public final class TimelineAsserts {
assertThat(period.getAdGroupCount()).isEqualTo(expectedAdGroupCounts[i]);
}
}
+
+ /**
+ * Asserts that {@link Timeline timelines} are equal except {@link Window#uid}, {@link
+ * Window#manifest}, {@link Period#id}, and {@link Period#uid}.
+ */
+ public static void assertEqualsExceptIdsAndManifest(
+ Timeline expectedTimeline, Timeline actualTimeline) {
+ assertThat(actualTimeline.getWindowCount()).isEqualTo(expectedTimeline.getWindowCount());
+ for (int i = 0; i < actualTimeline.getWindowCount(); i++) {
+ Window expectedWindow = new Window();
+ Window actualWindow = new Window();
+ assertWindowEqualsExceptUidAndManifest(
+ expectedTimeline.getWindow(i, expectedWindow, /* defaultPositionProjectionUs= */ 0),
+ actualTimeline.getWindow(i, actualWindow, /* defaultPositionProjectionUs= */ 0));
+ }
+ assertThat(actualTimeline.getPeriodCount()).isEqualTo(expectedTimeline.getPeriodCount());
+ for (int i = 0; i < actualTimeline.getPeriodCount(); i++) {
+ Period expectedPeriod = new Period();
+ Period actualPeriod = new Period();
+ assertPeriodEqualsExceptIds(
+ expectedTimeline.getPeriod(i, expectedPeriod, /* setIds= */ false),
+ actualTimeline.getPeriod(i, actualPeriod, /* setIds= */ false));
+ }
+ }
+
+ /**
+ * Asserts that {@link Window windows} are equal except {@link Window#uid} and {@link
+ * Window#manifest}.
+ */
+ public static void assertWindowEqualsExceptUidAndManifest(
+ Window expectedWindow, Window actualWindow) {
+ Object uid = expectedWindow.uid;
+ @Nullable Object manifest = expectedWindow.manifest;
+ try {
+ expectedWindow.uid = actualWindow.uid;
+ expectedWindow.manifest = actualWindow.manifest;
+ assertThat(actualWindow).isEqualTo(expectedWindow);
+ } finally {
+ expectedWindow.uid = uid;
+ expectedWindow.manifest = manifest;
+ }
+ }
+
+ /**
+ * Asserts that {@link Period periods} are equal except {@link Period#id} and {@link Period#uid}.
+ */
+ public static void assertPeriodEqualsExceptIds(Period expectedPeriod, Period actualPeriod) {
+ @Nullable Object id = expectedPeriod.id;
+ @Nullable Object uid = expectedPeriod.uid;
+ try {
+ expectedPeriod.id = actualPeriod.id;
+ expectedPeriod.uid = actualPeriod.uid;
+ assertThat(actualPeriod).isEqualTo(expectedPeriod);
+ } finally {
+ expectedPeriod.id = id;
+ expectedPeriod.uid = uid;
+ }
+ }
+
+ private TimelineAsserts() {}
}