diff --git a/constants.gradle b/constants.gradle index 0c018a940f..1f64aacb41 100644 --- a/constants.gradle +++ b/constants.gradle @@ -32,6 +32,7 @@ project.ext { androidxAnnotationVersion = '1.1.0' androidxAppCompatVersion = '1.1.0' androidxCollectionVersion = '1.1.0' + androidxCoreVersion = '1.3.2' androidxFuturesVersion = '1.1.0' androidxMediaVersion = '1.2.1' androidxMedia2Version = '1.1.2' @@ -42,6 +43,7 @@ project.ext { androidxTestRunnerVersion = '1.3.0' androidxTestRulesVersion = '1.3.0' androidxTestServicesStorageVersion = '1.3.0' + androidxTestTruthVersion = '1.3.0' truthVersion = '1.0' modulePrefix = ':' if (gradle.ext.has('exoplayerModulePrefix')) { diff --git a/library/common/build.gradle b/library/common/build.gradle index d1d0d86f42..01bda6fb9b 100644 --- a/library/common/build.gradle +++ b/library/common/build.gradle @@ -26,6 +26,7 @@ dependencies { exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations' } implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion + implementation 'androidx.core:core:' + androidxCoreVersion compileOnly 'com.google.code.findbugs:jsr305:' + jsr305Version compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion diff --git a/library/common/src/main/java/com/google/android/exoplayer2/BundleListRetriever.java b/library/common/src/main/java/com/google/android/exoplayer2/BundleListRetriever.java new file mode 100644 index 0000000000..4deaf43a8f --- /dev/null +++ b/library/common/src/main/java/com/google/android/exoplayer2/BundleListRetriever.java @@ -0,0 +1,125 @@ +/* + * 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 static com.google.android.exoplayer2.util.Assertions.checkNotNull; + +import android.os.Binder; +import android.os.Bundle; +import android.os.IBinder; +import android.os.Parcel; +import android.os.RemoteException; +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; +import java.util.List; + +/** + * A {@link Binder} to transfer a list of {@link Bundle Bundles} across processes by splitting the + * list into multiple transactions. + * + *

Note: Using this class causes synchronous binder calls in the opposite direction regardless of + * the "oneway" property. + * + *

Example usage: + * + *

{@code
+ * // Sender
+ * List list = ...;
+ * IBinder binder = new BundleListRetriever(list);
+ * Bundle bundle = new Bundle();
+ * bundle.putBinder("list", binder);
+ *
+ * // Receiver
+ * Bundle bundle = ...; // Received from the sender
+ * IBinder binder = bundle.getBinder("list");
+ * List list = BundleListRetriever.getList(binder);
+ * }
+ */ +public final class BundleListRetriever extends Binder { + + // Soft limit of an IPC buffer size + private static final int SUGGESTED_MAX_IPC_SIZE = + Util.SDK_INT >= 30 ? IBinder.getSuggestedMaxIpcSizeBytes() : 64 * 1024; + + private static final int REPLY_END_OF_LIST = 0; + private static final int REPLY_CONTINUE = 1; + private static final int REPLY_BREAK = 2; + + private final ImmutableList list; + + /** Creates a {@link Binder} to send a list of {@link Bundle Bundles} to another process. */ + public BundleListRetriever(List list) { + this.list = ImmutableList.copyOf(list); + } + + @Override + protected boolean onTransact(int code, Parcel data, @Nullable Parcel reply, int flags) + throws RemoteException { + if (code != FIRST_CALL_TRANSACTION) { + return super.onTransact(code, data, reply, flags); + } + + if (reply == null) { + return false; + } + + int count = list.size(); + int index = data.readInt(); + while (index < count && reply.dataSize() < SUGGESTED_MAX_IPC_SIZE) { + reply.writeInt(REPLY_CONTINUE); + reply.writeBundle(list.get(index)); + index++; + } + reply.writeInt(index < count ? REPLY_BREAK : REPLY_END_OF_LIST); + return true; + } + + /** + * Gets a list of {@link Bundle Bundles} from a {@link BundleListRetriever}. + * + * @param binder A binder interface backed by {@link BundleListRetriever}. + * @return The list of {@link Bundle Bundles}. + */ + public static ImmutableList getList(IBinder binder) { + ImmutableList.Builder 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() {} }