2021/08/23

Maven Central Repositoryにライブラリを公開する

公式手順はここで説明されています。
The Central Repository Documentation - Getting started

本稿では必要な手順を端的に書いていきます。

1. Create your JIRA account & Issue

Maven Central Repositoryにあなたのレポジトリを作成するには申請する必要があります。
申請はJIRAチケットで行われますので、下記からアカウントを作ります。
https://issues.sonatype.org/secure/Signup!default.jspa

アカウントを作ったら下記リンクからリポジトリ作成のIssueを作ります。
https://issues.sonatype.org/secure/CreateIssue.jspa?issuetype=21&pid=10134

フィールド
要約 プロジェクト名など 例:YukiMatsumura / koma
説明 READMEなどプロジェクトの概要
Group Id あなたのプロジェクトであることを示す識別子. *後述
Project URL プロジェクトページのURL. 例:https://github.com/YukiMatsumura/koma
SCM url GitのURLなど. 例:https://github.com/YukiMatsumura/koma.git
Username
Already Synced to Central No

Group Id

まずはここを読んだようがいいです。
https://central.sonatype.org/publish/requirements/coordinates/

Group Idはよくあるimplementation指定で使われるもので, 下記でいうと io.github.yukimatsumura がGroup Idになります。

implementation 'io.github.yukimatsumura:koma:0.2'

あなたが今後Maven Central RepositoryにリリースするであろうすべてのプロジェクトがこのGroup Idに紐づきます。
例えば、example.comを管理している場合、com.example.domaincom.example.testsupportなど、com.exampleで始まるGroup Idを使用することができます。

注意
ここで指定するGroupIdに紐づくドメインを所有または管理している必要があります。Issueで申請後、ドメインの所有/管理していることの証明を求められます。
ただし、GitHubやGitLabなど特定のコードホスティングサービスであればドメインの所有権がなくても、個人アカウントレベルのドメインをサポートしています。
https://central.sonatype.org/publish/requirements/coordinates/#supported-code-hosting-services-for-personal-groupid
例えば github.com/yourusername のアカウントであれば io.github.yourusername をGroup Idとして登録できます。

GitHubなどコードホスティングサービスの個人ページをGroup Idに指定した場合
指定のGroup Idがあなたの管理下にあることを証明する必要があります。
作成したIssueのチケット名で空のリポジトリを作成し、アカウントの所有権を証明しましょう。
例:io.github.myusernameをGroupIdに指定し管理している場合、チケット名OSSRH-*****を名前にしたリポジトリgithub.com/myusername/OSSRH-*****を作成します。

起票したIssueに最長でも2営業日以内に管理者からコメントで返信があるはずです。
反応があるまで待ちましょう。

ドメインの所有権確認などが済めば、リポジトリマネージャが利用できるようになります。
リポジトリマネージャにはJIRAの登録アカウントでログインできます。
https://oss.sonatype.org/

2. GPG

Maven Central Repositoryに登録するaarなどのアーティファクトにはGnuPGなどによる署名が必要です。
下記の手順に従ってGPGを導入しましょう。
https://central.sonatype.org/publish/requirements/gpg/

ざっくり手順を書いておきます。

1. インストール
$ brew install gnupg
2. バージョン確認
$ gpg --version
gpg (GnuPG) 2.2.29
3. 鍵生成
$ LANG=C gpg --full-gen-key
  • Kind of key: 1 RSA and RSA.
  • Key size: 4096 鍵のサイズ.
  • Expiration: 0 0で無期限. 期限ありにしたいならそれを指定.
  • Real name, email: ご自由に
  • Comment: フリーテキスト. 空でもok.

実行を終えるとキーを保護するためのパスワードを求められるので入力する。

4. 生成した鍵IDを確認
$ gpg --list-keys
/Users/xxx/.gnupg/pubring.kbx
---------------------------------
pub   rsa2048 2021-xx-xx [SC]
      ABCDEFG0123456789ABCDEFG0123456789ABCDEF
uid           [ultimate] MatsumuraYuki <xxxx@xxx.xxx>
sub   rsa2048 2021-xx-xx [E]

