2017/12/21

Android: ExoPlayer - Downloader

ExoPlayer 2.6.0からDownloaderが追加されたので実装を追った際のメモ書き.

Download

ダウンローダの構築に必要な情報を持つビルドパラメータクラスDownloaderConstructorHelper
ダウンローダのコンストラクタ引数に使われる.

public SegmentDownloader(Uri manifestUri, DownloaderConstructorHelper constructorHelper) {

ダウンロードメイン処理

// SegmentDownloader#download
  @Override
  public final synchronized void download(@Nullable ProgressListener listener)
      throws IOException, InterruptedException {
    priorityTaskManager.add(C.PRIORITY_DOWNLOAD);
    try {
      getManifestIfNeeded(false);
      List<Segment> segments = initStatus(false);
      notifyListener(listener); // Initial notification.
      Collections.sort(segments);
      byte[] buffer = new byte[BUFFER_SIZE_BYTES];
      CachingCounters cachingCounters = new CachingCounters();
      for (int i = 0; i < segments.size(); i++) {
        CacheUtil.cache(segments.get(i).dataSpec, cache, dataSource, buffer,
            priorityTaskManager, C.PRIORITY_DOWNLOAD, cachingCounters, true);
        downloadedBytes += cachingCounters.newlyCachedBytes;
        downloadedSegments++;
        notifyListener(listener);
      }
    } finally {
      priorityTaskManager.remove(C.PRIORITY_DOWNLOAD);
    }
  }

ダウンロード済みのコンテンツは再ダウンロード時にスキップされる

// CacheUtil#cache(DataSpec, Cache, CacheDataSource, byte[], PriorityTaskManager, int, CachingCounters, boolean)
      long blockLength = cache.getCachedBytes(key, start,
          left != C.LENGTH_UNSET ? left : Long.MAX_VALUE);
      if (blockLength > 0) {
        // Skip already cached data.

MasterPlaylist or MediaPlaylist?

ダウンロードするURLはMasterPlaylist or MediaPlaylist どちらかで, 内部ではMasterPlaylistではない(MediaPlaylistの)場合にSingleVariantMasterPlaylistとして扱うようにしている.

// HlsDownloader#getManifest
  @Override
  protected HlsMasterPlaylist getManifest(DataSource dataSource, Uri uri) throws IOException {
    HlsPlaylist hlsPlaylist = loadManifest(dataSource, uri);
    if (hlsPlaylist instanceof HlsMasterPlaylist) {
      return (HlsMasterPlaylist) hlsPlaylist;
    } else {
      return HlsMasterPlaylist.createSingleVariantMasterPlaylist(hlsPlaylist.baseUri);
    }
  }

ダウンロードをとめる.

ダウンロードを停止するには Thread.currentThread().interrupt(); を使う.
割り込みをチェックする(停止できる)タイミングは

1 . 各セグメント毎の読み込み前
2. セグメントの指定バッファサイズ読み込みの都度

ダウンロードコンテンツの永続化

SegmentDownloaderはオンラインデータソース(dataSource)とオフラインデータソース(offlineDataSource)をそれぞれ持っている.
それぞれのデータソースはDownloadConstructorHelperで生成される.
オンラインデータソースはコンストラクタで指定されたファクトリから生成されるデータソースを持つCacheDataSourceが作られる.
オフラインデータソースはデフォルトでFileDataSourceを持つCacheDataSourceが作られる.
また, オンラインデータソースにはキャッシュに情報を書き込むCacheDataSinkがデフォルトで設定される.

// DownloaderConstructorHelper#buildCacheDataSource
  public CacheDataSource buildCacheDataSource(boolean offline) {
    DataSource cacheReadDataSource = cacheReadDataSourceFactory != null
        ? cacheReadDataSourceFactory.createDataSource() : new FileDataSource();
    if (offline) {
      return new CacheDataSource(cache, DummyDataSource.INSTANCE,
          cacheReadDataSource, null, CacheDataSource.FLAG_BLOCK_ON_CACHE, null);
    } else {
      DataSink cacheWriteDataSink = cacheWriteDataSinkFactory != null
          ? cacheWriteDataSinkFactory.createDataSink()
          : new CacheDataSink(cache, CacheDataSource.DEFAULT_MAX_CACHE_FILE_SIZE);
      DataSource upstream = upstreamDataSourceFactory.createDataSource();
      upstream = priorityTaskManager == null ? upstream
          : new PriorityDataSource(upstream, priorityTaskManager, C.PRIORITY_DOWNLOAD);
      return new CacheDataSource(cache, upstream, cacheReadDataSource,
          cacheWriteDataSink, CacheDataSource.FLAG_BLOCK_ON_CACHE, null);
    }

オンラインデータソースではcacheWriteDataSourceの設定が行われる.
DownloaderConstructorHelper#buildCacheDataSourceで特に指定がない限り,
cacheWriteDataSourceには, オンラインデータソース(upstream)とCacheDataSinkが設定されたTeeDataSourceが指定される.
これで, オンラインデータソースの情報がCacheに保存される.

// CacheDataSource#CacheDataSource(...)
  public CacheDataSource(Cache cache, DataSource upstream, DataSource cacheReadDataSource,
      DataSink cacheWriteDataSink, @Flags int flags, @Nullable EventListener eventListener) {
    ...
    if (cacheWriteDataSink != null) {
      this.cacheWriteDataSource = new TeeDataSource(upstream, cacheWriteDataSink);

CacheDataSourcewriteDataSinkが指定されている場合は, TeeDataSourcecurrentDataSourceとして設定する.

// CacheDataSource#openNextSource
    if (cacheWriteDataSource != null) {
    currentDataSource = cacheWriteDataSource;
    lockedSpan = span;
    } else {
    currentDataSource = upstreamDataSource;
    cache.releaseHoleSpan(span);
    }

TeeDataSourceはオンラインデータソースの情報をdataSinkに書き込みながら読み込み処理を行う.

// TeeDataSource#read
  @Override
  public int read(byte[] buffer, int offset, int max) throws IOException {
    int num = upstream.read(buffer, offset, max);
    if (num > 0) {
      // TODO: Consider continuing even if disk writes fail.
      dataSink.write(buffer, offset, num);
    }
    return num;
  }

ここで書き込まれているdataSinkDownloaderConstructorHelperで生成された(デフォルトだと)CacheDataSinkになる.

// DownloaderConstructorHelper#buildCacheDataSource
      DataSink cacheWriteDataSink = cacheWriteDataSinkFactory != null
          ? cacheWriteDataSinkFactory.createDataSink()
          : new CacheDataSink(cache, CacheDataSource.DEFAULT_MAX_CACHE_FILE_SIZE);

CacheDataSink.writeによってファイルへの書き込みが行われる.

// CacheDataSink#openNextOutputStream
    cache.startFile(dataSpec.key, dataSpec.absoluteStreamPosition + dataSpecBytesWritten,
        maxLength);
    underlyingFileOutputStream = new FileOutputStream(file);
    if (bufferSize > 0) {
      if (bufferedOutputStream == null) {
        bufferedOutputStream = new ReusableBufferedOutputStream(underlyingFileOutputStream,
            bufferSize);
      } else {
        bufferedOutputStream.reset(underlyingFileOutputStream);
      }
      outputStream = bufferedOutputStream;

// CacheDataSink#write
    outputStream.write(buffer, offset + bytesWritten, bytesToWrite);

書き込まれる対象のファイルはChache.startFileから取得できる.

// SimpleCache#startFile
    return SimpleCacheSpan.getCacheFile(cacheDir, index.assignIdForKey(key), position,
        System.currentTimeMillis());

SimpleCacheSpan.getCacheFileでキャッシュするべきファイルパスを取得できる.

// SimpleCacheSpan#getCacheFile
  return new File(cacheDir, id + "." + position + "." + lastAccessTimestamp + SUFFIX);

SegmentDownloader.downloadで各セグメントをキャッシュする.

// SegmentDownloader#download
for (int i = 0; i < segments.size(); i++) {
CacheUtil.cache(segments.get(i).dataSpec, cache, dataSource, buffer,
    priorityTaskManager, C.PRIORITY_DOWNLOAD, cachingCounters, true);

CacheUtil.cacheにより, オンラインストリームの情報が永続化される.

// CacheUtil#cache(...)
while (left != 0) {
    long blockLength = cache.getCachedBytes(key, start,
        left != C.LENGTH_UNSET ? left : Long.MAX_VALUE);
    if (blockLength > 0) {
      // Skip already cached data.

キャッシュヒットした場合はオンラインソースからの情報取得をスキップする.

// CacheUtil#cache(...)
    if (blockLength > 0) {
      // Skip already cached data.
    } else {
      // There is a hole in the cache which is at least "-blockLength" long.
    blockLength = -blockLength;
    long read = readAndDiscard(dataSpec, start, blockLength, dataSource, buffer,
        priorityTaskManager, priority, counters);

キャッシュヒットしなかった場合はデータソースから読み込む.
このデータソースはTeeDataSourceであるため, オンラインデータソースから読み込みながらキャッシュへ書き込むことになる.

// CacheUtil#readAndDiscard
    int read = dataSource.read(buffer, 0,
        length != C.LENGTH_UNSET ? (int) Math.min(buffer.length, length - totalRead)
            : buffer.length);

// CacheUtil#cache(DataSpec, Cache, DataSource, CachingCounters)
cache(dataSpec, cache, new CacheDataSource(cache, upstream),
        new byte[DEFAULT_BUFFER_SIZE_BYTES], null, 0, counters, false);

キャッシュインデックスファイル: cached_content_index.exi
キャッシュコンテンツファイル:下記パターンにマッチするファイル名

Matcher matcher = CACHE_FILE_PATTERN_V3.matcher(name);

キャッシュコンテンツのバージョンが古い場合(現時点だと.v1.exo, or .v2.exoなファイル)はアップグレード処理の機能が動く.

// SimpleCacheSpan#upgradeFile
  private static File upgradeFile(File file, CachedContentIndex index) {
    String key;
    String filename = file.getName();
    Matcher matcher = CACHE_FILE_PATTERN_V2.matcher(filename);
    if (matcher.matches()) {
      key = Util.unescapeFileName(matcher.group(1));
      if (key == null) {
        return null;
      }
    } else {
      matcher = CACHE_FILE_PATTERN_V1.matcher(filename);
      if (!matcher.matches()) {
        return null;
      }
      key = matcher.group(1); // Keys were not escaped in version 1.
    }

    File newCacheFile = getCacheFile(file.getParentFile(), index.assignIdForKey(key),
        Long.parseLong(matcher.group(2)), Long.parseLong(matcher.group(3)));
    if (!file.renameTo(newCacheFile)) {
      return null;
    }
    return newCacheFile;
  }

これらに該当しないファイル(ExoDownloader管理外ファイル)は不要ファイルとしてSimpleCache.initialize()で削除される.

// SimpleCache#initialize
    File[] files = cacheDir.listFiles();
    if (files == null) {
      return;
    }
    for (File file : files) {
      if (file.getName().equals(CachedContentIndex.FILE_NAME)) {
        continue;
      }
      SimpleCacheSpan span = file.length() > 0
          ? SimpleCacheSpan.createCacheEntry(file, index) : null;
      if (span != null) {
        addSpan(span);
      } else {
        file.delete();
      }
    }

インデックス

CachedContentIndexのコンストラクタパラメータencrypttrueにすればインデックスファイルが暗号化される.

// CachedContentIndex#CachedContentIndex(File, byte[], boolean)
  public CachedContentIndex(File cacheDir, byte[] secretKey, boolean encrypt) {
    this.encrypt = encrypt;
    if (secretKey != null) {
      Assertions.checkArgument(secretKey.length == 16);
      try {
        cipher = getCipher();
        secretKeySpec = new SecretKeySpec(secretKey, "AES");
      } catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
        throw new IllegalStateException(e); // Should never happen.
      }
...

// CachedContentIndex#getChipher
    // Workaround for https://issuetracker.google.com/issues/36976726
    if (Util.SDK_INT == 18) {
      try {
        return Cipher.getInstance("AES/CBC/PKCS5PADDING", "BC");
      } catch (Throwable ignored) {
        // ignored
      }
    }
    return Cipher.getInstance("AES/CBC/PKCS5PADDING");
...

// CachedContentIndex#writeFile
      if (encrypt) {
        byte[] initializationVector = new byte[16];
        new Random().nextBytes(initializationVector);
        output.write(initializationVector);
        IvParameterSpec ivParameterSpec = new IvParameterSpec(initializationVector);
        try {
          cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec);
        } catch (InvalidKeyException | InvalidAlgorithmParameterException e) {
          throw new IllegalStateException(e); // Should never happen.
        }
        output.flush();
        output = new DataOutputStream(new CipherOutputStream(bufferedOutputStream, cipher));
      }
...

インデックスファイルを読み込む

// CachedContentIndex#readFile
      int count = input.readInt();
      int hashCode = 0;
      for (int i = 0; i < count; i++) {
        CachedContent cachedContent = new CachedContent(input);
        add(cachedContent);
        hashCode += cachedContent.headerHashCode();
      }

キャッシュファイルはインデックス情報と紐づけて管理されている.
インデックス情報のクラスはSimpleCacheクラスで生成される.

// SimpleCache#SimpleCache(File, CacheEvictor, byte[], boolean)
  this(cacheDir, evictor, new CachedContentIndex(cacheDir, secretKey, encrypt));

インデックス情報はSimpleCacheの初期化時に読み込まれる.

// SimpleCache#initialize
  index.load();

インデックス情報が格納されたファイルは下記のような構造になっており, フォーマットにしたがって順番にロードされる.
(暗号化されている場合はIV情報が格納されて, number_of_CachedContent以降が暗号化される)

  private final byte[] testIndexV1File = {
      0, 0, 0, 1, // version
      0, 0, 0, 0, // flags
      (byte) 0xFA, 0x12, ..., // IV
      0, 0, 0, 2, // number_of_CachedContent
      // number_of_CachedContentの分格納される
          0, 0, 0, 5, // cache_id
          0, 5, 65, 66, 67, 68, 69, // cache_key
          0, 0, 0, 0, 0, 0, 0, 10, // original_content_length
      (byte) 0xF6, (byte) 0xFB, 0x50, 0x41 // hashcode_of_CachedContent_array
  };


// CachedContentIndex#readFile
  DataInputStream inputStream = new DataInputStream(new BufferedInputStream(atomicFile.openRead()));
  int version = input.readInt();
  int flags = input.readInt();
  if ((flags & FLAG_ENCRYPTED_INDEX) != 0) input.readFully(initializationVector);
  int count = input.readInt();
  for (int i = 0; i < count; i++) {
    CachedContent cachedContent = new CachedContent(input);
    add(cachedContent)
    hashCode += cachedContent.headerHashCode();
  }
  if (input.readInt() != hashCode) return false;

CachedContentの情報を読み込む際にインデックス情報をメモリにロードする.
上記のインデックス情報にはコンテンツのメタ情報が格納されている.

  • id: 元のストリームを識別するためのファイルID
  • key: 元のストリームを識別するためのキー
  • length: 元のストリームの長さ
// CachedContentIndex#add(CachedContent)
  private void add(CachedContent cachedContent) {
    keyToContent.put(cachedContent.key, cachedContent);
    idToKey.put(cachedContent.id, cachedContent.key);
  }

鍵の保存

HlsDownloader.loadManifestでマニフェストがパース・ロードされる.

// HlsDownloader#loadManifest
    ParsingLoadable<HlsPlaylist> loadable = new ParsingLoadable<>(dataSource, dataSpec,
        C.DATA_TYPE_MANIFEST, new HlsPlaylistParser());
    loadable.load();

ロード時にはTeeDataSourceが設定されたDataSourceInputStreamから読み込まれるため,
マニフェストはこのタイミングで永続化される.

    DataSourceInputStream inputStream = new DataSourceInputStream(dataSource, dataSpec);
    try {
      inputStream.open();
      result = parser.parse(dataSource.getUri(), inputStream);

さらに, パース処理ではマニフェストの先頭から各行ごとに解析される.
鍵の情報はSegmentインスタンスにも記録されていく.

// HlsPlaylistParser#parse
        if (line.isEmpty()) {
          // Do nothing.
        } else if (line.startsWith(TAG_STREAM_INF)) {
          extraLines.add(line);
          return parseMasterPlaylist(new LineIterator(extraLines, reader), uri.toString());
        } else if (line.startsWith(TAG_TARGET_DURATION)
            || line.startsWith(TAG_MEDIA_SEQUENCE)
            || line.startsWith(TAG_MEDIA_DURATION)
            || line.startsWith(TAG_KEY)
            || line.startsWith(TAG_BYTERANGE)
            || line.equals(TAG_DISCONTINUITY)
            || line.equals(TAG_DISCONTINUITY_SEQUENCE)
            || line.equals(TAG_ENDLIST)) {
          extraLines.add(line);
          return parseMediaPlaylist(new LineIterator(extraLines, reader), uri.toString());

// HlsPlaylistParser#parseMediaPlaylist
      } else if (line.startsWith(TAG_KEY)) {
        String method = parseStringAttr(line, REGEX_METHOD);
        String keyFormat = parseOptionalStringAttr(line, REGEX_KEYFORMAT);
        encryptionKeyUri = null;
        encryptionIV = null;
        if (!METHOD_NONE.equals(method)) {
          encryptionIV = parseOptionalStringAttr(line, REGEX_IV);
          if (KEYFORMAT_IDENTITY.equals(keyFormat) || keyFormat == null) {
            if (METHOD_AES_128.equals(method)) {
              // The segment is fully encrypted using an identity key.
              encryptionKeyUri = parseStringAttr(line, REGEX_URI);
            } else {
              // Do nothing. Samples are encrypted using an identity key, but this is not supported.
              // Hopefully, a traditional DRM alternative is also provided.
            }
      ...
      } else if (!line.startsWith("#")) {
        String segmentEncryptionIV;
        if (encryptionKeyUri == null) {
          segmentEncryptionIV = null;
        } else if (encryptionIV != null) {
          segmentEncryptionIV = encryptionIV;
        } else {
          segmentEncryptionIV = Integer.toHexString(segmentMediaSequence);
        }
        segments.add(new Segment(line, segmentDurationUs, relativeDiscontinuitySequence,
            segmentStartTimeUs, encryptionKeyUri, segmentEncryptionIV,
            segmentByteRangeOffset, segmentByteRangeLength));

ここで追加されたSegmentはダウンローダが保存する形式のSegmentに変換される.
プレイリストのセグメント:
HlsMediaPlaylist.Segment#Segment

ダウンローダのセグメント:
SegmentDownloader.Segment

変換はHlsDownloader#addSegmentで行われる.
Segmentに鍵情報が格納されているので, fullSegmentEncryptionKeyUri != nullとなる.
encryptionKeyUrisHashSetなので, 新しい鍵Uriの場合にencryptionKeyUris.add(keyUri)trueを返し,
その鍵のURI情報はDataSpecuriとして格納され, 一つのセグメントとしてコレクションに追加される.

// HlsDownloader#addSegment
    if (hlsSegment.fullSegmentEncryptionKeyUri != null) {
      Uri keyUri = UriUtil.resolveToUri(mediaPlaylist.baseUri,
          hlsSegment.fullSegmentEncryptionKeyUri);
      if (encryptionKeyUris.add(keyUri)) {
        segments.add(new Segment(startTimeUs, new DataSpec(keyUri)));
      }
    }
    Uri resolvedUri = UriUtil.resolveToUri(mediaPlaylist.baseUri, hlsSegment.url);
    segments.add(new Segment(startTimeUs,
        new DataSpec(resolvedUri, hlsSegment.byterangeOffset, hlsSegment.byterangeLength, null)));

セグメントのコレクションはSegmentDownloaderによってキャッシュされるので, 結果的に鍵情報も同じように永続化される.

      for (int i = 0; i < segments.size(); i++) {
        CacheUtil.cache(segments.get(i).dataSpec, cache, dataSource, buffer,
            priorityTaskManager, C.PRIORITY_DOWNLOAD, cachingCounters, true);

.v3.exo

ファイルサイズの上限はCacheDataSource#DEFAULT_MAX_CACHE_FILE_SIZEで定義されており,
デフォルトで2MiB(2 * 1024 * 1024)が指定されている.
これを変更するにはDownloaderConstructorHelperのコンストラクタ引数cacheWriteDataSinkFactoryに自前のDataSink.Factoryを設定する.

Cache cache = new SimpleCache(dir, new NoOpCacheEvictor());
DownloaderConstructorHelper constructor =
    new DownloaderConstructorHelper(cache,
        new DefaultHttpDataSourceFactory("ExoPlayer", null),
        null,
        new CacheDataSinkFactory(cache, 20480),
        null);

.v3.exoの1ファイルあたりの上限サイズは2MiBがデフォルトで, これを超えると次の.v3.exoファイルに分割保存される.
.v3.exoSegmentDownloader.Segmentの単位で保存され, のファイル名は
<id>.<ストリームの書き込みバイト位置>.<ファイル書き込み時のタイムスタンプ>.<バージョン>.exo
の形式で決まる.

idはSegmentDownloader.Segmentの単位で管理されており, idが同じであれば同じセグメントを指す.
SegmentDownloader.SegmentはPlaylistや.ts, 暗号キーといった単位を表現する)
セグメントが変わればidも変わるため, .exoの書き込み容量が上限サイズを迎えていなくても次のファイル名に変わる.

分割保存された.exoファイルを, 後に結合するためには前述のcached_content_index.exiにある
インデックス情報(id, key, content length)を使って復元される.

暗号化

.exiは平文で保存されるのがデフォルトの挙動.
これを暗号化して保存したい場合はSimpleCacheに秘密鍵を渡すことで実現できる.

byte[] secretKey = "Bar12345Bar12345".getBytes("UTF-8")
new SimpleCache(cacheDir, new NoOpCacheEvictor(), secretKey);
2017/11/07

Android: デフォルトで@NonNull扱いにする

JSR 305’s @ParametersAreNonnullByDefault を使うと, @Nullable でアノテートされていないメソッド, 引数, フィールドが @NonNull アノテートされているように解釈され, @NonNull アノテートされているのと同じ振る舞いになります.

プロジェクトによっては, @NonNull を明示することが煩わしく, @Nullable のみを定義することにして, それ以外は @NonNull 扱いとするルールを採用しているところもあるかと思います.
ただ, これではIDEが提供するNull安全のインスペクションメッセージによる恩恵を受けることができず, 実装者が “@Nullableをつけ忘れていた” なんて悲劇を招く可能性もあります.

“Tool, not Rules” ということで, デフォルトの振舞いを @NonNull にしたいときは, @ParametersAreNonnullByDefault が使えます.
このアノテーションはクラス単位でつけることもできますが, 例えば次のようにパッケージ単位でも指定できるため, プロジェクトのデフォルト設定としても役に立ちます.

com.android.myappフォルダに↓のような package-info.java を用意すれば, パッケージ単位で @NonNull アノテーションが有効になります.

// com.android.myapp.package-info.java
@javax.annotation.ParametersAreNonnullByDefault
package com.android.myapp;

JavaからKotlin化する際には @Nullable / @NonNull が定義されていると, とても移行しやすいですのでおすすめです.

以上です.