1 /*
2  * Copyright (C) 2020 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.google.android.exoplayer2.testutil;
18 
19 import static com.google.common.truth.Truth.assertThat;
20 
21 import android.content.Context;
22 import android.os.Looper;
23 import androidx.annotation.Nullable;
24 import com.google.android.exoplayer2.DefaultLoadControl;
25 import com.google.android.exoplayer2.ExoPlaybackException;
26 import com.google.android.exoplayer2.ExoPlayer;
27 import com.google.android.exoplayer2.LoadControl;
28 import com.google.android.exoplayer2.Player;
29 import com.google.android.exoplayer2.Renderer;
30 import com.google.android.exoplayer2.RenderersFactory;
31 import com.google.android.exoplayer2.SimpleExoPlayer;
32 import com.google.android.exoplayer2.Timeline;
33 import com.google.android.exoplayer2.analytics.AnalyticsCollector;
34 import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
35 import com.google.android.exoplayer2.upstream.BandwidthMeter;
36 import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter;
37 import com.google.android.exoplayer2.util.Assertions;
38 import com.google.android.exoplayer2.util.Clock;
39 import com.google.android.exoplayer2.util.Supplier;
40 import com.google.android.exoplayer2.util.Util;
41 import com.google.android.exoplayer2.video.VideoListener;
42 import java.lang.reflect.InvocationTargetException;
43 import java.lang.reflect.Method;
44 import java.util.concurrent.atomic.AtomicBoolean;
45 import java.util.concurrent.atomic.AtomicReference;
46 import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
47 
48 /**
49  * Utilities to write unit/integration tests with a SimpleExoPlayer instance that uses fake
50  * components.
51  */
52 public class TestExoPlayer {
53 
54   /** Reflectively call Robolectric ShadowLooper#runOneTask. */
55   private static final Object shadowLooper;
56 
57   private static final Method runOneTaskMethod;
58 
59   static {
60     try {
61       Class<?> clazz = Class.forName("org.robolectric.Shadows");
62       Method shadowOfMethod =
63           Assertions.checkNotNull(clazz.getDeclaredMethod("shadowOf", Looper.class));
64       shadowLooper =
65           Assertions.checkNotNull(shadowOfMethod.invoke(new Object(), Looper.getMainLooper()));
66       runOneTaskMethod = shadowLooper.getClass().getDeclaredMethod("runOneTask");
67     } catch (Exception e) {
68       throw new RuntimeException(e);
69     }
70   }
71 
72   /** A builder of {@link SimpleExoPlayer} instances for testing. */
73   public static class Builder {
74 
75     private final Context context;
76     private Clock clock;
77     private DefaultTrackSelector trackSelector;
78     private LoadControl loadControl;
79     private BandwidthMeter bandwidthMeter;
80     @Nullable private Renderer[] renderers;
81     @Nullable private RenderersFactory renderersFactory;
82     private boolean useLazyPreparation;
83     private boolean throwWhenStuckBuffering;
84     private @MonotonicNonNull Looper looper;
85 
Builder(Context context)86     public Builder(Context context) {
87       this.context = context;
88       clock = new AutoAdvancingFakeClock();
89       trackSelector = new DefaultTrackSelector(context);
90       loadControl = new DefaultLoadControl();
91       bandwidthMeter = new DefaultBandwidthMeter.Builder(context).build();
92       @Nullable Looper myLooper = Looper.myLooper();
93       if (myLooper != null) {
94         looper = myLooper;
95       }
96     }
97 
98     /**
99      * Sets whether to use lazy preparation.
100      *
101      * @param useLazyPreparation Whether to use lazy preparation.
102      * @return This builder.
103      */
setUseLazyPreparation(boolean useLazyPreparation)104     public Builder setUseLazyPreparation(boolean useLazyPreparation) {
105       this.useLazyPreparation = useLazyPreparation;
106       return this;
107     }
108 
109     /** Returns whether the player will use lazy preparation. */
getUseLazyPreparation()110     public boolean getUseLazyPreparation() {
111       return useLazyPreparation;
112     }
113 
114     /**
115      * Sets a {@link DefaultTrackSelector}. The default value is a {@link DefaultTrackSelector} in
116      * its initial configuration.
117      *
118      * @param trackSelector The {@link DefaultTrackSelector} to be used by the player.
119      * @return This builder.
120      */
setTrackSelector(DefaultTrackSelector trackSelector)121     public Builder setTrackSelector(DefaultTrackSelector trackSelector) {
122       Assertions.checkNotNull(trackSelector);
123       this.trackSelector = trackSelector;
124       return this;
125     }
126 
127     /** Returns the track selector used by the player. */
getTrackSelector()128     public DefaultTrackSelector getTrackSelector() {
129       return trackSelector;
130     }
131 
132     /**
133      * Sets a {@link LoadControl} to be used by the player. The default value is a {@link
134      * DefaultLoadControl}.
135      *
136      * @param loadControl The {@link LoadControl} to be used by the player.
137      * @return This builder.
138      */
setLoadControl(LoadControl loadControl)139     public Builder setLoadControl(LoadControl loadControl) {
140       this.loadControl = loadControl;
141       return this;
142     }
143 
144     /** Returns the {@link LoadControl} that will be used by the player. */
getLoadControl()145     public LoadControl getLoadControl() {
146       return loadControl;
147     }
148 
149     /**
150      * Sets the {@link BandwidthMeter}. The default value is a {@link DefaultBandwidthMeter} in its
151      * default configuration.
152      *
153      * @param bandwidthMeter The {@link BandwidthMeter} to be used by the player.
154      * @return This builder.
155      */
setBandwidthMeter(BandwidthMeter bandwidthMeter)156     public Builder setBandwidthMeter(BandwidthMeter bandwidthMeter) {
157       Assertions.checkNotNull(bandwidthMeter);
158       this.bandwidthMeter = bandwidthMeter;
159       return this;
160     }
161 
162     /** Returns the bandwidth meter used by the player. */
getBandwidthMeter()163     public BandwidthMeter getBandwidthMeter() {
164       return bandwidthMeter;
165     }
166 
167     /**
168      * Sets the {@link Renderer}s. If not set, the player will use a {@link FakeVideoRenderer} and a
169      * {@link FakeAudioRenderer}. Setting the renderers is not allowed after a call to {@link
170      * #setRenderersFactory(RenderersFactory)}.
171      *
172      * @param renderers A list of {@link Renderer}s to be used by the player.
173      * @return This builder.
174      */
setRenderers(Renderer... renderers)175     public Builder setRenderers(Renderer... renderers) {
176       assertThat(renderersFactory).isNull();
177       this.renderers = renderers;
178       return this;
179     }
180 
181     /**
182      * Returns the {@link Renderer Renderers} that have been set with {@link #setRenderers} or null
183      * if no {@link Renderer Renderers} have been explicitly set. Note that these renderers may not
184      * be the ones used by the built player, for example if a {@link #setRenderersFactory Renderer
185      * factory} has been set.
186      */
187     @Nullable
getRenderers()188     public Renderer[] getRenderers() {
189       return renderers;
190     }
191 
192     /**
193      * Sets the {@link RenderersFactory}. The default factory creates all renderers set by {@link
194      * #setRenderers(Renderer...)}. Setting the renderer factory is not allowed after a call to
195      * {@link #setRenderers(Renderer...)}.
196      *
197      * @param renderersFactory A {@link RenderersFactory} to be used by the player.
198      * @return This builder.
199      */
setRenderersFactory(RenderersFactory renderersFactory)200     public Builder setRenderersFactory(RenderersFactory renderersFactory) {
201       assertThat(renderers).isNull();
202       this.renderersFactory = renderersFactory;
203       return this;
204     }
205 
206     /**
207      * Returns the {@link RenderersFactory} that has been set with {@link #setRenderersFactory} or
208      * null if no factory has been explicitly set.
209      */
210     @Nullable
getRenderersFactory()211     public RenderersFactory getRenderersFactory() {
212       return renderersFactory;
213     }
214 
215     /**
216      * Sets the {@link Clock} to be used by the player. The default value is a {@link
217      * AutoAdvancingFakeClock}.
218      *
219      * @param clock A {@link Clock} to be used by the player.
220      * @return This builder.
221      */
setClock(Clock clock)222     public Builder setClock(Clock clock) {
223       assertThat(clock).isNotNull();
224       this.clock = clock;
225       return this;
226     }
227 
228     /** Returns the clock used by the player. */
getClock()229     public Clock getClock() {
230       return clock;
231     }
232 
233     /**
234      * Sets the {@link Looper} to be used by the player.
235      *
236      * @param looper The {@link Looper} to be used by the player.
237      * @return This builder.
238      */
setLooper(Looper looper)239     public Builder setLooper(Looper looper) {
240       this.looper = looper;
241       return this;
242     }
243 
244     /**
245      * Returns the {@link Looper} that will be used by the player, or null if no {@link Looper} has
246      * been set yet and no default is available.
247      */
248     @Nullable
getLooper()249     public Looper getLooper() {
250       return looper;
251     }
252 
253     /**
254      * Sets whether the player should throw when it detects it's stuck buffering.
255      *
256      * <p>This method is experimental, and will be renamed or removed in a future release.
257      *
258      * @param throwWhenStuckBuffering Whether to throw when the player detects it's stuck buffering.
259      * @return This builder.
260      */
experimental_setThrowWhenStuckBuffering(boolean throwWhenStuckBuffering)261     public Builder experimental_setThrowWhenStuckBuffering(boolean throwWhenStuckBuffering) {
262       this.throwWhenStuckBuffering = throwWhenStuckBuffering;
263       return this;
264     }
265 
266     /**
267      * Builds an {@link SimpleExoPlayer} using the provided values or their defaults.
268      *
269      * @return The built {@link ExoPlayerTestRunner}.
270      */
build()271     public SimpleExoPlayer build() {
272       Assertions.checkNotNull(
273           looper, "TestExoPlayer builder run on a thread without Looper and no Looper specified.");
274       // Do not update renderersFactory and renderers here, otherwise their getters may
275       // return different values before and after build() is called, making them confusing.
276       RenderersFactory playerRenderersFactory = renderersFactory;
277       if (playerRenderersFactory == null) {
278         playerRenderersFactory =
279             (eventHandler,
280                 videoRendererEventListener,
281                 audioRendererEventListener,
282                 textRendererOutput,
283                 metadataRendererOutput) ->
284                 renderers != null
285                     ? renderers
286                     : new Renderer[] {
287                       new FakeVideoRenderer(eventHandler, videoRendererEventListener),
288                       new FakeAudioRenderer(eventHandler, audioRendererEventListener)
289                     };
290       }
291 
292       return new SimpleExoPlayer.Builder(context, playerRenderersFactory)
293           .setTrackSelector(trackSelector)
294           .setLoadControl(loadControl)
295           .setBandwidthMeter(bandwidthMeter)
296           .setAnalyticsCollector(new AnalyticsCollector(clock))
297           .setClock(clock)
298           .setUseLazyPreparation(useLazyPreparation)
299           .setLooper(looper)
300           .experimental_setThrowWhenStuckBuffering(throwWhenStuckBuffering)
301           .build();
302     }
303   }
304 
TestExoPlayer()305   private TestExoPlayer() {}
306 
307   /**
308    * Run tasks of the main {@link Looper} until the {@code player}'s state reaches the {@code
309    * expectedState}.
310    */
runUntilPlaybackState(Player player, @Player.State int expectedState)311   public static void runUntilPlaybackState(Player player, @Player.State int expectedState) {
312     verifyMainTestThread(player);
313     if (player.getPlaybackState() == expectedState) {
314       return;
315     }
316 
317     AtomicBoolean receivedExpectedState = new AtomicBoolean(false);
318     Player.EventListener listener =
319         new Player.EventListener() {
320           @Override
321           public void onPlaybackStateChanged(int state) {
322             if (state == expectedState) {
323               receivedExpectedState.set(true);
324             }
325           }
326         };
327     player.addListener(listener);
328     runUntil(() -> receivedExpectedState.get());
329     player.removeListener(listener);
330   }
331 
332   /**
333    * Run tasks of the main {@link Looper} until the {@code player} calls the {@link
334    * Player.EventListener#onPlaybackSpeedChanged} callback with that matches {@code
335    * expectedPlayWhenReady}.
336    */
runUntilPlayWhenReady(Player player, boolean expectedPlayWhenReady)337   public static void runUntilPlayWhenReady(Player player, boolean expectedPlayWhenReady) {
338     verifyMainTestThread(player);
339     if (player.getPlayWhenReady() == expectedPlayWhenReady) {
340       return;
341     }
342 
343     AtomicBoolean receivedExpectedPlayWhenReady = new AtomicBoolean(false);
344     Player.EventListener listener =
345         new Player.EventListener() {
346           @Override
347           public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) {
348             if (playWhenReady == expectedPlayWhenReady) {
349               receivedExpectedPlayWhenReady.set(true);
350             }
351             player.removeListener(this);
352           }
353         };
354     player.addListener(listener);
355     runUntil(() -> receivedExpectedPlayWhenReady.get());
356   }
357 
358   /**
359    * Run tasks of the main {@link Looper} until the {@code player} calls the {@link
360    * Player.EventListener#onTimelineChanged} callback.
361    *
362    * @param player The {@link Player}.
363    * @param expectedTimeline A specific {@link Timeline} to wait for, or null if any timeline is
364    *     accepted.
365    * @return The received {@link Timeline}.
366    */
runUntilTimelineChanged( Player player, @Nullable Timeline expectedTimeline)367   public static Timeline runUntilTimelineChanged(
368       Player player, @Nullable Timeline expectedTimeline) {
369     verifyMainTestThread(player);
370 
371     if (expectedTimeline != null && expectedTimeline.equals(player.getCurrentTimeline())) {
372       return expectedTimeline;
373     }
374 
375     AtomicReference<Timeline> receivedTimeline = new AtomicReference<>();
376     Player.EventListener listener =
377         new Player.EventListener() {
378           @Override
379           public void onTimelineChanged(Timeline timeline, int reason) {
380             if (expectedTimeline == null || expectedTimeline.equals(timeline)) {
381               receivedTimeline.set(timeline);
382             }
383             player.removeListener(this);
384           }
385         };
386     player.addListener(listener);
387     runUntil(() -> receivedTimeline.get() != null);
388     return receivedTimeline.get();
389   }
390 
391   /**
392    * Run tasks of the main {@link Looper} until the {@code player} calls the {@link
393    * Player.EventListener#onPositionDiscontinuity} callback with the specified {@link
394    * Player.DiscontinuityReason}.
395    */
runUntilPositionDiscontinuity( Player player, @Player.DiscontinuityReason int expectedReason)396   public static void runUntilPositionDiscontinuity(
397       Player player, @Player.DiscontinuityReason int expectedReason) {
398     AtomicBoolean receivedCallback = new AtomicBoolean(false);
399     Player.EventListener listener =
400         new Player.EventListener() {
401           @Override
402           public void onPositionDiscontinuity(int reason) {
403             if (reason == expectedReason) {
404               receivedCallback.set(true);
405               player.removeListener(this);
406             }
407           }
408         };
409     player.addListener(listener);
410     runUntil(() -> receivedCallback.get());
411   }
412 
413   /**
414    * Run tasks of the main {@link Looper} until the {@code player} calls the {@link
415    * Player.EventListener#onPlayerError} callback.
416    *
417    * @param player The {@link Player}.
418    * @return The raised error.
419    */
runUntilError(Player player)420   public static ExoPlaybackException runUntilError(Player player) {
421     verifyMainTestThread(player);
422     AtomicReference<ExoPlaybackException> receivedError = new AtomicReference<>();
423     Player.EventListener listener =
424         new Player.EventListener() {
425           @Override
426           public void onPlayerError(ExoPlaybackException error) {
427             receivedError.set(error);
428             player.removeListener(this);
429           }
430         };
431     player.addListener(listener);
432     runUntil(() -> receivedError.get() != null);
433     return receivedError.get();
434   }
435 
436   /**
437    * Run tasks of the main {@link Looper} until the {@code player} calls the {@link
438    * com.google.android.exoplayer2.video.VideoRendererEventListener#onRenderedFirstFrame} callback.
439    */
runUntilRenderedFirstFrame(SimpleExoPlayer player)440   public static void runUntilRenderedFirstFrame(SimpleExoPlayer player) {
441     verifyMainTestThread(player);
442     AtomicBoolean receivedCallback = new AtomicBoolean(false);
443     VideoListener listener =
444         new VideoListener() {
445           @Override
446           public void onRenderedFirstFrame() {
447             receivedCallback.set(true);
448             player.removeVideoListener(this);
449           }
450         };
451     player.addVideoListener(listener);
452     runUntil(() -> receivedCallback.get());
453   }
454 
455   /**
456    * Runs tasks of the main {@link Looper} until the {@code player} handled all previously issued
457    * commands completely on the internal playback thread.
458    */
runUntilPendingCommandsAreFullyHandled(ExoPlayer player)459   public static void runUntilPendingCommandsAreFullyHandled(ExoPlayer player) {
460     verifyMainTestThread(player);
461     // Send message to player that will arrive after all other pending commands. Thus, the message
462     // execution on the app thread will also happen after all other pending command
463     // acknowledgements have arrived back on the app thread.
464     AtomicBoolean receivedMessageCallback = new AtomicBoolean(false);
465     player
466         .createMessage((type, data) -> receivedMessageCallback.set(true))
467         .setHandler(Util.createHandler())
468         .send();
469     runUntil(() -> receivedMessageCallback.get());
470   }
471 
472   /** Run tasks of the main {@link Looper} until the {@code condition} returns {@code true}. */
runUntil(Supplier<Boolean> condition)473   public static void runUntil(Supplier<Boolean> condition) {
474     verifyMainTestThread();
475 
476     try {
477       while (!condition.get()) {
478         runOneTaskMethod.invoke(shadowLooper);
479       }
480     } catch (IllegalAccessException e) {
481       throw new IllegalStateException(e);
482     } catch (InvocationTargetException e) {
483       throw new IllegalStateException(e);
484     }
485   }
486 
verifyMainTestThread(Player player)487   private static void verifyMainTestThread(Player player) {
488     if (Looper.myLooper() != Looper.getMainLooper()
489         || player.getApplicationLooper() != Looper.getMainLooper()) {
490       throw new IllegalStateException();
491     }
492   }
493 
verifyMainTestThread()494   private static void verifyMainTestThread() {
495     if (Looper.myLooper() != Looper.getMainLooper()) {
496       throw new IllegalStateException();
497     }
498   }
499 }
500