ホロジュール Android アプリを Kotlin で作る（ホロライブの動画配信予定を収集 その４ やっと完成）

## はじめに

前回（[https://qiita.com/kerobot/items/bd504b0d787de63c364e](https://qiita.com/kerobot/items/bd504b0d787de63c364e)）は、Azure VM の Ubuntu の MongoDB から、ホロライブの配信予定や動画情報を取得する Web API を作成し、Azure VM (Ubuntu) + Nginx + gunicorn へ配置しました。

今回は、作成した Web API からホロジュールの配信予定を取得し、表示する Android アプリを Kotlin で開発してみます。

* Web API : ホロライブの配信予定や動画情報を取得（JWT認証あり）
* Kotlin : 今回の Android アプリ開発で初めて利用したプログラミング言語
* Android Studio : 今回の Android アプリ開発で初めて利用した開発環境

## 最終的にやりたいこと

ここ最近、毎日のように閲覧している バーチャル YouTuber プロダクション「ホロライブ」などの、動画配信予定を定期的にデータベースに格納し、それを Web API で参照して閲覧する Android アプリを作りたい。

1. 動画配信予定を収集するためのプログラムの作成（完了）
2. 収集した動画配信予定を格納するためのデータベースの作成と収集の自動化（完了）
3. 格納した動画配信予定を参照するための Web API の作成（完了）
4. Web API を参照して動画配信予定を閲覧する Android アプリの作成（今回）

## やったこと

構想および着手からちょうど 1年ほどかかりましたが、「Web API を参照して動画配信予定を閲覧する Android アプリの作成」がなんとかできました。

Android アプリの開発は 8年ぐらい前に Eclipse + Java で少々嗜んだ程度で、今回は Android Studio + Kotlin という初めての開発環境と開発言語でかなり苦戦しました。

1. Android 開発環境の準備
    * Android Studio のダウンロード
    * Android Studio のインストール
    * Android Studio の初期設定
    * SDK の設定

2. AVD（Android 仮想デバイス）
    * AVD（Android 仮想デバイス）の作成
    * AVD（Android 仮想デバイス）の設定

3. Android アプリの開発
    * プロジェクトの作成
    * プロジェクトの設定１（AndroidManifest.xml）
    * プロジェクトの設定２（システム環境変数）
    * プロジェクトの設定３（build.gradle）
    * リソースの設定１（顔画像）
    * リソースの設定２（文字列）
    * リソースの設定３（画面レイアウト）
    * プログラム作成１（モデル関連）
    * プログラム作成２（API関連）
    * プログラム作成３（アプリ完成）
    * プログラム実行１（仮想デバイスで実行）
    * プログラム実行２（APKを実機で実行）

## Android 開発環境の準備

下記構成の環境を利用しました。

* Windows 10 Pro 1909 x64
* Git for Windows 2.27.0 x64
* Android Studio 4.1.2
* Android SDK 9.0 (Pie)

### Android Studio のダウンロード

* [Android Studio](https://developer.android.com/studio/)
* 今回は、バージョン 4.1.2 を利用しました。

### Android Studio のインストール

* インストールするコンポーネントとして、「Android Studio」と「Android Virtual Device」をチェックします。

### Android Studio の初期設定

* Android Studio を起動したら、設定をインポートせず、インストールタイプは「Standard」とします。
* 念のため、Android Studio の Welcome 画面右下のメニューから「Check for Updates」を選択してアップデートしておきます。

### SDK の設定

* Android Studio の Welcome 画面右下のメニューから「Configure」の「SDK Manager」を起動します。
* SDK Manager の「SDK Platforms」タブの「Show Package Details」をチェックします。
* リストから「Android 9.0 (Pie)」の「Android SDK Platform 28」、「Source for Android 28」、「Google APIs Intel x86 Atom_64 System Image」をチェックします。
* 続いて、SDK Manager の「SDK Tools」タブの「Show Package Details」をチェックします。
* リストから「Android SDK Build-Tools」の最新版、「⿟Android Emulator」、「Android SDK Platform-Tools」がインストールされていることを確認し、不足していればチェックします。
* SDK Manager の「OK」ボタンをクリックします。
* 確認メッセージが表示されるので、Accept して進めます。

> AMD プロセッサ環境で開発するため、HAXM はインストールしませんでした。
> 代わりに、Hyper-V は無効化し、Windows ハイパーバイザープラットフォームを有効化して、UEFI(BIOS) のアドバンスド設定で、「SVMMode」を有効化し、Android Emulator Hypervisor Driver for AMD Processors をインストールしました。

## AVD（Android 仮想デバイス）

開発するアプリをPCで実行するための AVD（Android 仮想デバイス）を作成して設定します。

### AVD（Android 仮想デバイス）の作成

* Android Studio の Welcome 画面右下のメニューから「Configure」の「AVD Manager」を起動します。
* AVD Manager で Android 仮想デバイスを新規作成します。
* 今回は、「Phone」から「Pixcel4」を選択し、「Android 9.0 (Pie)」の System Image をダウンロードしました。
* 「Enable Device Frame」のチェックを外して Android 仮想デバイスの作成を完了します。

### AVD（Android 仮想デバイス）の設定

* 作成した Android 仮想デバイスの「▶」をクリックして起動します。
* しばらく待つと Android が起動するので、いくつか設定を行っておきます。
* Settingsアプリ → System → Languages & input → Languages → Add a language → 「日本語」を追加し、「日本語」を1番上に移動
* 設定アプリ → システム → 言語と入力 → 仮想キーボード → GBoard → 言語 → 「日本語」を追加し、「QWERTY」を選択
* 設定アプリ → システム → 日付と時刻 → タイムゾーンの自動設定 = OFF → タイムゾーンの選択 → タイムゾーン → 「東京」を選択

## Android アプリの開発

### プロジェクトの作成

* Android Studio を起動し、新規プロジェクトを作成します。
* プロジェクトテンプレートとして、「Empty Activity」を選択します。
* プロジェクト名、パッケージ名、保存場所を指定し、言語に Kotlin、SDK最低バージョンとして API28 (Android 9.0) を選択してプロジェクトを作成しました。
* プロジェクトが作成され、不足しているライブラリ等のダウンロードや設定が行われます。

### プロジェクトの設定１（AndroidManifest.xml）

AndroidManifest.xml に設定を追加します。

> [アプリ マニフェストの概要](https://developer.android.com/guide/topics/manifest/manifest-intro?hl=ja)
> マニフェストファイルは、アプリに関する重要な情報を Android ビルドツール、Android オペレーティング システム、Google Play に対して説明するものです。

* アプリがインターネット接続することを許可するための設定を追加
* アプリの設定情報を追加（設定情報は環境変数から取得し gradle で設定されるようにする）

```xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="kerobot.android.holoduleapp">

    <!-- ↓ インターネット接続することを許可するための設定を追加 -->
    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:allowBackup="false"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.Holoduleapp">

        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <!-- ↓ アプリの設定情報を追加 -->
        <meta-data android:name="API_URL" android:value="${API_URL}" />
        <meta-data android:name="API_USERNAME" android:value="${API_USERNAME}" />
        <meta-data android:name="API_PASSWORD" android:value="${API_PASSWORD}" />
    </application>

</manifest>
```

### プロジェクトの設定２（システム環境変数）

ビルド時に Gradle によって参照されて AndroidManifest.xml が利用する環境変数を、Windows のシステム環境変数に登録しておきます。

> [環境変数について](https://docs.microsoft.com/ja-jp/powershell/module/microsoft.powershell.core/about/about_environment_variables?view=powershell-7.1)
> 環境変数には、オペレーティングシステム環境に関する情報が格納されます。

* Powershell を管理者として起動し、下記コマンドレットを実行します。

```powershell
> [System.Environment]::SetEnvironmentVariable("API_URL", "<https://Web API の URL>", "Machine")
> [System.Environment]::SetEnvironmentVariable("API_USERNAME", "<Web API の JWT 認証情報>", "Machine")
> [System.Environment]::SetEnvironmentVariable("API_PASSWORD", "<Web API の JWT 認証情報>", "Machine")
```

### プロジェクトの設定３（build.gradle）

build.gradle (app) に設定を追加します。

> [Gradle](http://gradle.monochromeroad.com/docs/userguide/introduction.html)
> Gradleは、Java(JVM)環境におけるビルドシステムであり、従来のビルド技術を大きく躍進させるものです。
> Groovyでビルドスクリプトを記述します。

* AndroidManifest.xml に追加した設定情報に環境変数をセットするための設定を追加
* Web API の呼び出しなどに必要となるビルド依存関係を追加

```json
plugins {
    id 'com.android.application'
    id 'kotlin-android'
}

android {
    compileSdkVersion 30
    buildToolsVersion "30.0.3"

    defaultConfig {
        applicationId "kerobot.android.holoduleapp"
        minSdkVersion 28
        targetSdkVersion 30
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

        ↓ 追加
        manifestPlaceholders = [API_URL: System.getenv("API_URL"),
                                API_USERNAME: System.getenv("API_USERNAME"),
                                API_PASSWORD: System.getenv("API_PASSWORD")]
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = '1.8'
    }
}

dependencies {
    ↓ 必要に応じてバージョン更新
    implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
    implementation 'androidx.core:core-ktx:1.3.2'
    implementation 'androidx.appcompat:appcompat:1.2.0'
    implementation 'com.google.android.material:material:1.3.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
    testImplementation 'junit:junit:4.13.1'
    androidTestImplementation 'androidx.test.ext:junit:1.1.2'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
    ↓ 追加
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.8'
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.0'
    implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
    implementation 'com.squareup.retrofit2:retrofit:2.9.0'
    implementation 'com.squareup.okhttp3:logging-interceptor:4.9.0'
}
```

* 設定を反映させるため、Android Studio を起動し直し、Build → CleanProject と Build → RebuildProject を行っておきます。
* システム環境変数が登録されていないとエラーが発生します。
* 「Project Structure Dialog」でおすすめされるライブラリのバージョン更新も行っておきます。

### リソースの設定１（顔画像）

アプリが利用するライバーの顔画像を res\drawable フォルダに配置します。

* 今回は32名分の顔画像の jpg ファイルを個人利用の範囲で利用させて頂きます。

![img01.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283306/7c927cac-63d9-463f-3c7a-d7410dd887fc.jpeg)

### リソースの設定２（文字列）

アプリ名やボタン名や youtube の URL として利用する文字列を定義します。

* res\values\strings.xml

```xml
<resources>
    <string name="app_name">Holodule App</string>
    <string name="bt_date">日付</string>
    <string name="et_search">検索</string>
    <string name="bt_search">検索</string>
    <string name="iv_description">顔</string>
    <string name="youtube_vnd">vnd.youtube:</string>
    <string name="youtube_url">https://www.youtube.com/watch?v=</string>
    <string name="row_time">時間</string>
    <string name="row_name">名前</string>
    <string name="row_title">タイトル</string>
</resources>
```

### リソースの設定３（画面レイアウト）

アプリのメイン画面とリスト行の2つのレイアウトファイルを作成します。

* res\layout\activity_main.xml（メイン画面の XML ファイル）
* 検索ボタンのアイコンとして Vector Asset を追加し、ic_baseline_search_24 を指定しています。

```xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:id="@+id/constraintLayout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <Button
            android:id="@+id/btDate"
            android:layout_width="130dp"
            android:layout_height="wrap_content"
            android:text="@string/bt_date"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <EditText
            android:id="@+id/etSearch"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:autofillHints=""
            android:ems="10"
            android:hint="@string/et_search"
            android:inputType="textPersonName"
            app:layout_constraintEnd_toStartOf="@+id/btSearch"
            app:layout_constraintStart_toEndOf="@+id/btDate"
            app:layout_constraintTop_toTopOf="parent" />

        <Button
            android:id="@+id/btSearch"
            android:layout_width="50dp"
            android:layout_height="wrap_content"
            app:icon="@drawable/ic_baseline_search_24"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

    </androidx.constraintlayout.widget.ConstraintLayout>

    <ListView
        android:id="@+id/lvList"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/constraintLayout" />

</androidx.constraintlayout.widget.ConstraintLayout>
```

* res\layout\holodule_row.xml（リスト行の XML ファイル）
* 顔画像や配信内容を表示するための ListView の 1 行のレイアウトです。

```xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <ImageView
        android:id="@+id/ivFace"
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:contentDescription="@string/iv_description"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:srcCompat="@tools:sample/avatars" />

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="0dp"
        android:layout_height="100dp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@+id/ivFace"
        app:layout_constraintTop_toTopOf="parent">

        <TextView
            android:id="@+id/tvTime"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="4dp"
            android:layout_marginTop="4dp"
            android:width="60dp"
            android:text="@string/row_time"
            android:textSize="18sp"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/tvName"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="4dp"
            android:layout_marginTop="4dp"
            android:layout_marginEnd="4dp"
            android:text="@string/row_name"
            android:textSize="18sp"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toEndOf="@+id/tvTime"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/tvTitle"
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:layout_marginStart="4dp"
            android:layout_marginTop="4dp"
            android:layout_marginEnd="4dp"
            android:layout_marginBottom="4dp"
            android:background="#E6E6FF"
            android:paddingLeft="2dp"
            android:paddingTop="2dp"
            android:paddingRight="2dp"
            android:paddingBottom="2dp"
            android:text="@string/row_title"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/tvName" />
    </androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
```

### プログラム作成１（モデル関連）

モデル関連のプログラムを作成します。

* java\kerobot\android\holoduleapp\model\Auth.kt（JWT の認証情報データクラス）

```kotlin
package kerobot.android.holoduleapp.model

import com.google.gson.annotations.SerializedName

data class Auth (
    @SerializedName("username") var username : String,
    @SerializedName("password") var password : String
)
```

* java\kerobot\android\holoduleapp\model\Token.kt（JWT のトークンデータクラス）

```kotlin
package kerobot.android.holoduleapp.model

data class Token(val access_token: String?)
```

* java\kerobot\android\holoduleapp\model\Holodule.kt（Holodule のデータクラス）

```kotlin
package kerobot.android.holoduleapp.model

data class Holodule(val key: String?,
                    val video_id: String?,
                    val datetime: String?,
                    val name: String?,
                    val title: String?,
                    val url: String?,
                    val description: String?)
```

* java\kerobot\android\holoduleapp\model\Result.kt（Holuduke を含む API 結果のデータクラス）

```kotlin
package kerobot.android.holoduleapp.model

data class Result(val result: Int?, val holodules: List<Holodule>?)
```

### プログラム作成２（API関連）

Web API 関連のプログラムを作成します。
Web API とのやりとりに retrofit2 を利用してみました。

* java\kerobot\android\holoduleapp\api\IApiService.kt（API インターフェース）

```kotlin
package kerobot.android.holoduleapp.api

import kerobot.android.holoduleapp.model.Auth
import kerobot.android.holoduleapp.model.Token
import kerobot.android.holoduleapp.model.Result
import retrofit2.http.*

interface IApiService {
    // Web API の JWT トークンを取得（POST）
    @Headers("Content-Type: application/json")
    @POST("holoapi/auth")
    suspend fun createToken(@Body auth: Auth): Token

    // JWT トークンを指定してホロジュールの配信予定を取得（GET）
    @Headers("Content-Type: application/json")
    @GET("holoapi/holodules/{date}")
    suspend fun getHolodules(@Header("Authorization") jwtToken: String,
                     @Path("date") dateString: String): Result
}
```

* java\kerobot\android\holoduleapp\api\ApiClient.kt（API クライアント）

```kotlin
package kerobot.android.holoduleapp.api

import com.google.gson.GsonBuilder
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit

val httpBuilder: OkHttpClient.Builder get() {
    // HTTP client の作成
    val timeout:Long = 30
    val httpClient = OkHttpClient.Builder().addInterceptor(Interceptor { chain ->
        val original = chain.request()
        val request = original.newBuilder()
                .header("Accept", "application/json")
                .method(original.method, original.body)
                .build()
        return@Interceptor chain.proceed(request)
    }).readTimeout(timeout, TimeUnit.SECONDS)
    // ログの設定
    val loggingInterceptor = HttpLoggingInterceptor()
    loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
    httpClient.addInterceptor(loggingInterceptor)
    return httpClient
}

fun <S> create(serviceClass: Class<S>, baseUrl: String): S {
    // gson の作成
    val gson = GsonBuilder().serializeNulls().create()
    // retrofit の作成
    val retrofit = Retrofit.Builder()
            // 基本 URL の指定
            .baseUrl(baseUrl)
            // コンバーターとして Gson を利用
            .addConverterFactory(GsonConverterFactory.create(gson))
            // カスタマイズした HTTP client を指定
            .client(httpBuilder.build())
            // リクエストの組み立て
            .build()
    // Interface から実装を取得
    return retrofit.create(serviceClass)
}
```

### プログラム作成３（アプリ本体）

アプリ本体のプログラムを作成します。
リストアダプターを利用してメイン画面の ListView に配信予定リスト表示しました。

* java\kerobot\android\holoduleapp\ListAdapter.kt（リストアダプター）

```kotlin
package kerobot.android.holoduleapp

import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Color
import android.graphics.Typeface
import android.icu.text.SimpleDateFormat
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.ImageView
import android.widget.TextView
import kerobot.android.holoduleapp.model.Holodule
import java.util.*
import kotlin.collections.ArrayList

class ListAdapter (private val context: Context, private val holodules: ArrayList<Holodule>) : BaseAdapter() {

    private val faceMap = mapOf(
        "ときのそら" to R.drawable.tokino_sora,
        "ロボ子さん" to R.drawable.robokosan,
        "さくらみこ" to R.drawable.sakura_miko,
        "星街すいせい" to R.drawable.hoshimachi_suisei,
        "夜空メル" to R.drawable.yozora_mel,
        "アキ・ローゼンタール" to R.drawable.aki_rosenthal,
        "赤井はあと" to R.drawable.haachama,
        "白上フブキ" to R.drawable.shirakami_fubuki,
        "夏色まつり" to R.drawable.natsuiro_matsuri,
        "湊あくあ" to R.drawable.minato_aqua,
        "紫咲シオン" to R.drawable.murasaki_shion,
        "百鬼あやめ" to R.drawable.nakiri_ayame,
        "癒月ちょこ" to R.drawable.yuzuki_choco,
        "大空スバル" to R.drawable.oozora_subaru,
        "大神ミオ" to R.drawable.ookami_mio,
        "猫又おかゆ" to R.drawable.nekomata_okayu,
        "戌神ころね" to R.drawable.inugami_korone,
        "兎田ぺこら" to R.drawable.usada_pekora,
        "潤羽るしあ" to R.drawable.uruha_rushia,
        "不知火フレア" to R.drawable.shiranui_flare,
        "白銀ノエル" to R.drawable.shirogane_noel,
        "宝鐘マリン" to R.drawable.housyou_marine,
        "天音かなた" to R.drawable.amane_kanata,
        "桐生ココ" to R.drawable.kiryu_coco,
        "角巻わため" to R.drawable.tsunomaki_watame,
        "常闇トワ" to R.drawable.tokoyami_towa,
        "姫森ルーナ" to R.drawable.himemori_luna,
        "獅白ぼたん" to R.drawable.shishiro_botan,
        "雪花ラミィ" to R.drawable.yukihana_lamy,
        "尾丸ポルカ" to R.drawable.omaru_polka,
        "桃鈴ねね" to R.drawable.momosuzu_nene,
        "魔乃アロエ" to R.drawable.mano_aloe,
    )

    @SuppressLint("ViewHolder", "InflateParams", "SimpleDateFormat")
    override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
        val view: View = LayoutInflater.from(context).inflate(R.layout.holodule_row, null)
        val time = view.findViewById<TextView>(R.id.tvTime)
        val name = view.findViewById<TextView>(R.id.tvName)
        val title = view.findViewById<TextView>(R.id.tvTitle)
        val face = view.findViewById<ImageView>(R.id.ivFace)

        val holodule = holodules[position]
        val dateTime = SimpleDateFormat("yyyyMMdd HHmmss").parse(holodule.datetime)
        time.text = SimpleDateFormat("HH:mm").format(dateTime)
        if(dateTime > Date()) {
            time.setTextColor(Color.BLUE)
            time.typeface = Typeface.DEFAULT_BOLD
        }
        name.text = holodule.name
        title.text = holodule.title
        val faceId = faceMap.getOrElse(holodule.name!!, {0})
        if(faceId != 0 ) {
            face.setImageResource(faceId)
        }
        return view
    }

    override fun getItem(position: Int): Any {
        return holodules[position]
    }

    override fun getItemId(position: Int): Long {
        return 0
    }

    override fun getCount(): Int {
        return holodules.size
    }
}
```

* java\kerobot\android\holoduleapp\MainActivity.kt（メイン画面）

```kotlin
package kerobot.android.holoduleapp

import android.app.DatePickerDialog
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.AdapterView
import android.widget.Button
import android.widget.EditText
import android.widget.ListView
import androidx.appcompat.app.AppCompatActivity
import kerobot.android.holoduleapp.api.IApiService
import kerobot.android.holoduleapp.api.create
import kerobot.android.holoduleapp.model.Auth
import kerobot.android.holoduleapp.model.Holodule
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.format.DateTimeFormatter

class MainActivity : AppCompatActivity() {

    private lateinit var selectedDate: LocalDate
    private lateinit var searchString: String
    private lateinit var service: IApiService
    private lateinit var auth: Auth
    private lateinit var holoduleList: ArrayList<Holodule>

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        Log.d("MainActivity", "onCreate state:" + lifecycle.currentState)
        // 初期化
        selectedDate = LocalDate.now()
        searchString = ""
        holoduleList = ArrayList()
        // 設定読込
        val appInfo = packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA)
        val baseUrl = appInfo.metaData.getString("API_URL")
        val userName = appInfo.metaData.getString("API_USERNAME")
        val password = appInfo.metaData.getString("API_PASSWORD")
        service = create(IApiService::class.java, baseUrl!!)
        auth = Auth(userName!!, password!!)
        // 日付ボタン
        val btDate = findViewById<Button>(R.id.btDate)
        btDate.text = selectedDate.format(DateTimeFormatter.ofPattern("yyyy/MM/dd"))
        val dateListener = DateListener()
        btDate.setOnClickListener(dateListener)
        // 検索ボタン
        val btSearch = findViewById<Button>(R.id.btSearch)
        val searchListener = SearchListener()
        btSearch.setOnClickListener(searchListener)
        // リスト
        val lvList = findViewById<ListView>(R.id.lvList)
        val listItemClickListener = ListItemClickListener()
        lvList.onItemClickListener = listItemClickListener
        val listAdapter = ListAdapter(applicationContext, holoduleList)
        lvList.adapter = listAdapter
    }

    override fun onStart() {
        super.onStart()
        Log.d("MainActivity", "onStart state:" + lifecycle.currentState)
        // ホロジュールの取得
        searchHolodule()
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        Log.d("MainActivity", "onSaveInstanceState state:" + lifecycle.currentState)
        // 日付
        outState.putString("date", selectedDate.format(DateTimeFormatter.ofPattern("yyyy/MM/dd")))
        // 検索文字列
        outState.putString("search", searchString)
    }

    override fun onRestoreInstanceState(savedInstanceState: Bundle) {
        super.onRestoreInstanceState(savedInstanceState)
        Log.d("MainActivity", "onRestoreInstanceState state:" + lifecycle.currentState)
        // 日付
        val dateString = savedInstanceState.getString("date", selectedDate.format(DateTimeFormatter.ofPattern("yyyy/MM/dd")))
        selectedDate = LocalDate.parse(dateString, DateTimeFormatter.ofPattern("yyyy/MM/dd"))
        val btDate = findViewById<Button>(R.id.btDate)
        btDate.text = selectedDate.format(DateTimeFormatter.ofPattern("yyyy/MM/dd"))
        // 検索文字列
        searchString = savedInstanceState.getString("search", "")
        val btSearch = findViewById<Button>(R.id.btSearch)
        btSearch.text = searchString
        // ホロジュールの取得
        searchHolodule()
    }

    private inner class DateListener : View.OnClickListener {
        // 日付選択
        override fun onClick(view: View) {
            val datePickerDialog = DatePickerDialog(
                view.context,
                { _, year, month, dayOfMonth ->
                    selectedDate = LocalDate.of(year, month + 1, dayOfMonth)
                    val btDate = findViewById<Button>(R.id.btDate)
                    btDate.text = selectedDate.format(DateTimeFormatter.ofPattern("yyyy/MM/dd"))
                    // ホロジュールの取得
                    searchHolodule()
                },
                selectedDate.year,
                selectedDate.monthValue - 1,
                selectedDate.dayOfMonth
            )
            datePickerDialog.show()
        }
    }

    private inner class ListItemClickListener: AdapterView.OnItemClickListener{
        // リストクリック
        override fun onItemClick(parent: AdapterView<*>, view: View, position: Int, id: Long) {
            val holodule = parent.getItemAtPosition(position) as Holodule
            try {
                // youtubeアプリで表示
                val intent = Intent(
                    Intent.ACTION_VIEW,
                    Uri.parse(getString(R.string.youtube_vnd) + holodule.video_id)
                )
                startActivity(intent)
            } catch (ex: ActivityNotFoundException) {
                // ブラウザで表示
                val intent = Intent(
                    Intent.ACTION_VIEW,
                    Uri.parse(getString(R.string.youtube_url) + holodule.video_id)
                )
                startActivity(intent)
            }
        }
    }

    private inner class SearchListener : View.OnClickListener {
        override fun onClick(view: View) {
            // ホロジュールの取得
            searchHolodule()
        }
    }

    private fun searchHolodule() {
        // 検索文字列
        searchString = findViewById<EditText>(R.id.etSearch).text.toString()
        // リストアダプタ
        val listAdapter = findViewById<ListView>(R.id.lvList).adapter as ListAdapter
        // リストのクリア
        holoduleList.clear()
        // コルーチンで API アクセス
        CoroutineScope(Dispatchers.Main).launch {
            try {
                // トークンの取得
                val token = service.createToken(auth)
                // データの取得
                val jwtToken = "JWT " + token.access_token.toString()
                val dateString = selectedDate.format(DateTimeFormatter.ofPattern("yyyyMMdd"))
                val result = service.getHolodules(jwtToken, dateString)
                // データの絞り込み
                val filteredList = result.holodules?.filter {
                    x -> x.title?.contains(searchString) ?: false || x.description?.contains(searchString) ?: false
                }
                // リストの追加
                if (filteredList != null && filteredList.any()) {
                    holoduleList.addAll(filteredList)
                }
            }
            catch (e: Exception) {
                Log.d("Search", e.toString())
            }
            finally {
                listAdapter.notifyDataSetChanged()
            }
        }
    }
}
```

### プログラム実行１（仮想デバイスで実行）

Android 仮想デバイスを起動し、作成したアプリを Android Studio から実行します。

* Web API からホロジュールの配信予定を取得し、リスト表示することができました！
* 日付や任意の文字列でリスト表示を絞り込めます。
* リストをクリックすると Youtube を起動します。

![img02.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283306/b2e1add0-71fb-e0f1-2970-7e5adc17af45.jpeg)

### プログラム実行２（APKを実機で実行）

作成したアプリを Android にインストールするための APK ファイルを作成します。

* Android Studio の Build → Generate Signed Bundle / APK をクリックします。
* APK を選択し、キーストアを作成して指定し、シグネチャバージョンの V1 と V2 をチェックして作成します。
* 作成した APK ファイルを実機にコピーし、セキュリティ警告は無視してインストールします。
* 実機でも動作することを確認できました。

## おわりに

冒頭にも書きましたが、Android Studio + Kotlin という初めての開発環境や開発言語でかなり苦戦しました。
Android アプリってどうやって作るんだっけ？の状態から、開発環境も開発言語も初めての組み合わせだったので、なんとか動いている程度の実装になったかなと思います。
Fragment や ActivityライフサイクルのViewModel なども含めてあるべき実装方法をまったく調べられていないので、つまみ食いで終わらないように学習を進めたいです。

このアプリを作るために Kotlin の言語仕様をとりまとめてみたのが 1 年以上前、そこから Python などを利用してサーバー側の処理を作り、やっと目標に辿り着きました。
できあがったアプリを娘に見せたところ、「作ったの？え？キモイ」というお褒めの言葉を頂きましたので、引き続きホロライブの配信を見ながら Android アプリ開発を楽しみます。