Add AES-128 encryption support for HLS #69 and parsing logic for CODECS and RESOLUTION attributes.

This commit is contained in:
Andrey Udovenko 2014-11-04 13:38:22 -05:00
parent a21c9ebc31
commit a76addba5d
10 changed files with 267 additions and 15 deletions

View File

@ -111,7 +111,7 @@ public class HlsRendererBuilder implements RendererBuilder, ManifestCallback<Hls
private HlsMasterPlaylist newSimpleMasterPlaylist(String mediaPlaylistUrl) {
return new HlsMasterPlaylist(Uri.parse(""),
Collections.singletonList(new Variant(mediaPlaylistUrl, 0)));
Collections.singletonList(new Variant(mediaPlaylistUrl, 0, null, -1, -1)));
}
}

View File

@ -109,7 +109,7 @@ import java.util.Collections;
private HlsMasterPlaylist newSimpleMasterPlaylist(String mediaPlaylistUrl) {
return new HlsMasterPlaylist(Uri.parse(""),
Collections.singletonList(new Variant(mediaPlaylistUrl, 0)));
Collections.singletonList(new Variant(mediaPlaylistUrl, 0, null, -1, -1)));
}
}

View File

@ -18,6 +18,7 @@ package com.google.android.exoplayer.hls;
import com.google.android.exoplayer.C;
import com.google.android.exoplayer.MediaFormat;
import com.google.android.exoplayer.TrackRenderer;
import com.google.android.exoplayer.upstream.Aes128DataSource;
import com.google.android.exoplayer.upstream.DataSource;
import com.google.android.exoplayer.upstream.DataSpec;
import com.google.android.exoplayer.upstream.NonBlockingInputStream;
@ -28,7 +29,9 @@ import android.os.SystemClock;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.math.BigInteger;
import java.util.List;
import java.util.Locale;
/**
* A temporary test source of HLS chunks.
@ -38,7 +41,7 @@ import java.util.List;
*/
public class HlsChunkSource {
private final DataSource dataSource;
private final DataSource upstreamDataSource;
private final HlsMasterPlaylist masterPlaylist;
private final HlsMediaPlaylistParser mediaPlaylistParser;
@ -47,9 +50,12 @@ public class HlsChunkSource {
/* package */ boolean mediaPlaylistWasLive;
/* package */ long lastMediaPlaylistLoadTimeMs;
private DataSource encryptedDataSource;
private String encryptionKeyUri;
// TODO: Once proper m3u8 parsing is in place, actually use the url!
public HlsChunkSource(DataSource dataSource, HlsMasterPlaylist masterPlaylist) {
this.dataSource = dataSource;
this.upstreamDataSource = dataSource;
this.masterPlaylist = masterPlaylist;
mediaPlaylistParser = new HlsMediaPlaylistParser();
}
@ -144,8 +150,22 @@ public class HlsChunkSource {
}
HlsMediaPlaylist.Segment segment = mediaPlaylist.segments.get(chunkIndex);
Uri chunkUri = Util.getMergedUri(mediaPlaylist.baseUri, segment.url);
// Check if encryption is specified.
if (HlsMediaPlaylist.ENCRYPTION_METHOD_AES_128.equals(segment.encryptionMethod)) {
if (!segment.encryptionKeyUri.equals(encryptionKeyUri)) {
// Encryption is specified and the key has changed.
Uri keyUri = Util.getMergedUri(mediaPlaylist.baseUri, segment.encryptionKeyUri);
out.chunk = newEncryptionKeyChunk(keyUri, segment.encryptionIV);
encryptionKeyUri = segment.encryptionKeyUri;
return;
}
} else {
encryptedDataSource = null;
encryptionKeyUri = null;
}
DataSpec dataSpec = new DataSpec(chunkUri, 0, C.LENGTH_UNBOUNDED, null);
long startTimeUs = segment.startTimeUs;
@ -168,8 +188,15 @@ public class HlsChunkSource {
}
}
out.chunk = new TsChunk(dataSource, dataSpec, 0, startTimeUs, endTimeUs, nextChunkMediaSequence,
segment.discontinuity);
DataSource dataSource;
if (encryptedDataSource != null) {
dataSource = encryptedDataSource;
} else {
dataSource = upstreamDataSource;
}
out.chunk = new TsChunk(dataSource, dataSpec, 0, startTimeUs, endTimeUs,
nextChunkMediaSequence, segment.discontinuity);
}
private boolean shouldRerequestMediaPlaylist() {
@ -190,7 +217,12 @@ public class HlsChunkSource {
masterPlaylist.variants.get(0).url);
DataSpec dataSpec = new DataSpec(mediaPlaylistUri, 0, C.LENGTH_UNBOUNDED, null);
Uri mediaPlaylistBaseUri = Util.parseBaseUri(mediaPlaylistUri.toString());
return new MediaPlaylistChunk(dataSource, dataSpec, 0, mediaPlaylistBaseUri);
return new MediaPlaylistChunk(upstreamDataSource, dataSpec, 0, mediaPlaylistBaseUri);
}
private EncryptionKeyChunk newEncryptionKeyChunk(Uri keyUri, String iv) {
DataSpec dataSpec = new DataSpec(keyUri, 0, C.LENGTH_UNBOUNDED, null);
return new EncryptionKeyChunk(upstreamDataSource, dataSpec, 0, iv);
}
private class MediaPlaylistChunk extends HlsChunk {
@ -214,4 +246,35 @@ public class HlsChunkSource {
}
private class EncryptionKeyChunk extends HlsChunk {
private final String iv;
public EncryptionKeyChunk(DataSource dataSource, DataSpec dataSpec, int trigger, String iv) {
super(dataSource, dataSpec, trigger);
if (iv.toLowerCase(Locale.getDefault()).startsWith("0x")) {
this.iv = iv.substring(2);
} else {
this.iv = iv;
}
}
@Override
protected void consumeStream(NonBlockingInputStream stream) throws IOException {
byte[] keyData = new byte[(int) stream.getAvailableByteCount()];
stream.read(keyData, 0, keyData.length);
int ivParsed = Integer.parseInt(iv, 16);
String iv = String.format("%032X", ivParsed);
byte[] ivData = new BigInteger(iv, 16).toByteArray();
byte[] ivDataWithPadding = new byte[iv.length() / 2];
System.arraycopy(ivData, 0, ivDataWithPadding, ivDataWithPadding.length - ivData.length,
ivData.length);
encryptedDataSource = new Aes128DataSource(keyData, ivDataWithPadding, upstreamDataSource);
}
}
}