これで生成した公開鍵の情報が得られます。
pubにあるフィンガープリントの下8桁が鍵IDになります。(ここでは 89ABCDEF
この8桁の鍵IDはあとで使うのでメモしておきます。

5. 公開鍵を鍵サーバへ登録

公開鍵があなたのものであることを確認できるように、鍵サーバーにアップロードします。

$ gpg --keyserver keyserver.ubuntu.com --send-keys <先ほど生成した8桁の鍵ID>

現在Maven Central Repositoryがサポートしている鍵サーバは下記の3つです.

  • keyserver.ubuntu.com
  • keys.openpgp.org
  • pgp.mit.edu

6. 秘密鍵のBase64エクスポート

署名する際に使う秘密鍵の情報をBase64エクスポートしてメモしておきます。

$ gpg --export-secret-keys 89ABCDEF | base64

3. Setup Gradle

ここから先は下記のプロジェクトを参考に進めてみてください。動いている完成形で、これをベースに話を進めます。
https://github.com/YukiMatsumura/koma

公開に必要な設定はルートやモジュールのbuild.gradleとは別ファイルで管理するようにします(必須ではないですが、管理しやすくなるのでファイルを分けます)
プロジェクトルートに scripts ディクトリを作成して、そこに publish-module.gradlepublish-root.gradle の空ファイルを作成しておきます。

Root build.gradle

次にプロジェクトルートの build.gradle に下記を追加します。

buildscript {
  repositories {
    maven { url "https://plugins.gradle.org/m2/" }
    ...
  }
  dependencies {
      ...
      classpath 'io.github.gradle-nexus:publish-plugin:1.1.0'
      classpath "org.jetbrains.dokka:dokka-gradle-plugin:1.5.0"
  }
}

apply plugin: 'io.github.gradle-nexus.publish-plugin'
apply from: "${rootDir}/scripts/publish-root.gradle"

Maven Central Repositoryへの公開には gradle-nexus/publish-pluginを使います。
また、参考プロジェクトはdokkaを使っているのでそのクラスパスも追加しています。

publish-root.gradle

publish-root.gradle の内容は次のとおりです。

ext["ossrhUsername"] = ''
ext["ossrhPassword"] = ''
ext["sonatypeStagingProfileId"] = ''
ext["signing.keyId"] = ''
ext["signing.password"] = ''
ext["signing.key"] = ''

// CIとローカルビルド両方で動作するように秘匿情報の参照先を分けます
File secretPropsFile = project.rootProject.file('local.properties')
if (secretPropsFile.exists()) {
  Properties p = new Properties()
  new FileInputStream(secretPropsFile).withCloseable { is -> p.load(is) }
  p.each { name, value -> ext[name] = value }
} else {
  ext["ossrhUsername"] = System.getenv('OSSRH_USERNAME')
  ext["ossrhPassword"] = System.getenv('OSSRH_PASSWORD')
  ext["sonatypeStagingProfileId"] = System.getenv('SONATYPE_STAGING_PROFILE_ID')
  ext["signing.keyId"] = System.getenv('SIGNING_KEY_ID')
  ext["signing.password"] = System.getenv('SIGNING_PASSWORD')
  ext["signing.key"] = System.getenv('SIGNING_KEY')
}

nexusPublishing {
  repositories {
    sonatype {
      stagingProfileId = sonatypeStagingProfileId
      username = ossrhUsername
      password = ossrhPassword
      // 2021.02以降Maven Central Repositoryにリポジトリを新規作成する場合は下記の指定が必要です
      // https://central.sonatype.org/publish/publish-gradle/#metadata-definition-and-upload
      nexusUrl.set(uri("https://s01.oss.sonatype.org/service/local/"))
      snapshotRepositoryUrl.set(uri("https://s01.oss.sonatype.org/content/repositories/snapshots/"))
    }
  }
}

Module build.gradle

公開するライブラリモジュールの build.gradle に下記を追加します。

ext {
  // Provide your own coordinates here
  PUBLISH_GROUP_ID = 'Group ID. 例:io.github.yukimatsumura'
  PUBLISH_VERSION = 'ライブラリバージョン. 例:0.2'
  PUBLISH_ARTIFACT_ID = 'アーティファクトID. 例:koma'
}

apply from: "${rootProject.projectDir}/scripts/publish-module.gradle"

アーティファクトIDは implementation "GroupID:ArtifactID:version" で指定するアーティファクトIDになります。

publish-module.gradle

publish-module.gradle の内容は次のとおりです。

apply plugin: 'maven-publish'
apply plugin: 'signing'
apply plugin: 'org.jetbrains.dokka'

task androidSourcesJar(type: Jar) {
  archiveClassifier.set('sources')
  from android.sourceSets.main.java.srcDirs
  from android.sourceSets.main.kotlin.srcDirs
}

tasks.dokkaHtml.configure {
  outputDirectory.set(file("../documentation/html"))
}

tasks.withType(dokkaHtml.getClass()).configureEach {
  pluginsMapConfiguration.
      set(["org.jetbrains.dokka.base.DokkaBase": """{ "separateInheritedMembers": true}"""])
}

task javadocJar(type: Jar, dependsOn: dokkaJavadoc) {
  archiveClassifier.set('javadoc')
  from dokkaJavadoc.outputDirectory
}

artifacts {
  archives androidSourcesJar
  archives javadocJar
}

signing {
  useInMemoryPgpKeys(rootProject.ext["signing.keyId"],
      rootProject.ext["signing.key"],
      rootProject.ext["signing.password"],)
  sign publishing.publications
}

group = PUBLISH_GROUP_ID
version = PUBLISH_VERSION

afterEvaluate {
  publishing {
    publications {
      release(MavenPublication) {
        groupId PUBLISH_GROUP_ID
        artifactId PUBLISH_ARTIFACT_ID
        version PUBLISH_VERSION

        // Two artifacts, the `aar` (or `jar`) and the sources
        if (project.plugins.findPlugin("com.android.library")) {
          from components.release
        } else {
          from components.java
        }

        artifact androidSourcesJar
        artifact javadocJar

        pom {
          name = PUBLISH_ARTIFACT_ID
          description = 'プロジェクトの概要'
          url = 'プロジェクトのURL. 例:https://github.com/YukiMatsumura/koma'

          licenses {
            license {
              // ライセンス情報
              name = 'The Apache License, Version 2.0'
              url = 'http://www.apache.org/licenses/LICENSE-2.0.txt'
            }
          }
          developers {
            developer {
              id = 'よしなに. 例:YukiMatsumura'
              name = 'よしなに. 例:Matsumura Yuki'
              email = 'よしなに. 例:xxxx@gmail.com'
            }
          }
          scm {
            connection = 'VCS情報. 例:scm:git:github.com/YukiMatsumura/koma.git'
            developerConnection = 'VCS情報. 例:scm:git:ssh://github.com/YukiMatsumura/koma.git'
            url = 'VCS情報. 例:https://github.com/YukiMatsumura/koma/tree/main'
          }
        }
      }
    }
  }
}

4. local.properties

外部公開できない秘匿情報をlocal.propertiesに定義しましょう。

signing.keyId=公開鍵の8桁ID. 例:89ABCDEF
signing.password=PGPで生成した秘密鍵Base64情報. 例:PMxxxxxxxxxxxxxxxx.........xx==

ossrhUsername=リポジトリマネージャログインID
ossrhPassword=リポジトリマネージャログインパスワード
sonatypeStagingProfileId=ステージングプロファイルID

ossrhUsername/password

そのままsonatypeのusername/passwordを指定することもできますが、よりセキュアにアクセストークンを発行して指定することもできます。
Sonatypeのリポジトリマネージャで、 画面右上のログイン名 → Profile → User Token からトークンを生成し、username/passwordと差し替えます。

Staging profile id

https://s01.oss.sonatype.org/ にログイン後, Build Promotion → Staging Profiles を選択し, 自分のプロファイルを選択するとURLの末尾にプロファイルIDが表示されます。
これをsonatypeStagingProfileIdに指定します。

例:https://s01.oss.sonatype.org/#stagingProfiles;<profile id>

5. Release

これですべての設定は完了しました。
Gradleのタスクリストを見ると、ライブラリモジュールのタスクにpublishReleasePublicationToSonatypeRepositoryがいるはずです。
コマンドを実行してライブラリをプレリリースしましょう。

./gradlew :<モジュール名>:publishReleasePublicationToSonatypeRepository

コマンドを実行すると、Sonatypeリポジトリマネージャの Build Promotion → Staging Repositories にライブラリがアップロードされているのがわかります。

ライブラリを選択し Close アクションを実行しましょう。
Closeを実行するとしばらくの間バリデーションが実行されます。実行状況は同画面の Activity タブから確認できます。

リポジトリを閉じるとDropReleaseのアクションが選択可能になります。
公開プロセスで問題があった場合はDropでキャンセルできます。
Releaseを選択するとMaven Centralに公開します。Release後はステージングのアイテムは不要なのでDropできます。

公開には10~15分、長いと1時間以上かかります。
正常に公開されると https://repo1.maven.org/maven2/ であなたのリポジトリが参照できます。
さらに数時間後には https://search.maven.org/ で検索が可能になっているはずです。

以上です。

2021/05/20

Android: uses-permissionの追加・定義元を確認する

ライブラリがパーミッションを定義していると, アプリのパーミッションとして自動で追加される.
Android StudioでAndroidManifest.xmlを開いて Merged Manifestタブを開けば最終的にアプリが使用するパーミッションを確認できる.

ここで, <uses-permission> として定義されたパーミッションをどのライブラリが追加・定義しているのかを調べたい場合, アプリを一度ビルドして[module]/build/outputs/logs/manifest-merger-[build variant]-report.txtの内容を確認すれば良い.

下記のような出力結果が得られるので, 「READ_EXTERNAL_STORAGELeakCanary が追加しているんだな」と知ることができる.

uses-permission#android.permission.READ_EXTERNAL_STORAGE
ADDED from [com.squareup.leakcanary:leakcanary-android-core:2.4] xxxleakcanary-android-core-2.4/AndroidManifest.xml:23:5-80

以上.

non-SDK interfaces を veridex toolで検出する

SDKに定義されていない非公開なインタフェース(non-SDK interface)であってもリフレクションを使うことでアプリから参照することができていましたが, Android 9(API Lv.28)以降は制限されるようになりました. 詳細は「Improving Stability by Reducing Usage of non-SDK Interfaces」を参照してください.
この変更により, アプリはTarget SDKのバージョンを変更する際にはアプリや依存するライブラリがnon-SDK interfaceを使っていないかをチェックする必要があります.

チェックする方法はいくつかありますが, 本稿ではveridex toolを使用する方法についてまとめます. 「Android Developers - Restrictions on non-SDK interfaces
」にも方法が書かれてありますveridex toolsの制限など知りたい方はそちらを参照してください.

non-SDK interface使用箇所の検出

今回はmacOSでveridex toolsを実行します.

veridex toolsの実行

  1. gitからappcompatのtar.gzをDLします.
  2. tar.gzを展開すると veridex-mac.zip があるのでこれを展開します
  3. appcompat.sh があるのでこれを実行します
./appcompat.sh --dex-file=[APKファイルパス]

出力結果の確認

appcompat.shを実行すると下記のようなフォーマットで結果が出力されます.

#1: Linking unsupported Llibcore/io/Memory;->pokeByte(JB)V use(s):
       Lcom/google/android/gms/internal/gtm/zztx$zzb;->zza(JB)V

#2: Reflection max-target-p Landroid/widget/AutoCompleteTextView;->ensureImeVisible use(s):
       Landroidx/appcompat/widget/SearchView$PreQAutoCompleteTextViewReflector;-><init>()V

...

83 hidden API(s) used: 28 linked against, 55 through reflection
    70 in unsupported
    0 in blocked
    1 in max-target-o
    12 in max-target-p
    0 in max-target-q
    0 in max-target-r

結果の見方は下図の通りです.

non-SDK APIリストの種別

non-SDK APIリストの種別で unsupported は現在, 特に使用制限はなく, アプリが使用できるnon-SDK interfaceです.

max-target-p はAndroid 9では制限されていなかったが, Android 10から制限されるようになったAPIです. Android 9では問題ありません. しかし, Android 10かつTarget SDKバージョン10のアプリはこのAPIを使うことができません. 同条件でこのAPIを呼び出すと実行時例外が発生します.

検出されたnon-SDK interface

制限の対象となり得るnon-SDK interfaceです.

non -SDK interfaceの使用元

non-SDK interfaceをリフレクションを使って参照している参照元です.

検出されたnon-SDK interfaceの参照元をチェック

検出された参照元が自アプリのコードなら, 下記のようにSDKバージョンに応じて処理を分けるようにします.

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {...}

検出された参照元が3rd-partyライブラリのコードならそのコードを参照し, SDKバージョンに応じて処理を分けているか確認します.

例えば, 下記のような出力結果が得られた場合,

#2: Reflection max-target-p Landroid/widget/AutoCompleteTextView;->ensureImeVisible use(s):
       Landroidx/appcompat/widget/SearchView$PreQAutoCompleteTextViewReflector;-><init>()V

androidx.appcompat.widget.SearchView$PreQAutoCompleteTextViewReflector のコードを確認します.

static final PreQAutoCompleteTextViewReflector PRE_API_29_HIDDEN_METHOD_INVOKER =
        (Build.VERSION.SDK_INT < 29) ? new PreQAutoCompleteTextViewReflector() : null;

SDKバージョンが考慮されているので, この出力結果は問題ないことがわかります.

もしライブラリ側に問題があった場合, ライブラリのバージョンを上げるか, コードオーナーに修正を依頼するなどして対応を待つ必要があります.

以上です.

2021/02/27

NavHostFragmentをFragmentの入れ子にする時はsetPrimaryNavigationFragmentを指定する

NavHostFragmentapp:defaultNavHost=trueを指定すればバックキー制御をNavHostFragmentに任せることができます.

    <androidx.fragment.app.FragmentContainerView
        android:name="androidx.navigation.fragment.NavHostFragment"
        app:defaultNavHost="true"
        ...

NavHostFragmentをアクティビティのレイアウトに指定した時の構造は次の通りです.

Activity
  |- NavHostFragment

一方で, アクティビティ直下にNavHostFragmentを配置せず, 下記のように間にフラグメントがいる場合は注意が必要です.

Activity
  |- Fragment
     |- NavHostFragment

この場合, フラグメントのレイアウトでapp:defaultNavHost=trueを指定しても, バックキー制御などナビゲーション周りで意図しない動作となります.

解決策

NavHostFragmentを持つフラグメントを PrimaryNavigationFragment に設定します.

class HostFragment : Fragment {
    override fun onAttach(context: Context) {
        super.onAttach(context)
        parentFragmentManager.commit {
            setPrimaryNavigationFragment(this@HostFragment)
        }

FragmentTransaction.setPrimaryNavigationFragment

app:defaultNavHost

NavHostFragmentapp:defaultNavHost=trueを指定されると, 自身のonAttachで同様に setPrimaryNavigationFragment(this) を設定します.

NavHostFragment.ktの該当行 - GitHub

setPrimaryNavigationFragment

プライマリナビゲーションフラグメントに指定されると, バックナビゲーションなどをハンドリングできるようになります.
app:defaultNavHost=trueを指定するだけで, NavHostFragmentがバックナビゲーションをうまく制御できるのはこのためです.

プライマリナビゲーションフラグメントはフラグメントマネージャのインスタンス毎に1つしか設定できません.

FragmentManager.setPrimaryNavigationFragment - GitHub

NavHostFragmentを複数管理する場合, app:defaultNavHost=trueNavHostFragmentは1つにしなければならない理由でもあります.

フラグメントがプライマリナビゲーションフラグメントと判定されるには, 親フラグメントがいる場合, 関連するフラグメントマネージャのプライマリナビゲーションフラグメントに指定されている必要があります.

つまり, 次の構造ではNavHostFragmentの親フラグメント/親フラグメントマネージャがいないので, NavHostfragmentがプライマリナビゲーションフラグメントになります.

Activity
  |- NavHostFragment

しかし, 次の構造ではNavHostFragmentに親フラグメントがおり, その親がsetPrimaryNavigationFragmentとして指定されていない場合, 子であるNavHostFragmentもプライマリナビゲーションフラグメントの条件を満たしません.

Activity
  |- Fragment
     |- NavHostFragment

そのため, 親フラグメントは次のようなコードで自身をプライマリナビゲーションフラグメントとして指定する必要があります.

class HostFragment : Fragment {
    override fun onAttach(context: Context) {
        super.onAttach(context)
        parentFragmentManager.commit {
            setPrimaryNavigationFragment(this@HostFragment)
        }

蛇足: NavHostFragmentのバックナビゲーション周りの実装

// デフォルトでフラグメントマネージャのOnBackPressedCallbackはenable=falseになっている

FragmentManager
    private final OnBackPressedCallback mOnBackPressedCallback =
            new OnBackPressedCallback(false) {
                @Override
                public void handleOnBackPressed() {
                    FragmentManager.this.handleOnBackPressed();
                }
            };

---

// OnBackPressedCallbackをenable=trueにするにはisPrimaryNavigationでtrueを返す必要がある

    private void updateOnBackPressedCallbackEnabled() {
        ...
        // This FragmentManager needs to have a back stack for this to be enabled
        // And the parent fragment, if it exists, needs to be the primary navigation
        // fragment.
        mOnBackPressedCallback.setEnabled(getBackStackEntryCount() > 0
                && isPrimaryNavigation(mParent));
    }

---

// PrimaryNavigationFragmentに変更があると...

Fragment
    void performPrimaryNavigationFragmentChanged() {
        boolean isPrimaryNavigationFragment = mFragmentManager.isPrimaryNavigation(this); ⭐️
        // Only send out the callback / dispatch if the state has changed
        if (mIsPrimaryNavigationFragment == null
                || mIsPrimaryNavigationFragment != isPrimaryNavigationFragment) {
            mIsPrimaryNavigationFragment = isPrimaryNavigationFragment;
            onPrimaryNavigationFragmentChanged(isPrimaryNavigationFragment); 🍣

---

// 一方, NavHostFragmentでは...

NavHostFragment
🍣
    public void onPrimaryNavigationFragmentChanged(boolean isPrimaryNavigationFragment) {
        if (mNavController != null) {
            mNavController.enableOnBackPressed(isPrimaryNavigationFragment); 🌴

---

// BackPressedCallbackを有効にするにはisPrimaryNavigationFragmentがtrueである必要がある.

NavController
🌴
    void enableOnBackPressed(boolean enabled) {
        mEnableOnBackPressedCallback = enabled;  
        updateOnBackPressedCallbackEnabled();

    private void updateOnBackPressedCallbackEnabled() {
        mOnBackPressedCallback.setEnabled(mEnableOnBackPressedCallback
                && getDestinationCountOnBackStack() > 1);

---

// NavControllerはOnBackPressedCallbackを持っている. NavHostFragmentのバックキー制御はNavControllerの責務

    private final OnBackPressedCallback mOnBackPressedCallback =
            new OnBackPressedCallback(false) {
        @Override
        public void handleOnBackPressed() {
            popBackStack();
        }
    };

以上.

2020/05/13

Android: dropbox/Store

dropbox/Store :Github

Dropbox/Storeとは

Storeはデータロードのためのライブラリです.
データの問合せに対して, ネットワーク越しにデータを取得するフェッチャーを定義し, 取得したデータをどのようにキャッシュするのかを決め, 指定したキャッシュポリシーにしたがって, その後のデータ取得を効率的に行うことができます.
Storeにアクセスするクラスは, データの所在(ネットワーク or ディスク or メモリ)を気にすることなくデータ取得することができるようになります.

Storeが主に提供する機能は次の3つです.

  1. ネットワークを経由してデータをフェッチする方法の宣言(required)
  2. 取得したデータをメモリまたはディスクにキャッシュする方法の宣言(optional)
  3. キャッシュのEvictionPolity(optional)

StoreはデータをFlowで返すためマルチスレッド処理することが容易になるよう設計されています. Flow/Coroutinesによる構造化された同時実行性の性質によって, スコープが明確に定義され, メモリリークの減少, パフォーマンスの向上, クラッシュリスクの軽減が期待されます.

Storeによってデータのフェッチ/共有/キャッシュに関するロジックがカプセル化され, ビューで最新のデータを効率的に購読することができ, データをオフラインで使用することもできるようになります.

Store簡単まとめ

  • フェッチャーには単一レスポンスと複数レスポンス(Flow)のバリエーションがある
  • ディスクキャッシュする/しないを選べる. メモリキャッシュする/しないを選べる
  • メモリキャッシュのEviction Polityには最も過去に生成/更新されたものを破棄, 最終アクセスからn時間経過で破棄, キャッシュの上限個数を指定できる
  • Storeへのデータ問合せ時にはデータを一意に識別できる汎用キーが必要. これは同一リクエストかの判定やキャッシュヒットの判定に使われ, 汎用キーはKotlin Data Classが推奨される.
  • Storeへのデータ問合せによって Loading, Data, Errorのレスポンスがエミットされる
  • Storeへのデータ問合せ中に発生したエラーはErrorとしてエミットされる
  • フェッチャーが使うFlowのスコープはGlobalScope
  • In-flight debouncerが実装されており, 初回の複数同時リクエスト時にもうまくキャッシュが効く

ビルダーによるStoreの構築

StoreStoreBuilderによって構築されます.

StoreBuilder
    .from(
        fetcher = nonFlowValueFetcher { api.fetchSubreddit(it, "10").data.children.map(::toPosts) },
        sourceOfTruth = SourceOfTrue.from(
            reader = db.postDao()::loadPosts,
            writer = db.postDao()::insertPosts,
            delete = db.postDao()::clearFeed,
            deleteAll = db.postDao()::clearAllFeeds
        )
    ).cachePolicy(
        MemoryPolicy.builder()
            .setMemorySize(10)
            .setExpireAfterAccess(10.minutes) // or setExpireAfterWrite(10.minutes)
            .build()
    ).build()

これは次のことを宣言しています

  1. 複数回呼び出された場合に備えるフェッチしたデータのメモリキャッシュ
  2. ネットワークがオフラインの場合に備えたディスクキャッシュ

Storeはネットワークへの過剰な呼び出しを防ぎ, ディスクキャッシュをSource of Truthとして使用することができます. Source of Truthの実装には Room, SQLDelight などの監視可能なソースを提供できるデータベースが利用できます.

StoreBuilder.fromNonFlow

fun <Key : Any, Output : Any> fromNonFlow(
    fetcher: suspend (key: Key) -> Output
): StoreBuilder<Key, Output>

Flowを返さないフェッチャーを持つStoreBuilderを生成します.
リクエストに対してHTTPのように単一の応答を返すフェッチャーを持つStoreを生成します.

StoreBuilder.from

fun <Key : Any, Output : Any> from(
    fetcher: (key: Key) -> Flow<Output>
): StoreBuilder<Key, Output> = BuilderImpl(fetcher)

Flowを返すフェッチャーを持つStoreBuilderを生成します.
リクエストに対してWebsocketのように複数の応答を返すフェッチャーを持つStoreを生成します.

StoreBuilder.persister

fun <NewOutput : Any> persister(
    reader: (Key) -> Flow<NewOutput?>,
    writer: suspend (Key, Output) -> Unit,
    delete: (suspend (Key) -> Unit)? = null,
    deleteAll: (suspend () -> Unit)? = null
): StoreBuilder<Key, NewOutput>

Flowなディスクキャッシュへアクセスするための reader, writer, deleter を定義します.

柔軟性を確保するため, writerのレコードタイプ(Output)とreaderのレコードタイプ(NewOutput)は異なる型にすることができます. これによって, ネットワークから取得される型とローカルストレージのレコードタイプを分けることができます.

StoreBuilder.nonFlowingPersister

fun <NewOutput : Any> nonFlowingPersister(
    reader: suspend (Key) -> NewOutput?,
    writer: suspend (Key, Output) -> Unit,
    delete: (suspend (Key) -> Unit)? = null,
    deleteAll: (suspend () -> Unit)? = null
): StoreBuilder<Key, NewOutput>

Flowではないディスクキャッシュへアクセスするための reader, writer, deleter を定義します.

StoreBuilder.cachePolicy

fun cachePolicy(memoryPolicy: MemoryPolicy?): StoreBuilder<Key, Output>

StoreのメモリキャッシュにおけるEvictionポリシーを指定できます.
MemoryPolicy.MemoryPolicyBuilderで TTLまたは容量ベースのEvictionを設定できます.

ビルダーで特にポリシーの指定がない場合, 次のキャッシュポリシーが適用されます.

  • キャッシュできるエントリーの上限個数 = 100
  • Evictionポリシー = キャッシュしたエントリーの作成/更新から24時間以上経過したものを破棄

StoreBuilder.disableCache

fun disableCache(): StoreBuilder<Key, Output>

キャッシュ機構を持たないStoreになります.

StoreBuilder.scope

fun scope(scope: CoroutineScope): StoreBuilder<Key, Output>

Storeが汎用キーに対応するデータをフェッチ(あるいはキャッシュヒット)して, 複数の購読者に結果をマルチキャストする際, そのスコープはGlobalScopeとなるのがデフォルトの挙動です.
このマルチキャストのスコープを独自にハンドリングしたい場合はこの関数でCoroutineScopeを指定します.

MemoryPolicyBuilder

Storeのメモリキャッシュポリシー(MemoryPolicy)を定義するためのビルダークラスです.
いわゆるEvictionポリシーはここで指定することになります.
このポリシーは最終的にCacheクラスを生成するパラメータとして利用されます.

MemoryPolicyBuilder.setExpireAfterWrite

fun setExpireAfterWrite(expireAfterWrite: Duration): MemoryPolicyBuilder

キャッシュエントリーが作成 or 置換/更新されてから一定時間後に自動削除するポリシーです.
Duration0で指定されるとキャッシュされなくなります.

MemoryPolicyBuilder.setExpireAfterAccess

fun setExpireAfterAccess(expireAfterAccess: Duration): MemoryPolicyBuilder

キャッシュエントリーが作成 or 置換/更新 or 最後にアクセスされてから一定時間後に自動削除するポリシーです.
Duration0で指定されるとキャッシュされなくなります.

MemoryPolicyBuilder.setMemorySize

fun setMemorySize(maxSize: Long): MemoryPolicyBuilder

キャッシュされるエントリー個数の上限を指定します. エントリー個数が上限を超えた場合, アクセスされた時間の最も古いエントリーが削除対象となります(LRU)
0が指定されるとすぐにキャッシュを破棄するため, キャッシュされなくなります.
特にビルダーで指定しなかった場合は個数の上限を設けません.

Storeの実装制約

Storeの唯一の実装制約はflowを返す関数, または特定の型を返すフェッチ用関数(フェッチャー)を実装する必要があることです.

val store = StoreBuilder.from {
    articleId -> api.getArticle(articleId) //Flow<Article>
}
.build() 

データの識別子

Storeはデータの識別子として汎用キーを使用します.
この汎用キーはtoString(), equals(), hashCode() を適切に実装した値オブジェクトにする必要があります.
汎用キーにはKotlinのdata classを使うことが強く推奨されます.

この汎用キーはフェッチ関数の引数として渡されます. また, キャッシュのプライマリ識別子としても利用されます.
UIはこの汎用キーさえ知っていれば, いつでもStoreからデータをネットワーク/キャッシュを気にせず再取得できるようになっています.

Stream API

Storeが提供する主要なAPIとしてstream functionがあります.

fun stream(request: StoreRequest<Key>): Flow<StoreResponse>Output>>

stream の呼び出しに渡される StoreRequest には次の情報が格納されています.

  1. データを識別するための汎用キー
  2. キャッシュの利用方針(ディスク/メモリキャッシュの利用有無)

streamの戻り値はStoreResponseFlowで返されます.

StoreResponse

StoreResponseseald classでサブクラスにはLoading, Data, Errorが定義されています.

  • それぞれのクラスには ResponseOrigin フィールドがあり, データの取得元がキャッシュ or ディスク or フェッチなのかを判別できるようになっています.
  • LoadingResponseOriginのみを持ちます. このクラスはデータのロードをUIに反映するきっかけとして使うことができます.
  • DataStoreから返される値を持ったクラスです
  • ErrorResponseOriginによって投げられた例外をフィールドに持ちます

エラーが発生した場合でもStoreは例外をスローしません. その代わり, StoreResponse.Errorタイプによってこれが表現されます.
これによってFlowが壊れることがなく, データへの問い合わせやデータの更新が継続して行われることになります.
これによって, UIはflowの再起動/再接続を意識する必要がなくなります.

lifecycleScope.launchWhenStarted {
  store.stream(StoreRequest.cached(key = key, refresh=true)).collect { response ->
    when(response) {
        is StoreResponse.Loading -> showLoadingSpinner()
        is StoreResponse.Data -> {
            if (response.origin == ResponseOrigin.Fetcher) hideLoadingSpinner()
            updateUI(response.value)
        }
        is StoreResponse.Error -> {
            if (response.origin == ResponseOrigin.Fetcher) hideLoadingSpinner()
            showError(response.error)
        }
    }
  }
}

extention function

suspend fun Store.get(key: Key): Value

渡された値に対応するデータを単発取得します.
メモリ/ディスクキャッシュにヒットするデータがあればそこから取得されます.
エラー(StoreResponse.Error)が発生した場合は例外がスローされ, データがない場合はNullPointerExceptionがスローされます.

キャッシュされたデータもない初めての Store.get の呼び出しでは, ネットワークからデータを取得して, ディスク/メモリキャッシュにこれを格納します.
再度, おなじ汎用キーでStore.getを呼び出したなら, キャッシュからデータを取得して, ネットワーク通信を最低限に抑えようとします.

suspend fun Store.fresh(key: Key): Value

フェッチャーによる問合せによって, ネットワークからデータを単発取得します.
ディスク/メモリキャッシュをスキップしてデータを取得するため, 定期ジョブによるデータ(キャッシュ)更新や, Pull to Refreshによるデータの強制更新時などに利用されます.

suspend fun Store.stream(key: Key): Flow

データを監視してリアルタイムにUI更新をしたい場合などでは Store.stream が利用できます.
ディスクキャッシュの更新や, ネットワークからのロード/エラーイベントを監視するストリームを作成する方法と考えることができます.

In-flight debouncer

Storeは同じデータに対するリクエストの重複を避けるためにIn-flight debouncerの機能が組み込まれています.
リクエストに対するデータがまだキャッシュされていない場合, 複数個の同じデータに対するリクエストが同時にあると, それぞれが並列に処理されてキャッシュヒットせず, 両方のリクエストがネットワーク通信に至ってしまう可能性があります.
In-flight debouncerはこの不要なネットワーク通信を回避するために, 最初のリクエストはデータ取得のためにブロックされ, 他方の呼び出しはデータの到着を待たせます.

参考

2020/04/07

Android:バッテリー温度の確認方法

下記コマンドでバッテリー状態をダンプできます.

adb shell dumpsys battery

コマンドを実行すると, 次のような出力が得られます.

Current Battery Service state:
  AC powered: false
  USB powered: true
  Wireless powered: false
  Max charging current: 0
  Max charging voltage: 0
  Charge counter: 3160971
  status: 2
  health: 2
  present: true
  level: 95
  scale: 100
  voltage: 4315
  temperature: 358
  technology: Li-ion
  batteryMiscEvent: 0
  batteryCurrentEvent: 32768
  mSecPlugTypeSummary: 2
  ... 続く

出力された中にある temperature: 358 がバッテリー温度になります.
数値は温度(摂氏)の10倍値になるので, 358 なら35.8℃ ということになります.

バッテリー状態を管理するサービスクラスはBatteryServiceです.
バッテリー状態のデータはHealthInfoに定義されています.

以上です.

2019/01/09

Android: /data/data配下にadb push

/data/data/<Application ID>/配下にADBでファイル追加しようとするとpermission errorで失敗した✍

issueはこれ.
https://issuetracker.google.com/issues/37138359

Android Studioに付属してるFileExplorerを使えばファイルを追加できた.
FileExplorerは次の手順を踏んでいた。

# ファイルを一時領域へコピー 
$ adb push hoge /data/local/tmp 

# アプリユーザに切り替え 
$ adb shell 
$ adb run-as <Application ID>

# ファイルコピー 
$ cp /data/local/tmp/hoge /data/data/<ApplicationID>/hoge

## ↑でエラーが出た場合はcatリダイレクトする
$ cat /data/local/tmp/hohe > /data/data/<Application ID>/hoge

FileExplorerのコードはこの辺.

https://android.googlesource.com/platform/tools/adt/idea/+/studio-3.2.1/android/src/com/android/tools/idea/explorer/adbimpl/AdbDeviceDataDirectoryEntry.java#246

https://android.googlesource.com/platform/tools/adt/idea/+/studio-3.2.1/android/src/com/android/tools/idea/explorer/adbimpl/AdbFileOperations.java#225

以上.

2018/12/23

Android: 擬似的に日本に夏時間を導入してテストする

はじめに

i18n対応で考えないといけないことの1つに夏時間(Daylight Saving Time)があります.
夏時間のテストはいくつかの理由で難しい場合が多いです.

  • サーバAPI開発中で, 海外へのサービス提供が蓋閉めされている
  • 夏時間がくるまで待てない

そこで, 日本でも夏時間が導入されていることにして, 好きなタイミングでJST(Japan Standard Time)↔️JDT(Japan Daylight Saving Time)を切り替えられればテストが捗りそうです.

本稿は, そのような環境を構築するためにTZDB(Time Zone Database)をテスト用に編集して, それをシステムに認識させる方法を紹介します.

本稿執筆時点でのTZDB Versionは2018gが最新です. 以降は最新が 2018g の前提で話を進めます.

ThreeTenBp と ThreeTenABP

ThreeTenBpはTime Zone情報のロード周りでメモリ効率が悪いため, Android向けにThreeTenABPが提供されています.

ThreeTenBpを使用するためにはTime Zone情報を提供する必要があります.
ThreeTenABPはその手続きを肩代わりしてくれるライブラリです.

ThreeTenABPは, ThreeTenBp(/IANA)が提供する TZDB.dat を Assetsに内包し, AndroidThreeTen.init でこれを ZoneRulesProviderに登録する AssetsZoneRulesInitializerを実行します.

ThreeTenABPがやっていることはこれだけです.
自前でAssetsZoneRulesInitializerTZDB.datを用意して, ZoneRulesProvider に登録すれば同じことが実現できるので, 次のようなクラスを用意しておけば, 好きなTZDB.datを登録できるようになります.

class AssetsZoneRulesInitializer(private val context: Context) : ZoneRulesInitializer() {
  override fun initializeProviders() {
    context.assets.open("TZDB.dat").use {
      ZoneRulesProvider.registerProvider(TzdbZoneRulesProvider(it))
    }
  }
}

// Application.onCreateで下記を実行する
ZoneRulesInitializer.setInitializer(AssetsZoneRulesInitializer(this))

今回は, テスト用に定義した TZDB.data を作成・登録することで, 擬似的に日本にも夏時間があることにします.

カスタム TZDB.dat 生成手順

  1. ThreeTenBP GitHubをクローン
  2. IANAから最新のTZDBをダウンロード
  3. クローンしたソースの src/tzdb/{tzdb-version} に, 展開したTZDBファイルを移動
  4. TZDBを編集
  5. mvn clean package -Dtzdb-jar を実行
  6. target/threeten-TZDB-{version}.jar から TZDB.dat を抽出

1. ThreeTenBP GitHubをクローン

ThreeTenBP GitHub にはTZDBを読み込んでビルドし, TZDB.datを生成するコンパイラ TzdbZoneRulesCompiler があります.
TzdbZoneRulesProviderに読み込ませるTZDB.datを生成するためにこのレポジトリをクローンします.

2. IANAから最新のTZDBをダウンロード

TZDBを管理するInternet Assigned Numbers Authority(IANA)から最新のTZDBをダウンロードすることができます.

ダウンロードできる種類がいくつかありますが, 今回はタイムゾーン情報があればよいので “tzdata2018g.tar.gz - Data Only Distribution” を選びます.

3. クローンしたソースの src/tzdb/{tzdb-version} に, 展開したTZDBファイルを移動

手順2でダウンロードした tar.gz を展開するとTZDBファイルが入っています.
このTZDBファイルを, 手順1でクローンしたThreeTenBpsrc/tzdb/{tzdb-version}ディレクトリに移動します.

クローン直後は srcディレクトリ直下に tzdb ディレクトリはないので作成しておきます.
また, 注意点として {tzdb-version} の名前は下記の正規表現にマッチする必要があります

[12][0-9][0-9][0-9][A-Za-z0-9._-]+

OK: 2018g
NG: tzdb-2018g

tar.gzを展開してできるディレクトリ名には余計なプレフィックス tzdb- が入っているので注意が必要です.

最終的に, asia ファイルの場所は下記になります.

{threetenbp-root}/src/tzdb/2018g/asia

4. TZDBを編集

手順3で移動したTZDB情報を編集します.
今回は日本に夏時間があった場合をシミュレーションするため “Asia/Tokyo” リージョンの情報が定義されている {threetenbp-root}/src/tzdb/2018g/asia ファイルを編集します.

日本(Asia/Tokyo)のタイムゾーン情報は 2018g では次のように定義されています.

# Rule  NAME    FROM    TO  TYPE    IN  ON  AT  SAVE    LETTER/S
Rule    Japan   1948    only    -   May Sat>=1  24:00   1:00    D
Rule    Japan   1948    1951    -   Sep Sat>=8  25:00   0   S
Rule    Japan   1949    only    -   Apr Sat>=1  24:00   1:00    D
Rule    Japan   1950    1951    -   May Sat>=1  24:00   1:00    D

日本でも過去に夏時間(夏時刻法)があったことがわかります.

TZDBのフォーマットは人間にも読めるようになっています.
フォーマットルールはzic man pageに載っています.
これに則り, 日本に夏時間を導入するため次の1行を追加してみましょう.

Rule  Japan  2018  only  -  Dec  23  00:00  1:00  D

これで, TZDB的には 2018/12/23 00:00:00(JST) から日本では夏時間(JDT)が適用されるようになります.

5. mvn clean package -Dtzdb-jar を実行

TzdbZoneRulesCompilerを使って編集したTZDBをもとに TZDB.dat を生成します.
ThreeTenBpのルートで下記のコマンドを実行するとTzdbZoneRulesCompilerがビルドを始めます.

mvn clean package -Dtzdb-jar

実行するとビルドログが出力されます.
下記のように Source directory contains no valid source folders のログが出力される場合はTZDBのディレクトリ名かパスが間違っており, TzdbZoneRulesCompilerがTZDBをうまく認識できていない可能性があります. その場合は手順2をやり直しましょう.

Source filenames not specified, using default set
(africa antarctica asia australasia backward etcetera europe northamerica southamerica)

は, 今回特にファイル名を指定していないので出力されても問題ありません.

...
[INFO] --- exec-maven-plugin:1.2.1:java (default) @ threetenbp ---
Source filenames not specified, using default set
(africa antarctica asia australasia backward etcetera europe northamerica southamerica)
Source directory contains no valid source folders: xxx
...

編集したTZDBがうまく読み込まれなかった場合も BUILD SUCCESS となるので注意してください. その場合, 後述の threeten-TZDB-2018g.jar が出力されません.

6. target/threeten-TZDB-{version}.jar から TZDB.dat を抽出

TzdbZoneRulesCompiler のビルド結果はThreeTenBpプロジェクトルート直下の target ディレクトリに出力されます.
ビルドが成功すると target/threeten-TZDB-2018g.jar が出力されます.

このJarファイルに目的のTZDB.datが含まれているので, それを抽出します.
下記のJarコマンドで内包されているファイルパスの一覧を取得します.

jar tf {threeten-TZDB-2018g.jar のパス} 

今回のケースでは org/threeten/bp/TZDB.datTZDB.datがありました.
同じくJar コマンドでこれを抽出します.

jar -xvf {threeten-TZDB-2018g.jar のパス} org/threeten/bp/TZDB.dat

コマンドを実行したディレクトリに org/threeten/bp/TZDB.dat が抽出されます.

テスト

日本にも夏時間が定義されたTZDB.dat が作成できたので, これをZoneRulesProviderに登録します.

class AssetsZoneRulesInitializer(private val context: Context) : ZoneRulesInitializer() {
  override fun initializeProviders() {
    context.assets.open("TZDB.dat").use {
      ZoneRulesProvider.registerProvider(TzdbZoneRulesProvider(it))
    }
  }
}

// Application.onCreateで下記を実行する.
// AndroidThreeTen.initは実行しない(ThreeTenABPは使わない)
ZoneRulesInitializer.setInitializer(AssetsZoneRulesInitializer(this))

この状態で, ZonedDateTimeを使って日本時間表示してみると夏時間が適用されていることがわかります.

ZonedDateTime
  .now(ZoneId.of("Asia/Tokyo"))
  .format(DateTimeFormatter.ISO_DATE_TIME)

// 出力: 2018-12-23T15:26:19.295+10:00[Asia/Tokyo]

ZoneId.of("Asia/Tokyo").rules.isDaylightSavings(Instant.now())

// 出力:true

以上です.

参考:

2018/12/14

不変条件とか, Nullabilityとか, Kotlin化とか

備忘録. 走り書き.
Kotlin化するときに苦しんだお話.

Java → Kotlin化する時によく困るのがNullabilityの判断.
ある日, こんな感じのコードに出会った.

class Hoge {
  public final String id;
  protected String foo;

  public static Hoge from(proto HogeProto) {
    if (proto == null) throw new IllegalArgumentException(...);

    Hoge hoge = Hoge(proto.id)
    hoge.foo = Wire.get(proto.foo, HogeProto.DEFAULT_FOO)
    return hoge;
  }

  private Hoge(String id) { 
    this.id = id;
  }
  ...
}

APIコールの応答として HogeProto を受け取り, それをモデル Hoge に変換させるコード. (Protocol Buffers と Wireライブラリを使ってる)

直したい部分がいくつかある.

まず, proto.id がJavaのString型なので null の可能性を捨てきれない.
もし, とってもラッキーなことに, 全く正しく疑う余地のない最新のドキュメントが存在していて「idは絶対にnullにならない」って明記されていたり, サーバサイドのコードが assert id != null の不変条件を表明していたりする場合は, requireNotNull(...) の一文を事前条件として追加できるかもしれない.

  public static Hoge from(proto HogeProto) {
    requireNotNull(proto, "...");
    String id = requireNotNull(proto.id, "...");

    Hoge hoge = Hoge(id);
    ....

でも, 残念なことに今回はそんな状況じゃなかった.

ビジネス上, proto.idnull である可能性が限りなく乏しい状況だけれど, 数千万のユーザを抱えるサービスのエンジニアとしての責任を考えると「大丈夫でしょ♪」と根拠のない自信だけで例外を投げるチェックコードを追加する訳にもいかないし, そんなコードをリリースした夜はきっと眠れない(私は少し心配性).

サービスやコードの規模が大きくなった後で, こうしたチェックを追加するのはかなり苦労する. この問題は, Hogeクラスのコードを書いたプログラマがちょっと気を利かせて, Hogeクラスの不変条件をコードで表明しておいてくれれば助かるケースだった.

不変条件が追加できると判断できれば, Kotlin化もスムーズに滞りなくできる.

class Hoge (
  val id: String
) {
  companion object {
    fun from(HogeProto proto): Hoge {
      requireNonNull(proto) {...}
      val id = requireNotNull(proto.id) {...}

      Hoge(id)
      ...
}

IDの不変条件の話はこれぐらいにして, Hogeクラスにはもう一つ問題があった.
でもそれはIDの問題と比べればとっても小さい問題. 相手はフィールド foo.

class Hoge {
  ...
  protected String foo;

  public static Hoge from(proto HogeProto) {
    ...
    Hoge hoge = Hoge(proto.id)
    hoge.foo = Wire.get(proto.foo, HogeProto.DEFAULT_FOO)
    return hoge;
  }
  ...
}

foo のアクセス修飾子は protected. たぶん書いた当時はユニットテストからアクセスさせるためにスコープを広くとったんだと思う.
だったら @VisibleForTesting をつけてほしいけど, まぁ本題じゃないのでそれは横に置いといて…(よくないけど)

実はHogeクラスはモデルというより POJO なクラス. ビジネスロジックを持っている訳でもないし, DTO 的な使われ方をする. Kotlin化で data class になるようなヤツ.
なので, 実際に foo は一度初期化されれば, その後変更されない.

だけれども foo はコンストラクタで初期化されていない.
なので, この状態で フィールドfoo の宣言文に @NonNull アノテーションをつけるとIDEが @Not-null fields must be initialized ってWarningを表示する(そりゃそうだ).
何も考えずKotlin化すると lateinit になっちゃうとこだけど, それはこのクラスの実態にあっていないから, そんなことはしたくない.

Kotlin化するときは引数やフィールドのNullabilityをはっきりとさせるためのチェックとして Javaコードに @NonNull, @Nullable を付けてからKotlin化するようにしているけれど, こういう foo のようなコードを書かれると, 問題解消のための一手間が必要になる.

でもこれはIDの問題と比べればとっても小さい問題. foofinal にすれば解消できる(もちろん, そうできるなら… だけど)

class Hoge {
  @NonNull public final String id;
  @NonNull public final String foo;

  public static Hoge from(proto HogeProto) {
    requireNotNull(proto, "...");
    String id = requireNotNull(proto.id, "...");

    return Hoge(
      id,
      Wire.get(proto.foo, HogeProto.DEFAULT_FOO)
    );
  }

  private Hoge(String id, String foo) { 
    this.id = id;
    this.foo = foo;
  }
  ...
}

ここまでくれば, すぐにでもkotlin化に着手できる.

ちょっと id の話に戻るけど,,,
HogeProto 側にある “デフォルト値” の意味を考えると idWire.get(proto.id, HogeProto.DEFAULT_ID) って形で取得した方がいいのかな〜って思ったりする.
でも, 大抵 DEFAULT_ID って空文字だろうし, モデル Hoge としてはIDに空文字を許したくないので, 結局次のようなコードが必要になる.

String id = Wire.get(proto.id, HogeProto.DEFAULT_ID);
if (StringUtil.isNullOrEmpty(id)) throw new IllegalArgumentException(...);

“空文字を許さない” って部分をちゃんと事前条件として表明できているのはいいことだし, もし DEFAULT_ID が “空文字ではない何か” に置き換わるアップデートが proto にあったとしてもうまく対応できそうだ.

大切にしたいのは, proto はあくまで “APIレスポンスの仕様” ってだけで, クラス Hoge はアプリ内で使われるモデルやデータとして正しい形で存在してなきゃいけないってところ.
Hoge クラスに「こうあってほしい」って考えをコードに落とし込むってところ.

“理解なんてものは概ね願望に基づくものだ” ってセリフがある.
自分の理解をコードを通して他人にも理解させるってとこは, 願望のコード化でもあるなと思った.

閑話休題. 本題のkotlin化に戻る.

Nullabilityもハッキリして, 不変条件まで付いてくればKotlin化はかなり楽になる.
ただ, Hoge のコードには現れていないけれど, Javaの頃によくやった NullObject Pattern が意外とKotlin化する上で厄介だったりする.

例えば, もし次のようなコードがあった場合にちょっと困る.

final Hoge EMPTY = Hoge(null);

だいたい, UNKNOWN とか EMPTY みたいな名前と一緒に NullObject Pattern が使われている.
↑のケースだと, Hoge.idnullable (String?) にしなきゃいけなくなるし, わざわざ HogeEMPTY かどうかを各所でチェックする必要が出てくる.
nullを許容して Hoge? なプロパティや引数をとる方が isEmpty チェック漏れを心配する必要もない.

Javaの頃はこういうオブジェクトがあればnull-safeなコードが書けたので重宝したけど, Kotlinだと言語レベルでnull-safeをサポートしているので, NullObject Patternの必要性がかなり下がると思った.
むしろ, あると邪魔なケースが多くて, ??: で解決できるようなところをわざわざif (obj !== UNKNOWN) みたいにしなきゃいけないし, null であってくれたほうが, 型チェック(nullable or not-nullable)の機構が働くので嬉しいことが多い.

特に何もしないデフォルトリスナーとかを NullObject にしているケースなんかは nullable にしてもさほど困らなさそう.
ただ, nullNullObjectを明確に区別する使い方をしているケースだと簡単に nullable にはできないので厄介だったりする.

以上, 走り書きでした.

2018/10/23

Android: DownloadManagerのタスク管理と競合

タスクの状態

開発者はダウンローダへの要求をアクションとして表現します. アクションには ダウンロード削除 の2種類があり, ダウンローダはこれらをタスクとして処理していきます.
ExoPlayer downloaderはタスクの状態を2種類定義しています.
1つはTaskStateとして外部に公開される状態で下記の状態と遷移を持ちます.

状態リスト:

State Description
queued 開始待機状態
started 開始済み状態
completed 完了状態
canceled キャンセル状態
failed 失敗状態

状態遷移:

もう1つは外部に公開されない, DownloadManagerの内部管理用の状態です.
主に, “キャンセル中”や”停止中”といった状態遷移中の状態が定義されています.

内部状態リスト:

State Description
queued 開始待機状態
started 開始済み状態
completed 完了状態
canceled キャンセル状態
failed 失敗状態
queued_canceling 開始待機キャンセル中状態
started_canceling 実行キャンセル中状態
started_stopping 実行停止中状態

内部状態遷移:

ExoPlayer downloaderを使う上では前者の公開用状態を把握しておけば十分なのですが、ダウンローダの振る舞いを把握するには後者の内部状態を把握しておいた方が理解が進みます。

DownloadManagerのタスク管理と競合

DownloadManagerはタスクの状態変更を DownloadManager.onTaskStateChange 検知します.
ここでタスクの状態がアクティブではない場合, タスクの開始が試みられます.
タスクが下記の条件を満たす場合にはアクティブであると判断されます.

    /** Returns whether the task is started. */
    public boolean isActive() {
      return currentState == STATE_QUEUED_CANCELING
          || currentState == STATE_STARTED
          || currentState == STATE_STARTED_STOPPING
          || currentState == STATE_STARTED_CANCELING;
    }

要するにタスクが何かしらのアクション中であればアクティブと判断されます.

DownloadManagerは次の条件が全て満たされていることを確認してタスクを開始します.

  1. タスクがまだ開始されていないこと(taskstate == queued)
  2. 既にあるタスクと競合しないこと
  3. ダウンロードタスクの場合, 先行するダウンロードタスクが保留中ではない. かつ, アクティブダウンロード数の上限に達していないこと

1はわかりやすいですね. 既に開始済みのタスクを再び開始することはできないということです.
DownloadManagerは, タスクの状態に関わらずタスクリストの先頭から順番に開始を試みます. 既に開始済みのタスクはここで除外されます.

3の”アクティブダウンロード数の上限”はDownloadManagerのコンストラクタ引数 maxSimultaneousDownloads が参照されます.
また, これはダウンロードタスクに課せられる条件で, 削除タスクはこの条件に該当しません.

2は少し複雑です. DownloadManagerは次の2点をチェックしてタスク(アクション)の競合を検知します.

  1. 同じコンテンツに対するアクションか
  2. いずれかのアクションが削除アクションであるか

DownloadManagerはタスクリストの先頭から順番に開始を試みる過程で, 同タスクリスト内の他タスクと競合していないかを検知するためにタスク同士を比較します.
タスクが扱うコンテンツの同一性は DownloadAction.isSameMedia を使って判定され, タスクに紐づくアクションが持つ uri が同じかどうかで判断されます.

  /** Returns whether this is an action for the same media as the {@code other}. */
  public boolean isSameMedia(DownloadAction other) {
    return uri.equals(other.uri);
  }

同じコンテンツに対するアクションがタスクリスト内に複数見つかった場合, 比較元あるいは比較先のタスクのいずれかが削除アクションである場合は “競合した” と判定されます.
例えば, とあるコンテンツAをダウンロード中に, 同コンテンツに対して削除アクションを投げるとこの状態になります.

タスクが競合すると, 比較元が削除アクションであれば比較対象のタスクがキャンセルされ, 比較元の削除タスクもスキップされます(キャンセルはされません).
比較対象のタスクが削除アクションであれば比較元のタスクはスキップされます(キャンセルはされません).

タスクがキャンセルされるとタスクの状態が変化するので DownloadManager.onTaskStateChange が呼ばれます. DownloadManager.onTaskStateChange ではタスクの状態がアクティブではない場合にタスクの開始を試みるので, ここで再びタスクの開始が試みられます. スキップされた削除タスクはアクティブにはなっておらず, また競合していたタスクはキャンセルされているので競合は発生しなくなります.

これによって, とあるコンテンツAをダウンロード中に, 同コンテンツに対して削除アクションを投げるとダウンロード処理はキャンセルされて, ダウンロードのキャンセル処理が終了した後に削除処理が行われることになります.

ダウンローダの開始と停止

DownloadServiceが起動されるとRequirementsHelperを登録してデバイス状態の監視を始めます.
デバイスの状態がダウンロード開始条件を満たした場合, いよいよダウンロード処理が開始されます.

DownloadManagerはダウンローダ停止状態を示す downloadsStopped フィールドを内部に持っています.
このフィールドが false の時, ダウンロードアクション/タスクが追加されたとしても新しくダウンロードが開始されることはありません.
ダウンローダを開始するには DownloadManager.startDownloads を呼び出して, このフィールドを true にする必要があります.

ダウンローダの状態はダウンローダ停止状態がデフォルトですが, 開発者が明示的に DownloadManager.startDownloads を呼び出してダウンローダを開始する必要はありません.
その理由として, まずDownloadServiceにダウンロードアクションを登録すると, タスクの開始を試みる処理が実行されます.
しかし, このタイミングではダウンローダが停止状態なので, ダウンロードタスクは一旦スキップされます.
スキップされたタスクがどのようにして実行されるのかというと, DownloadServiceがアクション/タスクを追加した後, RequirementsHelperを登録してデバイス状態の監視を始めます.
デバイス状態がダウンロード開始の条件を満たした時, DownloadServiceはDownloadManager.startDownloadsでダウンローダを開始状態にします.
ダウンローダの開始時にはタスクの実行を試みるようになっているので, ここでスキップされたタスクが実行されることになります.

“ダウンロードの開始/停止条件はRequirementsで表現される”と過去の投稿で言いましたが, 厳密には Requirementsはダウンローダの開始条件 になります.
ExoPlayerのダウンローダは開始済みであれば全てのタスクを処理しようとします.
このダウンローダは全てのタスクが消化されたとしても, Requirementsの条件が満たされている限り停止しません.
開発者が手動でDownloadManager.startDownload/stopDownloadを呼び出すときは, Requirementsとの整合性が崩れる可能性がある点に注意しなければいけません.

ちなみに, downloadsStopped はダウンロードアクションに対するものであり, 削除アクションはダウンローダの開始状態に依存しないため, このフィールドが false であっても削除アクションは実行されます.
つまり, Requirementsを満たしていない状態でもコンテンツの削除は可能です.

2018/10/19

Android: ExoPlayer DownloadManager, DownloadService

ExoPlyerのDownloadManager, DownloadServiceを調べた時のメモ.

中断されたアクションを読み込むタイミング

保存されたActionFileの読み込みタイミングはDownloadManagerを初期化したタイミングとなります.

プロセスの強制終了などでActionを完遂できなかった場合, 保存されたActionFileをプロセス再開後に読み込んでダウンロード処理を再開する必要があります.
保存されたActionFileは DownloadManager.loadActions によってバックグラウンドスレッド上で読み込まれ, これはDownloadManagerのコンストラクタで実行されます.

コンテンツの削除はバックグラウンド?

コンテンツを削除するにはremove flagをtrueにしたアクションを発行します.
アクションはDownloadServiceで実行されるので, フォアグラウンドサービスとして実行することが可能です.
削除中の通知も DownloadService.getForegroundNotification で返すNotification objectをカスタマイズすることができます.
また, 通知の雛形として DownloadNotificationUtil.buildProgressNotification が用意されています.
buildProgressNotification は引数のタスクステートに応じて通知の表示内容を変えるため, ダウンロード中通知や削除中通知もこのメソッドで生成できます.

ダウンロード進捗率を取得する

DownloadManager.Listenerの onTaskStateChanged コールバック引数のTaskStateから間近のダウンロード進捗率が取得できます.
TaskState.downloadPercentage がダウンロード進捗率を格納したフィールドです.
ダウンロード進捗率が未定/不明 あるいは 削除タスクである場合は com.google.android.exoplayer2.C#PERCENTAGE_UNSET がセットされます.
DownloadServiceでは, このコールバックを受けて通知の進捗率を更新するようになっています.

タスクの実行順序とダウンロードのキャンセル

タスクはDownloadManagerによってArrayListで管理されており, 新しいタスクはタスクキュー(タスクリスト)の最後尾に追加され, 先頭から順に実行されます.
DownloadManager.handleAction は新しいダウンロード/削除アクションアクションからタスクを生成してタスクキューの最後尾に追加します.
新しく削除アクションがリクエストされた場合, 既に同じメディアファイルのダウンロードタスクがタスクキューに存在するなら, そのダウンロードを即座にキャンセルします.
つまり, ダウンロード中やダウンロードリクエストをキューイングした後にこれをキャンセルしたい場合は削除アクションを投げるとキャンセルできます.

ダウンロードの開始条件

サービスによっては”従量制ネットワーク接続時にはダウンロードしたくない”といった要件があるかもしれません.
あるいは, “NWが瞬断されてダウンロード中断されたけれど, NW接続が回復したら自動再開したい” 要件があるかもしれません.
ExoPlayer Downloaderではデバイスの状態を監視して, こうした要件に応える機能があります.

RequirementsHelper はデバイスの状態を監視し, 特定の条件を満たした場合にダウンロードを開始/再開するヘルパークラスです. ここで指定できる”特定の条件” は Requirements クラスで表現され, Requirementsに指定できる条件は次の通りです.

  1. ネットワーク種別 ( NW接続済み, 従量制NWに接続済み, ローミング中, etc. )
  2. 充電中かどうか
  3. アイドル状態かどうか

また, JobSchedulerによる監視もサポートされています.
JobSchedulerを使用する場合は, Requirementsの情報がJobInfoに変換されてスケジューリングされます.

API Lv.によっては判定できるNW種別の種類や, アイドル状態と判定する条件に差異があるので, JobSchedulerRequirementsのコードを確認した方がよいです.

ダウンロードの開始プロセス

ダウンロードを開始するにはDownloadManagerを初期化して, DownloadServiceを起動し, タスク(アクション)を追加する必要があります.
DownloadServiceは起動されるとRequirementsHelperを起動してデバイス状態を監視し始めます.
デバイスの状態がダウンロード開始条件を満たした場合, いよいよダウンロード処理が開始されます.

RequirementsHelperとスケジューラの生存区間

アプリのプロセスが生きている間はRequirementsHelperが動的ブロードキャストレシーバーを使ってデバイス状態を監視し, ダウンロードを開始/再開させます.
デバイスの監視はDownloadServiceによって開始されますが, ダウンロードが中断されてDownloadServiceが停止してもこの監視は続きます.
これは, デバイスの状態を監視するRequirementsHelperをDownloadServiceのstaticフィールドで保持しているためです.

DownloadServiceがgetSchedulerでスケジューラを指定している場合は, スケジューラでもデバイス状態が監視されます.
スケジューラはAndroid標準のJobSchedulerを使用することができ, これによってアプリのプロセスが停止している場合にもダウンロードを開始/再開させることが可能になります.
スケジューラはダウンロード開始条件が満たされていないと判断された場合にスケジューリングされます.

ダウンロード開始条件が満たされた場合, RequirementsHelperによる監視が続いている(アプリのプロセスが生きている)状態であればRequirementsHelperがダウンロードを開始/再開させて, スケジューラのスケジューリングをキャンセルします.
RequiermentsHelperによる監視がされていない(アプリのプロセスが停止している)状態であればスケジューラによる開始/再開が行われます.

スケジューラだけでデバイス監視しないのは, RequirementsHelper(staticフィールドと動的ブロードキャストレシーバー)を使った方がデバイス状態の検知からダウンロードの開始/再開までを素早く行えるというメリットがあります.

RequirementsとSchedulerとDownloadService

RequirementsとSchedulerは, DownloadServiceのgetSchedulergetRequirementsをオーバーライドして指定します.

  @Override
  protected Requirements getRequirements() {
    return new Requirements(Requirements.NETWORK_TYPE_ANY, false, false);
  }

  @Override
  protected PlatformScheduler getScheduler() {
    return Util.SDK_INT >= 21 ? new PlatformScheduler(this, JOB_ID) : null;
  }

JobScheduler を使ったデバイスの監視を実現するため PlatformScheduler クラスが用意されています. PlatformSchedulerを使うにはAndroidManifest.xmlに次の定義を追加します.

<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>

<service android:name="com.google.android.exoplayer2.util.scheduler.PlatformScheduler$PlatformSchedulerService"
    android:permission="android.permission.BIND_JOB_SERVICE"
    android:exported="true"/>

あるいはFirebaseJobDispatcherを使ったスケジューラ JobDispatcherScheduler も用意されています. JobDispatcherSchedulerを使うにはAndroidManifest.xmlに次の定義を追加します.

  <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>

  <service
      android:name="com.google.android.exoplayer2.ext.jobdispatcher.JobDispatcherScheduler$JobDispatcherSchedulerService"
      android:exported="false">
    <intent-filter>
      <action android:name="com.firebase.jobdispatcher.ACTION_EXECUTE"/>
    </intent-filter>
  </service>
2018/09/19

Android: Exoplayer DownloadManager

Exoplayer r2.8.4のダウンローダ機能関連APIのメモ.

r2.6.0の頃はコンテンツをダウンロードするAPIだけが提供されていましたが, r2.8.0からはダウンロードタスクの管理やダウンロード処理の再開、サービスや通知といった部分までサポートされるようになっています.

関連記事:Android: ExoPlayer - Downloader

DownloadManager

DownloadManager
マルチダウンロードストリームの管理とダウンロードリクエストの削除をするクラスです.
このクラスのメソッドはメインスレッド上から呼び出す必要があり, 複数スレッドからの呼び出しは想定されていません.

ダウンロードマネージャは内部ハンドラを持ちます. もしLooperを持たないスレッドからの呼び出しがあった場合, Looper.getMainLooper()によってメインスレッドが取得されます.

次のコードはダウンロードマネージャーを生成します.

// ダウンロードアクションファイルのデシリアライズに必要なクラスを定義
private val DOWNLOAD_DESERIALIZERS = arrayOf(
  DashDownloadAction.DESERIALIZER,
  HlsDownloadAction.DESERIALIZER)

fun initDownloadManager() {
  // ダウンローダの生成に必要なコンストラクタヘルパー
  val constructorHelper = DownloaderConstructorHelper(...)

  // ダウンロードマネージャを生成
  DownloadManager(
    constructorHelper,
    DownloadManager.DEFAULT_MAX_SIMULTANEOUS_DOWNLOADS,
    DownloadManager.DEFAULT_MIN_RETRY_COUNT,
    File( /* アクションファイルを保存するファイルパス */ ),
    DOWNLOAD_DESERIALIZERS)
}

ダウンロードマネージャのコンストラクタパラメータは下記.

public DownloadManager(
    DownloaderConstructorHelper constructorHelper,
    int maxSimultaneousDownloads,
    int minRetryCount,
    String actionSaveFile,
    Deserializer... deserializers) {

constructorHelper
ダウンローダを生成するためのコンストラクタヘルパー.

maxSimultaneousDownloads
最大同時ダウンロード本数. デフォルト値は1 (DownloadManager.DEFAULT_MAX_SIMULTANEOUS_DOWNLOADS) です.

minRetryCount
ダウンロードの最小再試行回数. デフォルト値は5 (DownloadManager.DEFAULT_MIN_RETRY_COUNT) です.

actionSaveFile
DownloadActionのシリアライズを保存するファイルパス. アクションを永続化することでプロセスを跨いでアクションを再開することができる. ただし, このファイルをSimpleCacheで使用したフォルダに保存しないこと. (ダウンローダは知らないファイルを削除するため)

deserializers
actionSaveFileに保存されているDownloadActionのデシリアライザ. HlsDownloadAction.DESERIALIZER etc.

DownloadManager.Listener

DownloadManager.Listenerを使ってダウンロードイベントのリスナーを登録することができます.
次のコールバックメソッドを定義してイベントを受け取ることができるようになります.

onInitialized
全てのアクションがリストアされたときに呼び出される.

onTaskStateChanged
タスクの状態が変わったときに呼び出される.

onIdle
アクティブなタスクがなくなったときに呼び出される.

DownloadAction

DownloadAction
コンテンツのダウンロードリクエストやコンテンツの削除リクエストを表現するクラス.
ダウンロードやコンテンツ削除のために必要なパラメータ情報を保持している.
このクラスは自前のシリアライザ/デシリアライザを持っており, 自身のアクションをシリアライズすることで, プロセスを跨いでも同アクションをでシリアライズして再開できるようになっている.

アクションが持つパラメータは下記.

type
アクションのタイプ.
このタイプ値はシリアライズ情報に含まれ, アクションをデシリアライズする際に最適なデシリアライザーを選択するために使用される.

version
アクションのバージョン.
アクションはシリアライザによって永続化される際にバージョン情報を記録する.
これによって保存されたアクションのバージョンを判別でき, バリデーションやマイグレーション処理に使うことができる.

uri
ダウンロードまたは削除するURI.
SegmentDownloaderを継承したクラスであれば, URIフィールドもシリアライズの対象になる.

isRemoveAction
削除アクションであればtrue, ダウンロードアクションであればfalse.
SegmentDownloaderを継承したクラスであれば, URIフィールドもシリアライズの対象になる.

data
アクションのカスタムデータ.
アクションファイルには任意の情報をカスタムデータとしてバイト配列形式で保存することができる.
SegmentDownloaderを継承したクラスであれば, URIフィールドもシリアライズの対象になる.

アクションファイルのフォーマットは次の通り.

// type, version は共通フォーマット
output.writeUTF(action.type);
output.writeInt(action.version);

// 以下はSegmentDownloadAction系のフォーマット. keysについては後述
output.writeUTF(uri.toString());
output.writeBoolean(isRemoveAction);
output.writeInt(data.length);
output.write(data);
output.writeInt(keys.size());
for (int i = 0; i < keys.size(); i++) 
  writeKey(output, keys.get(i));
}

SegmentDownloader - HlsDownloaderの関係と同じく, HlsDownloadActionSegmentDownloadActionを継承しています.
SegmentDownloadActionの生成方法は下記です.

protected SegmentDownloadAction(
    Uri manifestUri, 
    boolean isRemoveAction, 
    @Nullable String data, 
    K[] keys)

manifestUri
ダウンロードしたいコンテンツのURL(Master/MediaPlaylist etc.).

isRemoveAction
ダウンロードするアクションの場合はfalse, ダウンロードコンテンツの削除アクションの場合はfalse.

data
カスタムデータを指定したい場合はここに指定する.
DownloadServiceでも参照することができる.

keys
ダウンロードするトラックのキー(HLSであればレンディション. DASHであればレプリゼンテーション)を指定します. keysが空配列の場合はすべてのトラックがダウンロードされる.
この引数をnullにすることはできず, またremoveActionがtrueの場合は空配列である必要がある.

DownloadHelper

ダウンロードアクションを生成する際には, ダウンロード対象のプレイリスト/マニフェストURLと, トラックキー(レンディション / レプリゼンテーション)を指定する必要がある.
トラックキーを取得するにはプレイリスト/マニフェストファイルをダウンロード・パースする必要がある.
DownloadHelperはそうした前準備処理とトラックキー取得、ダウンロードアクションの生成を助けてくれる.

DownloadHelperが提供するヘルパーメソッドは次の通り.

prepare
ヘルパーを初期化する.
この操作にはプレイリストやマニフェストのダウンロードを伴う.
引数callbackDownloadHelper.Callbackを指定することで初期化の成功・失敗を受け取ることができる.
初期化処理は別スレッドで実行され, コールバックはメインスレッド上で実行される.

getPeriodCount
有効なピリオドの数を取得します.
HLSコンテンツの場合は固定で1が返され, DASHコンテンツの場合はピリオド数が返されます.
このメソッドはヘルパーを初期化した後で呼び出す必要があります.

getTrackGroups
指定ピリオドに含まれるトラックグループを取得します.
HLSコンテンツの場合, Media playlistであれば空が返され, Master playlistであればvariants, audio, subtitleを含むグループを返します.
DASHコンテンツの場合は, 引数periodIndexで指定されたピリオドに含まれるアダプションセットに含まれるレプリゼンテーションのフォーマット配列を返します.
このメソッドはヘルパーを初期化した後で呼び出す必要があります.

getDownloadAction
指定のトラック(レンディション / レプリゼンテーション)をダウンロードするダウンロードアクションを構築します.
引数dataにはダウンロードアクションのコンストラクタ引数dataを指定します.
このメソッドはヘルパーを初期化した後で呼び出す必要があります.

getRemoveAction
コンテンツを削除するダウンロードアクションを構築します.
このメソッドはヘルパーを初期化していない状態でも呼び出すことができます.

次のコードはヘルパーを使ってダウンロードアクションを生成するものです.

val mediaPlaylistUri = ...
val helper = HlsDownloadHelper(mediaPlaylistUri, dataSourceFactory)
helper.prepare(object : DownloadHelper.Callback {
    override fun onPrepared(helper: DownloadHelper) {
      helper.getDownloadAction( ... )

      // TrackKeyのリストは下記の要領で構築できる
      // val trackKeys = mutableListOf<TrackKey>()
      // for (i in 0 until helper.periodCount) {
      //   val trackGroups = helper.getTrackGroups(i)
      //   for (j in 0 until trackGroups.length) {
      //     val trackGroup = trackGroups.get(j)
      //     for (k in 0 until trackGroup.length) {
      //       // 必要ならtrackGroup.getFormat(k)でパラメータを確認してフィルタアウトできる
      //       trackKeys += TrackKey(i, j, k)
      //     }
      //   }
      // }
    }

    override fun onPrepareError(helper: DownloadHelper, e: IOException) {
      ...
    }
  })

単純にHLSコンテンツのMedia playlistに含まれる全てのレンディションをダウンロードするのであれば, 事前にプレイリストをダウンロードして解析する必要もないので, ヘルパーを使わずに次のように生成します.

HlsDownloadAction(uri, false, data, emptyList())

DonwloadService

DownloadService
バックグラウンドでダウンロード処理を継続維持するためのServiceを継承した抽象クラス.
アプリはこのクラスを継承して必要なメソッドをオーバーライドすることでサービスの管理をExoPlayerに任せることができる.

コンストラクタ引数には次のものがある.

foregroundNotificationId
フォアグラウンドサービス用のNotification ID.

foregroundNotificationUpdateInterval
フォアグラウンドノーティフィケーションをアップデートする間隔(ミリ秒).

channelId
フォアグラウンドノーティフィケーションで使用されるチャネルID.
チャネルは低優先度のチャネルとして作成される. 自身でチャネルを作成する場合はnullを指定する.

channelName
フォアグラウンドノティフィケーションで使用するチャネル名.
自身でチャネルを作成する場合は特に使用されない.

定義されている抽象メソッドは下記.

getDownloadManager()
コンテンツのダウンロードで使用されるDownloadManagerインスタンスを返す.
このメソッドはサービスのライフサイクルの中で1度しか呼ばれない.

getScheduler()
特定の条件を満たした時にDownloadServiceを初期化するジョブを持ったSchedulerを返す.
これによって, アプリが実行されていなくてもダウンロードを開始するスケジューリングが可能になる.
スケジューリングが不要な場合はnullを返す.

getForegroundNotification
フォアグラウンドサービスに必要なNotificationを生成する. 引数taskState[]を使ってNotification情報を構築することができる.
このメソッドは, タスクの状態が変化するか, アクティブなタスクがあれば定期的に呼び出される.
呼び出し間隔はDownloadServiceのコンストラクタで調整可能.
API Lv.26以降, このメソッドはサービスが停止する前に空のTaskState[]を引数に呼び出される.

抽象メソッドではないが, サブクラスが意識するべきメソッドは下記.

getRequirements
ダウンロード開始条件をカスタマイズすることができる. デフォルトではネットワーク接続の有無がダウンロード条件として設定される.

onTaskStateChanged
タスクの状態が変わった時に呼び出される.

ダウンロードサービスの開始

アプリがバックグラウンドにいる状態でもダウンロード処理を継続したい場合は, ダウンロードサービスをフォアグラウンドサービスとして振る舞わせる必要がある.
DownloadServiceDownloadService.startForeground(Notification) を使って起動することができる.

// DownloadService
public static void startWithAction(
    Context context, 
    Class<? extends DownloadService> clazz, 
    DownloadAction downloadAction.
    boolean foreground)

clazz
作成したDownloadServiceのサブクラスを指定します.

downloadAction
DownloadActionはダウンロードストリーム/コンテンツに対するアクション.
対象のストリーム種別によってProgressiveDownloadAction, HlsDownloadAction, DashDownloadActionなどが用意されている.

foreground
フォアグラウンドサービスとして起動する場合はtrue.

ダウンロードサービスを開始するIntentだけが欲しい場合は次のメソッドを使用する.

DownloadService.buildAddActionIntent

生成されるIntentにはダウンロードアクションを格納する download_action と, フォアグラウンドサービスとして移動するかどうかのフラグ foreground が格納される.

ダウンロードサービスは次のメソッドを使うことでダウンロードアクションを指定せずに起動することもできる.

DownloadService.start
DownloadService.startForeground

未完了のダウンロードアクションがある場合や, ダウンロード開始条件が満了された場合, サービスはそれらのダウンロードアクションを再開する.
実行するアクションがなければサービスは即終了する.

ダウンロードタスクの状態

ダウンロードタスクの状態は DownloadManager.TaskState で表現される.

定義:

STATE_QUEUED:開始待ち
STATE_STARTED:開始済み
STATE_COMPLETED:完了済み
STATE_CANCELED:キャンセル済み
STATE_FAILED:失敗

状態遷移図:

queued <-> started -> (canceled | completed | failed)