diff --git a/demo/src/main/java/com/google/android/exoplayer/demo/player/DashRendererBuilder.java b/demo/src/main/java/com/google/android/exoplayer/demo/player/DashRendererBuilder.java index 3f14a58bdb..dd1cf829d9 100644 --- a/demo/src/main/java/com/google/android/exoplayer/demo/player/DashRendererBuilder.java +++ b/demo/src/main/java/com/google/android/exoplayer/demo/player/DashRendererBuilder.java @@ -37,6 +37,9 @@ import com.google.android.exoplayer.dash.mpd.MediaPresentationDescription; import com.google.android.exoplayer.dash.mpd.MediaPresentationDescriptionParser; import com.google.android.exoplayer.dash.mpd.Period; import com.google.android.exoplayer.dash.mpd.Representation; +import com.google.android.exoplayer.dash.mpd.UtcTimingElement; +import com.google.android.exoplayer.dash.mpd.UtcTimingElementResolver; +import com.google.android.exoplayer.dash.mpd.UtcTimingElementResolver.UtcTimingCallback; import com.google.android.exoplayer.demo.DemoUtil; import com.google.android.exoplayer.demo.player.DemoPlayer.RendererBuilder; import com.google.android.exoplayer.demo.player.DemoPlayer.RendererBuilderCallback; @@ -59,6 +62,7 @@ import android.annotation.TargetApi; import android.media.MediaCodec; import android.media.UnsupportedSchemeException; import android.os.Handler; +import android.util.Log; import android.util.Pair; import android.widget.TextView; @@ -70,7 +74,9 @@ import java.util.List; * A {@link RendererBuilder} for DASH. */ public class DashRendererBuilder implements RendererBuilder, - ManifestCallback { + ManifestCallback, UtcTimingCallback { + + private static final String TAG = "DashRendererBuilder"; private static final int BUFFER_SEGMENT_SIZE = 64 * 1024; private static final int VIDEO_BUFFER_SEGMENTS = 200; @@ -96,6 +102,9 @@ public class DashRendererBuilder implements RendererBuilder, private RendererBuilderCallback callback; private ManifestFetcher manifestFetcher; + private MediaPresentationDescription manifest; + private long elapsedRealtimeOffset; + public DashRendererBuilder(String userAgent, String url, String contentId, MediaDrmCallback drmCallback, TextView debugTextView, AudioCapabilities audioCapabilities) { this.userAgent = userAgent; @@ -116,13 +125,36 @@ public class DashRendererBuilder implements RendererBuilder, manifestFetcher.singleLoad(player.getMainHandler().getLooper(), this); } + @Override + public void onManifest(String contentId, MediaPresentationDescription manifest) { + this.manifest = manifest; + if (manifest.dynamic && manifest.utcTiming != null) { + UtcTimingElementResolver.resolveTimingElement(userAgent, manifest.utcTiming, + manifestFetcher.getManifestLoadTimestamp(), this); + } else { + buildRenderers(); + } + } + @Override public void onManifestError(String contentId, IOException e) { callback.onRenderersError(e); } @Override - public void onManifest(String contentId, MediaPresentationDescription manifest) { + public void onTimestampResolved(UtcTimingElement utcTiming, long elapsedRealtimeOffset) { + this.elapsedRealtimeOffset = elapsedRealtimeOffset; + buildRenderers(); + } + + @Override + public void onTimestampError(UtcTimingElement utcTiming, IOException e) { + Log.e(TAG, "Failed to resolve UtcTiming element [" + utcTiming + "]", e); + // Be optimistic and continue in the hope that the device clock is correct. + buildRenderers(); + } + + private void buildRenderers() { Period period = manifest.periods.get(0); Handler mainHandler = player.getMainHandler(); LoadControl loadControl = new DefaultLoadControl(new BufferPool(BUFFER_SEGMENT_SIZE)); @@ -207,7 +239,7 @@ public class DashRendererBuilder implements RendererBuilder, DataSource videoDataSource = new UriDataSource(userAgent, bandwidthMeter); ChunkSource videoChunkSource = new DashChunkSource(manifestFetcher, videoAdaptationSetIndex, videoRepresentationIndices, videoDataSource, new AdaptiveEvaluator(bandwidthMeter), - LIVE_EDGE_LATENCY_MS); + LIVE_EDGE_LATENCY_MS, elapsedRealtimeOffset); ChunkSampleSource videoSampleSource = new ChunkSampleSource(videoChunkSource, loadControl, VIDEO_BUFFER_SEGMENTS * BUFFER_SEGMENT_SIZE, true, mainHandler, player, DemoPlayer.TYPE_VIDEO); @@ -230,7 +262,8 @@ public class DashRendererBuilder implements RendererBuilder, audioTrackNameList.add(format.id + " (" + format.numChannels + "ch, " + format.audioSamplingRate + "Hz)"); audioChunkSourceList.add(new DashChunkSource(manifestFetcher, audioAdaptationSetIndex, - new int[] {i}, audioDataSource, audioEvaluator, LIVE_EDGE_LATENCY_MS)); + new int[] {i}, audioDataSource, audioEvaluator, LIVE_EDGE_LATENCY_MS, + elapsedRealtimeOffset)); haveAc3Tracks |= AC_3_CODEC.equals(format.codecs) || E_AC_3_CODEC.equals(format.codecs); } // Filter out non-AC-3 tracks if there is an AC-3 track, to avoid having to switch renderers. @@ -285,7 +318,7 @@ public class DashRendererBuilder implements RendererBuilder, Representation representation = representations.get(j); textTrackNameList.add(representation.format.id); textChunkSourceList.add(new DashChunkSource(manifestFetcher, i, new int[] {j}, - textDataSource, textEvaluator, LIVE_EDGE_LATENCY_MS)); + textDataSource, textEvaluator, LIVE_EDGE_LATENCY_MS, elapsedRealtimeOffset)); } } } diff --git a/library/src/main/java/com/google/android/exoplayer/dash/DashChunkSource.java b/library/src/main/java/com/google/android/exoplayer/dash/DashChunkSource.java index 2a1d768048..7186412722 100644 --- a/library/src/main/java/com/google/android/exoplayer/dash/DashChunkSource.java +++ b/library/src/main/java/com/google/android/exoplayer/dash/DashChunkSource.java @@ -86,6 +86,7 @@ public class DashChunkSource implements ChunkSource { private final Evaluation evaluation; private final StringBuilder headerBuilder; private final long liveEdgeLatencyUs; + private final long elapsedRealtimeOffsetUs; private final int maxWidth; private final int maxHeight; @@ -140,7 +141,8 @@ public class DashChunkSource implements ChunkSource { */ public DashChunkSource(MediaPresentationDescription manifest, int adaptationSetIndex, int[] representationIndices, DataSource dataSource, FormatEvaluator formatEvaluator) { - this(null, manifest, adaptationSetIndex, representationIndices, dataSource, formatEvaluator, 0); + this(null, manifest, adaptationSetIndex, representationIndices, dataSource, formatEvaluator, 0, + 0); } /** @@ -162,18 +164,21 @@ public class DashChunkSource implements ChunkSource { * manifest). Choosing a small value will minimize latency introduced by the player, however * note that the value sets an upper bound on the length of media that the player can buffer. * Hence a small value may increase the probability of rebuffering and playback failures. + * @param elapsedRealtimeOffsetMs If known, an estimate of the instantaneous difference between + * server-side unix time and {@link SystemClock#elapsedRealtime()} in milliseconds, specified + * as the server's unix time minus the local elapsed time. It unknown, set to 0. */ public DashChunkSource(ManifestFetcher manifestFetcher, int adaptationSetIndex, int[] representationIndices, DataSource dataSource, - FormatEvaluator formatEvaluator, long liveEdgeLatencyMs) { + FormatEvaluator formatEvaluator, long liveEdgeLatencyMs, long elapsedRealtimeOffsetMs) { this(manifestFetcher, manifestFetcher.getManifest(), adaptationSetIndex, representationIndices, - dataSource, formatEvaluator, liveEdgeLatencyMs * 1000); + dataSource, formatEvaluator, liveEdgeLatencyMs * 1000, elapsedRealtimeOffsetMs * 1000); } private DashChunkSource(ManifestFetcher manifestFetcher, MediaPresentationDescription initialManifest, int adaptationSetIndex, int[] representationIndices, DataSource dataSource, FormatEvaluator formatEvaluator, - long liveEdgeLatencyUs) { + long liveEdgeLatencyUs, long elapsedRealtimeOffsetUs) { this.manifestFetcher = manifestFetcher; this.currentManifest = initialManifest; this.adaptationSetIndex = adaptationSetIndex; @@ -181,6 +186,7 @@ public class DashChunkSource implements ChunkSource { this.dataSource = dataSource; this.evaluator = formatEvaluator; this.liveEdgeLatencyUs = liveEdgeLatencyUs; + this.elapsedRealtimeOffsetUs = elapsedRealtimeOffsetUs; this.evaluation = new Evaluation(); this.headerBuilder = new StringBuilder(); @@ -326,8 +332,12 @@ public class DashChunkSource implements ChunkSource { return; } - // TODO: Use UtcTimingElement where possible. - long nowUs = System.currentTimeMillis() * 1000; + long nowUs; + if (elapsedRealtimeOffsetUs != 0) { + nowUs = (SystemClock.elapsedRealtime() * 1000) + elapsedRealtimeOffsetUs; + } else { + nowUs = System.currentTimeMillis() * 1000; + } int firstAvailableSegmentNum = segmentIndex.getFirstSegmentNum(); int lastAvailableSegmentNum = segmentIndex.getLastSegmentNum(); diff --git a/library/src/main/java/com/google/android/exoplayer/dash/mpd/UtcTimingElement.java b/library/src/main/java/com/google/android/exoplayer/dash/mpd/UtcTimingElement.java index cbcc30de7e..e2f452b543 100644 --- a/library/src/main/java/com/google/android/exoplayer/dash/mpd/UtcTimingElement.java +++ b/library/src/main/java/com/google/android/exoplayer/dash/mpd/UtcTimingElement.java @@ -28,4 +28,9 @@ public class UtcTimingElement { this.value = value; } + @Override + public String toString() { + return schemeIdUri + ", " + value; + } + } diff --git a/library/src/main/java/com/google/android/exoplayer/dash/mpd/UtcTimingElementResolver.java b/library/src/main/java/com/google/android/exoplayer/dash/mpd/UtcTimingElementResolver.java new file mode 100644 index 0000000000..3c457c6fd7 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/dash/mpd/UtcTimingElementResolver.java @@ -0,0 +1,186 @@ +/* + * Copyright (C) 2014 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.exoplayer.dash.mpd; + +import com.google.android.exoplayer.ParserException; +import com.google.android.exoplayer.upstream.Loader; +import com.google.android.exoplayer.upstream.Loader.Loadable; +import com.google.android.exoplayer.util.Assertions; +import com.google.android.exoplayer.util.NetworkLoadable; +import com.google.android.exoplayer.util.Util; + +import android.os.SystemClock; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Locale; +import java.util.concurrent.CancellationException; + +/** + * Resolves a {@link UtcTimingElement}. + */ +public class UtcTimingElementResolver implements Loader.Callback { + + /** + * Callback for timing element resolution. + */ + public interface UtcTimingCallback { + + /** + * Invoked when the element has been resolved. + * + * @param utcTiming The element that was resolved. + * @param elapsedRealtimeOffset The offset between the resolved UTC time and + * {@link SystemClock#elapsedRealtime()} in milliseconds, specified as the UTC time minus + * the local elapsed time. + */ + void onTimestampResolved(UtcTimingElement utcTiming, long elapsedRealtimeOffset); + + /** + * Invoked when the element was not successfully resolved. + * + * @param utcTiming The element that was not resolved. + * @param e The cause of the failure. + */ + void onTimestampError(UtcTimingElement utcTiming, IOException e); + } + + private static final int TYPE_XS = 0; + private static final int TYPE_ISO = 1; + + private final String userAgent; + private final UtcTimingElement timingElement; + private final long timingElementElapsedRealtime; + private final UtcTimingCallback callback; + + private Loader singleUseLoader; + private HttpTimestampLoadable singleUseLoadable; + + /** + * Resolves a {@link UtcTimingElement}. + * + * @param userAgent A user agent to use should network requests be necessary. + * @param timingElement The element to resolve. + * @param timingElementElapsedRealtime The {@link SystemClock#elapsedRealtime()} timestamp at + * which the element was obtained. Used if the element contains a timestamp directly. + * @param callback The callback to invoke on resolution or failure. + */ + public static void resolveTimingElement(String userAgent, UtcTimingElement timingElement, + long timingElementElapsedRealtime, UtcTimingCallback callback) { + UtcTimingElementResolver resolver = new UtcTimingElementResolver(userAgent, timingElement, + timingElementElapsedRealtime, callback); + resolver.resolve(); + } + + private UtcTimingElementResolver(String userAgent, UtcTimingElement timingElement, + long timingElementElapsedRealtime, UtcTimingCallback callback) { + this.userAgent = userAgent; + this.timingElement = Assertions.checkNotNull(timingElement); + this.timingElementElapsedRealtime = timingElementElapsedRealtime; + this.callback = Assertions.checkNotNull(callback); + } + + private void resolve() { + String scheme = timingElement.schemeIdUri; + if (Util.areEqual(scheme, "urn:mpeg:dash:utc:direct:2012")) { + resolveDirect(); + } else if (Util.areEqual(scheme, "urn:mpeg:dash:utc:http-iso:2014")) { + resolveHttp(TYPE_ISO); + } else if (Util.areEqual(scheme, "urn:mpeg:dash:utc:http-xsdate:2012") + || Util.areEqual(scheme, "urn:mpeg:dash:utc:http-xsdate:2014")) { + resolveHttp(TYPE_XS); + } else { + // Unsupported scheme. + callback.onTimestampError(timingElement, new IOException("Unsupported utc timing scheme")); + } + } + + private void resolveDirect() { + try { + long utcTimestamp = Util.parseXsDateTime(timingElement.value); + long elapsedRealtimeOffset = utcTimestamp - timingElementElapsedRealtime; + callback.onTimestampResolved(timingElement, elapsedRealtimeOffset); + } catch (ParseException e) { + callback.onTimestampError(timingElement, new ParserException(e)); + } + } + + private void resolveHttp(int type) { + singleUseLoader = new Loader("utctiming"); + singleUseLoadable = new HttpTimestampLoadable(timingElement.value, userAgent, type); + singleUseLoader.startLoading(singleUseLoadable, this); + } + + @Override + public void onLoadCanceled(Loadable loadable) { + onLoadError(loadable, new IOException("Load cancelled", new CancellationException())); + } + + @Override + public void onLoadCompleted(Loadable loadable) { + releaseLoader(); + long elapsedRealtimeOffset = singleUseLoadable.getResult() - SystemClock.elapsedRealtime(); + callback.onTimestampResolved(timingElement, elapsedRealtimeOffset); + } + + @Override + public void onLoadError(Loadable loadable, IOException exception) { + releaseLoader(); + callback.onTimestampError(timingElement, exception); + } + + private void releaseLoader() { + singleUseLoader.release(); + } + + private static class HttpTimestampLoadable extends NetworkLoadable { + + private final int type; + + public HttpTimestampLoadable(String url, String userAgent, int type) { + super(url, userAgent); + this.type = type; + } + + @Override + protected Long parse(String connectionUrl, InputStream inputStream, String inputEncoding) + throws ParserException, IOException { + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream)); + String firstLine = reader.readLine(); + try { + switch (type) { + case TYPE_XS: + return Util.parseXsDateTime(firstLine); + case TYPE_ISO: + // TODO: It may be necessary to handle timestamp offsets from UTC. + SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US); + return format.parse(firstLine).getTime(); + default: + // Never happens. + throw new RuntimeException(); + } + } catch (ParseException e) { + throw new ParserException(e); + } + } + + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/util/ManifestFetcher.java b/library/src/main/java/com/google/android/exoplayer/util/ManifestFetcher.java index 9aed794b22..7841ba032d 100644 --- a/library/src/main/java/com/google/android/exoplayer/util/ManifestFetcher.java +++ b/library/src/main/java/com/google/android/exoplayer/util/ManifestFetcher.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer.util; +import com.google.android.exoplayer.ParserException; import com.google.android.exoplayer.upstream.Loader; import com.google.android.exoplayer.upstream.Loader.Loadable; @@ -25,8 +26,6 @@ import android.util.Pair; import java.io.IOException; import java.io.InputStream; -import java.net.URL; -import java.net.URLConnection; import java.util.concurrent.CancellationException; /** @@ -84,7 +83,7 @@ public class ManifestFetcher implements Loader.Callback { private int enabledCount; private Loader loader; - private ManifestLoadable currentLoadable; + private ManifestLoadable currentLoadable; private int loadExceptionCount; private long loadExceptionTimestamp; @@ -204,7 +203,7 @@ public class ManifestFetcher implements Loader.Callback { loader = new Loader("manifestLoader"); } if (!loader.isLoading()) { - currentLoadable = new ManifestLoadable(); + currentLoadable = new ManifestLoadable(manifestUrl, userAgent, contentId, parser); loader.startLoading(currentLoadable, this); notifyManifestRefreshStarted(); } @@ -217,7 +216,7 @@ public class ManifestFetcher implements Loader.Callback { return; } - manifest = currentLoadable.result; + manifest = currentLoadable.getResult(); manifestLoadTimestamp = SystemClock.elapsedRealtime(); loadExceptionCount = 0; loadException = null; @@ -244,6 +243,11 @@ public class ManifestFetcher implements Loader.Callback { notifyManifestError(loadException); } + /* package */ void onSingleFetchCompleted(T result) { + manifest = result; + manifestLoadTimestamp = SystemClock.elapsedRealtime(); + } + private long getRetryDelayMillis(long errorCount) { return Math.min((errorCount - 1) * 1000, 5000); } @@ -286,13 +290,13 @@ public class ManifestFetcher implements Loader.Callback { private final Looper callbackLooper; private final ManifestCallback wrappedCallback; private final Loader singleUseLoader; - private final ManifestLoadable singleUseLoadable; + private final ManifestLoadable singleUseLoadable; public SingleFetchHelper(Looper callbackLooper, ManifestCallback wrappedCallback) { this.callbackLooper = callbackLooper; this.wrappedCallback = wrappedCallback; singleUseLoader = new Loader("manifestLoader:single"); - singleUseLoadable = new ManifestLoadable(); + singleUseLoadable = new ManifestLoadable(manifestUrl, userAgent, contentId, parser); } public void startLoading() { @@ -302,9 +306,9 @@ public class ManifestFetcher implements Loader.Callback { @Override public void onLoadCompleted(Loadable loadable) { try { - manifest = singleUseLoadable.result; - manifestLoadTimestamp = SystemClock.elapsedRealtime(); - wrappedCallback.onManifest(contentId, singleUseLoadable.result); + T result = singleUseLoadable.getResult(); + onSingleFetchCompleted(result); + wrappedCallback.onManifest(contentId, result); } finally { releaseLoader(); } @@ -336,50 +340,22 @@ public class ManifestFetcher implements Loader.Callback { } - private class ManifestLoadable implements Loadable { + private static class ManifestLoadable extends NetworkLoadable { - private static final int TIMEOUT_MILLIS = 10000; + private final String contentId; + private final ManifestParser parser; - /* package */ volatile T result; - private volatile boolean isCanceled; - - @Override - public void cancelLoad() { - // We don't actually cancel anything, but we need to record the cancellation so that - // isLoadCanceled can return the correct value. - isCanceled = true; + public ManifestLoadable(String url, String userAgent, String contentId, + ManifestParser parser) { + super(url, userAgent); + this.contentId = contentId; + this.parser = parser; } @Override - public boolean isLoadCanceled() { - return isCanceled; - } - - @Override - public void load() throws IOException, InterruptedException { - String inputEncoding; - InputStream inputStream = null; - try { - URLConnection connection = configureConnection(new URL(manifestUrl)); - inputStream = connection.getInputStream(); - inputEncoding = connection.getContentEncoding(); - result = parser.parse(inputStream, inputEncoding, contentId, - Util.parseBaseUri(connection.getURL().toString())); - } finally { - if (inputStream != null) { - inputStream.close(); - } - } - } - - private URLConnection configureConnection(URL url) throws IOException { - URLConnection connection = url.openConnection(); - connection.setConnectTimeout(TIMEOUT_MILLIS); - connection.setReadTimeout(TIMEOUT_MILLIS); - connection.setDoOutput(false); - connection.setRequestProperty("User-Agent", userAgent); - connection.connect(); - return connection; + protected T parse(String connectionUrl, InputStream inputStream, String inputEncoding) + throws ParserException, IOException { + return parser.parse(inputStream, inputEncoding, contentId, Util.parseBaseUri(connectionUrl)); } } diff --git a/library/src/main/java/com/google/android/exoplayer/util/NetworkLoadable.java b/library/src/main/java/com/google/android/exoplayer/util/NetworkLoadable.java new file mode 100644 index 0000000000..676e2380ef --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/util/NetworkLoadable.java @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2014 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.exoplayer.util; + +import com.google.android.exoplayer.ParserException; +import com.google.android.exoplayer.upstream.Loader.Loadable; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLConnection; + +/** + * A {@link Loadable} for loading an object over the network. + * + * @param The type of the object being loaded. + */ +public abstract class NetworkLoadable implements Loadable { + + public static final int DEFAULT_TIMEOUT_MILLIS = 10000; + + private final String url; + private final String userAgent; + private final int timeoutMillis; + + private volatile T result; + private volatile boolean isCanceled; + + /** + * @param url The url from which the object should be loaded. + * @param userAgent The user agent to use when requesting the object. + */ + public NetworkLoadable(String url, String userAgent) { + this(url, userAgent, DEFAULT_TIMEOUT_MILLIS); + } + + /** + * @param url The url from which the object should be loaded. + * @param userAgent The user agent to use when requesting the object. + * @param timeoutMillis The desired http timeout in milliseconds. + */ + public NetworkLoadable(String url, String userAgent, int timeoutMillis) { + this.url = url; + this.userAgent = userAgent; + this.timeoutMillis = timeoutMillis; + } + + /** + * Returns the loaded object, or null if an object has not been loaded. + */ + public final T getResult() { + return result; + } + + @Override + public final void cancelLoad() { + // We don't actually cancel anything, but we need to record the cancellation so that + // isLoadCanceled can return the correct value. + isCanceled = true; + } + + @Override + public final boolean isLoadCanceled() { + return isCanceled; + } + + @Override + public final void load() throws IOException, InterruptedException { + String inputEncoding; + InputStream inputStream = null; + try { + URLConnection connection = configureConnection(new URL(url)); + inputStream = connection.getInputStream(); + inputEncoding = connection.getContentEncoding(); + result = parse(connection.getURL().toString(), inputStream, inputEncoding); + } finally { + if (inputStream != null) { + inputStream.close(); + } + } + } + + /** + * Parses the raw data into an object. + * + * @param connectionUrl The url, after any redirection has taken place. + * @param inputStream An {@link InputStream} from which the raw data can be read. + * @param inputEncoding The encoding of the raw data, if available. + * @return The parsed object. + * @throws ParserException If an error occurs parsing the data. + * @throws IOException If an error occurs reading data from the stream. + */ + protected abstract T parse(String connectionUrl, InputStream inputStream, String inputEncoding) + throws ParserException, IOException; + + private URLConnection configureConnection(URL url) throws IOException { + URLConnection connection = url.openConnection(); + connection.setConnectTimeout(timeoutMillis); + connection.setReadTimeout(timeoutMillis); + connection.setDoOutput(false); + connection.setRequestProperty("User-Agent", userAgent); + connection.connect(); + return connection; + } + +}