View File

@ -30,10 +30,16 @@ public final class HlsMasterPlaylist {
public static final class Variant {
public final int bandwidth;
public final String url;
public final String[] codecs;
public final int width;
public final int height;
public Variant(String url, int bandwidth) {
public Variant(String url, int bandwidth, String[] codecs, int width, int height) {
this.bandwidth = bandwidth;
this.url = url;
this.codecs = codecs;
this.width = width;
this.height = height;
}
}

View File

@ -36,9 +36,15 @@ public final class HlsMasterPlaylistParser implements ManifestParser<HlsMasterPl
private static final String STREAM_INF_TAG = "#EXT-X-STREAM-INF";
private static final String BANDWIDTH_ATTR = "BANDWIDTH";
private static final String CODECS_ATTR = "CODECS";
private static final String RESOLUTION_ATTR = "RESOLUTION";
private static final Pattern BANDWIDTH_ATTR_REGEX =
Pattern.compile(BANDWIDTH_ATTR + "=(\\d+)\\b");
private static final Pattern CODECS_ATTR_REGEX =
Pattern.compile(CODECS_ATTR + "=\"(.+)\"");
private static final Pattern RESOLUTION_ATTR_REGEX =
Pattern.compile(RESOLUTION_ATTR + "=(\\d+x\\d+)");
@Override
public HlsMasterPlaylist parse(InputStream inputStream, String inputEncoding,
@ -52,6 +58,10 @@ public final class HlsMasterPlaylistParser implements ManifestParser<HlsMasterPl
? new InputStreamReader(inputStream) : new InputStreamReader(inputStream, inputEncoding));
List<Variant> variants = new ArrayList<Variant>();
int bandwidth = 0;
String[] codecs = null;
int width = -1;
int height = -1;
String line;
while ((line = reader.readLine()) != null) {
line = line.trim();
@ -60,9 +70,29 @@ public final class HlsMasterPlaylistParser implements ManifestParser<HlsMasterPl
}
if (line.startsWith(STREAM_INF_TAG)) {
bandwidth = HlsParserUtil.parseIntAttr(line, BANDWIDTH_ATTR_REGEX, BANDWIDTH_ATTR);
String codecsString = HlsParserUtil.parseOptionalStringAttr(line, CODECS_ATTR_REGEX,
CODECS_ATTR);
if (codecsString != null) {
codecs = codecsString.split(",");
} else {
codecs = null;
}
String resolutionString = HlsParserUtil.parseOptionalStringAttr(line, RESOLUTION_ATTR_REGEX,
RESOLUTION_ATTR);
if (resolutionString != null) {
String[] widthAndHeight = resolutionString.split("x");
width = Integer.parseInt(widthAndHeight[0]);
height = Integer.parseInt(widthAndHeight[1]);
} else {
width = -1;
height = -1;
}
} else if (!line.startsWith("#")) {
variants.add(new Variant(line, bandwidth));
variants.add(new Variant(line, bandwidth, codecs, width, height));
bandwidth = 0;
codecs = null;
width = -1;
height = -1;
}
}
return new HlsMasterPlaylist(baseUri, Collections.unmodifiableList(variants));

View File

@ -32,12 +32,19 @@ public final class HlsMediaPlaylist {
public final double durationSecs;
public final String url;
public final long startTimeUs;
public final String encryptionMethod;
public final String encryptionKeyUri;
public final String encryptionIV;
public Segment(String uri, double durationSecs, boolean discontinuity, long startTimeUs) {
public Segment(String uri, double durationSecs, boolean discontinuity, long startTimeUs,
String encryptionMethod, String encryptionKeyUri, String encryptionIV) {
this.url = uri;
this.durationSecs = durationSecs;
this.discontinuity = discontinuity;
this.startTimeUs = startTimeUs;
this.encryptionMethod = encryptionMethod;
this.encryptionKeyUri = encryptionKeyUri;
this.encryptionIV = encryptionIV;
}
@Override
@ -46,6 +53,9 @@ public final class HlsMediaPlaylist {
}
}
public static final String ENCRYPTION_METHOD_NONE = "NONE";
public static final String ENCRYPTION_METHOD_AES_128 = "AES-128";
public final Uri baseUri;
public final int mediaSequence;
public final int targetDurationSecs;

View File

@ -40,6 +40,11 @@ public final class HlsMediaPlaylistParser implements ManifestParser<HlsMediaPlay
private static final String TARGET_DURATION_TAG = "#EXT-X-TARGETDURATION";
private static final String VERSION_TAG = "#EXT-X-VERSION";
private static final String ENDLIST_TAG = "#EXT-X-ENDLIST";
private static final String KEY_TAG = "#EXT-X-KEY";
private static final String METHOD_ATTR = "METHOD";
private static final String URI_ATTR = "URI";
private static final String IV_ATTR = "IV";
private static final Pattern MEDIA_DURATION_REGEX =
Pattern.compile(MEDIA_DURATION_TAG + ":([\\d.]+),");
@ -50,6 +55,13 @@ public final class HlsMediaPlaylistParser implements ManifestParser<HlsMediaPlay
private static final Pattern VERSION_REGEX =
Pattern.compile(VERSION_TAG + ":(\\d+)\\b");
private static final Pattern METHOD_ATTR_REGEX =
Pattern.compile(METHOD_ATTR + "=([^,.*]+)");
private static final Pattern URI_ATTR_REGEX =
Pattern.compile(URI_ATTR + "=\"(.+)\"");
private static final Pattern IV_ATTR_REGEX =
Pattern.compile(IV_ATTR + "=([^,.*]+)");
@Override
public HlsMediaPlaylist parse(InputStream inputStream, String inputEncoding,
String contentId, Uri baseUri) throws IOException {
@ -70,6 +82,11 @@ public final class HlsMediaPlaylistParser implements ManifestParser<HlsMediaPlay
double segmentDurationSecs = 0.0;
boolean segmentDiscontinuity = false;
long segmentStartTimeUs = 0;
String segmentEncryptionMethod = null;
String segmentEncryptionKeyUri = null;
String segmentEncryptionIV = null;
int segmentMediaSequence = 0;
String line;
while ((line = reader.readLine()) != null) {
@ -82,16 +99,34 @@ public final class HlsMediaPlaylistParser implements ManifestParser<HlsMediaPlay
TARGET_DURATION_TAG);
} else if (line.startsWith(MEDIA_SEQUENCE_TAG)) {
mediaSequence = HlsParserUtil.parseIntAttr(line, MEDIA_SEQUENCE_REGEX, MEDIA_SEQUENCE_TAG);
segmentMediaSequence = mediaSequence;
} else if (line.startsWith(VERSION_TAG)) {
version = HlsParserUtil.parseIntAttr(line, VERSION_REGEX, VERSION_TAG);
} else if (line.startsWith(MEDIA_DURATION_TAG)) {
segmentDurationSecs = HlsParserUtil.parseDoubleAttr(line, MEDIA_DURATION_REGEX,
MEDIA_DURATION_TAG);
} else if (line.startsWith(KEY_TAG)) {
segmentEncryptionMethod = HlsParserUtil.parseStringAttr(line, METHOD_ATTR_REGEX,
METHOD_ATTR);
if (segmentEncryptionMethod.equals(HlsMediaPlaylist.ENCRYPTION_METHOD_NONE)) {
segmentEncryptionKeyUri = null;
segmentEncryptionIV = null;
} else {
segmentEncryptionKeyUri = HlsParserUtil.parseStringAttr(line, URI_ATTR_REGEX,
URI_ATTR);
segmentEncryptionIV = HlsParserUtil.parseOptionalStringAttr(line, IV_ATTR_REGEX,
IV_ATTR);
if (segmentEncryptionIV == null) {
segmentEncryptionIV = Integer.toHexString(segmentMediaSequence);
}
}
} else if (line.equals(DISCONTINUITY_TAG)) {
segmentDiscontinuity = true;
} else if (!line.startsWith("#")) {
segmentMediaSequence++;
segments.add(new Segment(line, segmentDurationSecs, segmentDiscontinuity,
segmentStartTimeUs));
segmentStartTimeUs, segmentEncryptionMethod, segmentEncryptionKeyUri,
segmentEncryptionIV));
segmentStartTimeUs += (long) (segmentDurationSecs * 1000000);
segmentDiscontinuity = false;
segmentDurationSecs = 0.0;

View File

@ -36,6 +36,14 @@ import java.util.regex.Pattern;
throw new ParserException(String.format("Couldn't match %s tag in %s", tag, line));
}
public static String parseOptionalStringAttr(String line, Pattern pattern, String tag) {
Matcher matcher = pattern.matcher(line);
if (matcher.find() && matcher.groupCount() == 1) {
return matcher.group(1);
}
return null;
}
public static int parseIntAttr(String line, Pattern pattern, String tag)
throws ParserException {
return Integer.parseInt(parseStringAttr(line, pattern, tag));

View File

@ -184,10 +184,14 @@ public class HlsSampleSource implements SampleSource, Loader.Callback {
haveSufficientSamples = true;
} else {
extractor.reset(mediaChunk.startTimeUs);
for (int i = 0; i < pendingDiscontinuities.length; i++) {
pendingDiscontinuities[i] = true;
}
mediaChunk.clearPendingDiscontinuity();
if (pendingDiscontinuities == null) {
// We're not prepared yet.
} else {
for (int i = 0; i < pendingDiscontinuities.length; i++) {
pendingDiscontinuities[i] = true;
}
}
}
}

View File

@ -0,0 +1,96 @@
/*
* 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.upstream;
import com.google.android.exoplayer.C;
import com.google.android.exoplayer.util.Assertions;
import java.io.IOException;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.Key;
import java.security.NoSuchAlgorithmException;
import java.security.spec.AlgorithmParameterSpec;
import javax.crypto.Cipher;
import javax.crypto.CipherInputStream;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
/**
* A {@link DataSource} that decrypts the data read from an upstream source, encrypted with AES-128
* with a 128-bit key and PKCS7 padding.
*
*/
public class Aes128DataSource implements DataSource {
private final DataSource upstream;
private final byte[] secretKey;
private final byte[] iv;
private Cipher cipher;
private CipherInputStream cipherInputStream;
public Aes128DataSource(byte[] secretKey, byte[] iv, DataSource upstream) {
this.upstream = upstream;
this.secretKey = secretKey;
this.iv = iv;
}
@Override
public long open(DataSpec dataSpec) throws IOException {
try {
cipher = Cipher.getInstance("AES/CBC/PKCS7Padding");
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
} catch (NoSuchPaddingException e) {
throw new RuntimeException(e);
}
Key cipherKey = new SecretKeySpec(secretKey, "AES");
AlgorithmParameterSpec cipherIV = new IvParameterSpec(iv);
try {
cipher.init(Cipher.DECRYPT_MODE, cipherKey, cipherIV);
} catch (InvalidKeyException e) {
throw new RuntimeException(e);
} catch (InvalidAlgorithmParameterException e) {
throw new RuntimeException(e);
}
cipherInputStream = new CipherInputStream(
new DataSourceInputStream(upstream, dataSpec), cipher);
return C.LENGTH_UNBOUNDED;
}
@Override
public void close() throws IOException {
upstream.close();
}
@Override
public int read(byte[] buffer, int offset, int readLength) throws IOException {
Assertions.checkState(cipherInputStream != null);
int bytesRead = cipherInputStream.read(buffer, offset, readLength);
if (bytesRead < 0) {
return -1;
}
return bytesRead;
}
}