Dialogflow の Java client library は Android で動作するのか？（DetectIntent編）

# はじめに

Android で Dialogflow を使いたい！というのはよくある話だと思う。
さて、作ってみよう！とまずは SDK が無いか調べてみる。

https://cloud.google.com/dialogflow/docs/reference/libraries/java
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315833/fbabdbf4-b903-9935-2976-c5f7f77b0256.png)

おっと、Android はサポートしていないだって。
みんなどうやって開発してるんだろ？と思って調べてみると、[api.ai](https://github.com/dialogflow/dialogflow-android-client) なるものを発見！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315833/df609301-15d5-1b60-fe7a-efc4e3c03dba.png)

なんだって。
Dialogflow API V2 を使いたいなら client library を使え？
サポート外って書いてるじゃん…。

はてさて、REST API 使うしかないのか。
けど、SDK は音声入力ストリームもできるようで、是非とも使いたい！

そこで、Java client library がどこまで動くのか検証してみました。

今回は **DetectIntent** が動くか確認してみます。
別途、音声入力ストリームも試す予定です。

(追記) 
音声入力ストリーム編もアップしました！
https://qiita.com/r-hashioka/items/a9d0ca0704ef8dd739a2
ただ、今回記事ありきで書いてるので結構端折ってます。

# 対象者
Dialogflow の機能説明などは省きます。
はじめましての人は[公式ドキュメント](https://cloud.google.com/dialogflow/docs/)などを見てから読むといいかもしれません。

# 環境
言語：Kotlin
端末：Pixcel3a (Android 10)

# Dialogflow の 事前準備

今回使用する intent は次の2点です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315833/babf9401-a032-d472-16d2-bd5c887079af.png)

## Default Welcome Intent
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315833/25ed62e0-1e9b-5594-a5d7-bf37175cec90.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315833/8b09061c-ba49-5257-2e80-3f2aa605d01a.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315833/82111b5b-6fdf-ed6a-5b0d-6a563e68621f.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315833/df981202-bc49-218b-2092-e0a33af980a5.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315833/0e95fe5d-aa61-5dc8-776a-38c1869b4ed4.png)

## today's_weather
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315833/0f8e386e-2413-41da-3f05-18d4452e11d0.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315833/e1ea5fd5-62d3-b521-79d9-f85d9757058e.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315833/3d9c4a13-242c-7635-14b4-ff84893b4511.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315833/91fd3597-52ff-67fb-e0ab-ef7165fedec3.png)

## 説明
- 挨拶をすると、```Default Welcome Intent``` に入り、コマンド受付状態に入ります。
- 「今日の天気は？」と問いかけると「今日の天気は晴れです」と応答がある。といった流れです。

# Let's Programming!!!

## まずはライブラリ import から

dependencies に下記２つを追加してください。
Dialogflow のライブラリだけではダメだったようです。

``` app/build.gradle
    implementation 'com.google.cloud:google-cloud-dialogflow:0.105.0-alpha'
    
    // https://mvnrepository.com/artifact/io.grpc/grpc-okhttp
    // GCP サービスとコネクション貼るときに使用
    implementation 'io.grpc:grpc-okhttp:1.25.0'
```

## Manifest に PERMISSION 追加

- Dialogflow に接続するので INTERNET を追加
- 音声入力ストリームも見越して、RECORD_AUDIO も追加

``` AndroidManifest.xml
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.RECORD_AUDIO" />
```

## credentials.json を配置

- まずはアカウントキー IAM の サービスアカウント の「Dialogflow Integrations」から鍵を作成し、JSONをダウンロード
細かい手順は省きます。
下記を参考にダウンロードしてください。
https://cloud.google.com/iam/docs/creating-managing-service-account-keys?hl=ja#iam-service-account-keys-create-console
https://www.magellanic-clouds.com/blocks/guide/create-gcp-service-account-key/

- ``` res/raw ``` に ``` credentials.json``` を配置

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315833/7cb3e737-fff6-abc5-0845-876f3c33e114.png)

### ※※注意※※
Credential を端末に持たせるのはいいことではありません。
今回は検証でとりあえず、とやっているだけですので、対策必須です！

## layout を作成

下記画像のような画面を作りたいと思います
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315833/8c6ff1f0-e03e-e7a1-a056-8f9c971670ef.png)

各 UI の機能は下記の通りです。

- 「こんにちは」ボタンを押すと、Dialogflow に「こんにちは」を送信
- 「今日の天気」ボタンを押すと、Dialogflow に「今日の天気は？」を送信
- Dialogflow からの応答を Hello World! の TextView に表示
- 「RESET」ボタンを押すと、contexts を削除します。

```  activity_main.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">

    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <Button
        android:id="@+id/greetingBtn"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="32dp"
        android:text="こんにちは"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <Button
        android:id="@+id/weatherBtn"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="32dp"
        android:text="今日の天気"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/greetingBtn" />

    <Button
        android:id="@+id/resetBtn"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="32dp"
        android:text="RESET"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/weatherBtn" />

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

## MainActivity にボタンイベント追加

※この時点ではエラー発生します。

``` MainActivity.kt
package jp.hashioka.ryo.dialogflowsample

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.Button
import android.widget.TextView
import jp.hashioka.ryo.dialogflowsample.dialogflow.DetectIntent


class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val detectIntent = DetectIntent(this)

        findViewById<Button>(R.id.greetingBtn).setOnClickListener {
            findViewById<TextView>(R.id.textView).text = detectIntent.send("こんにちは")
        }
        findViewById<Button>(R.id.weatherBtn).setOnClickListener {
            findViewById<TextView>(R.id.textView).text = detectIntent.send("今日の天気は？")
        }
        findViewById<Button>(R.id.resetBtn).setOnClickListener {
            detectIntent.resetContexts()
            findViewById<TextView>(R.id.textView).text = "-"
        }
    }

    companion object {
        const val TAG = "MainActivity"
    }
}

```

## DetectIntent クラス作成

dialogflow パッケージを作成し、DetectIntent.kt ファイルを作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315833/67360b69-5868-20da-aae3-55f6b7fc4d74.png)

``` dialogflow/DetectIntent.kt
package jp.hashioka.ryo.dialogflowsample.dialogflow

import android.content.Context
import android.util.Log
import com.google.api.gax.core.FixedCredentialsProvider
import com.google.auth.oauth2.GoogleCredentials
import com.google.cloud.dialogflow.v2.*
import jp.hashioka.ryo.dialogflowsample.R

/**
 * Dialogflow の detectIntent に関するクラス
 */
class DetectIntent (
    context: Context
) {

    companion object {
        private const val TAG = "DetectIntent"
        const val PROJECT_ID = "voice-recognition-trial-261200"
        const val LANGUAGE_CODE = "ja" // TODO: Dialogflow の言語コードはグローバル対応するときに設定ファイルで管理
        val SCOPE = listOf("https://www.googleapis.com/auth/cloud-platform")

        /**
         * セッションを取得する。
         * TODO : Dialogflow のセッションはクライアント毎にユニークとなるよう処理を記述する。
         */
        private fun getSession() : String {
            return "hogehoge"
        }
    }

    private val sessionsClient : SessionsClient
    private val contextClient : ContextsClient

    init {
        // 認証情報セット
        val credentials = GoogleCredentials
            .fromStream(context.resources.openRawResource(R.raw.credentials))
            .createScoped(SCOPE)
        sessionsClient = createSessions(credentials)
        contextClient = createContexts(credentials)
    }

    /**
     * SessionClient を作成する。
     */
    private fun createSessions(credentials: GoogleCredentials): SessionsClient {
        val sessionsSetting =
            SessionsSettings.newBuilder()
                .setCredentialsProvider(FixedCredentialsProvider.create(credentials))
                .build()
        return SessionsClient.create(sessionsSetting)
    }

    /**
     * ContextsClient を作成する。
     */
    private fun createContexts(credentials: GoogleCredentials) : ContextsClient {
        val contextsSettings =
            ContextsSettings.newBuilder()
                .setCredentialsProvider(FixedCredentialsProvider.create(credentials))
                .build()
        return ContextsClient.create(contextsSettings)
    }

    /**
     * detectIntent を実行し、その結果を返却
     * 指定されたテキストを送信するだけ。
     */
    fun send(text: String) : String {
        val request = DetectIntentRequest.newBuilder()
            .setQueryInput(
                QueryInput.newBuilder()
                    .setText(
                        TextInput.newBuilder()
                            .setText(text)
                            .setLanguageCode(LANGUAGE_CODE)
                    )
                    .build())
            .setSession(SessionName.format(PROJECT_ID, getSession()))
            .build()

        val res = sessionsClient.detectIntent(request)
        Log.d(TAG, "response result : ${res.queryResult}")
        return res.queryResult.fulfillmentText
    }

    /**
     * detectIntent を実行し、その結果を返却
     * context 指定可能
     */
    fun send(text: String, contexts: List<String>) : String {
        val queryParametersBuilder = QueryParameters.newBuilder()
        contexts.forEach {
            queryParametersBuilder
                .addContexts(
                    com.google.cloud.dialogflow.v2.Context.newBuilder()
                        .setName(ContextName.format(PROJECT_ID, getSession(), it))
                        .setLifespanCount(5) // TODO: context の Lifespan を動的にする。
                        .build()
                )
        }

        // Dialogflow に投げるテキスト、コンテキストなどセット
        val request = DetectIntentRequest.newBuilder()
            .setQueryParams(queryParametersBuilder.build())
            .setQueryInput(
                QueryInput.newBuilder()
                    .setText(
                        TextInput.newBuilder()
                            .setText(text)
                            .setLanguageCode(LANGUAGE_CODE)
                    )
                    .build())
            .setSession(SessionName.format(PROJECT_ID, getSession()))
            .build()

        val res = sessionsClient.detectIntent(request)
        Log.d(TAG, "response result : ${res.queryResult}")
        return res.queryResult.fulfillmentText
    }

    /**
     * context をリセットする。
     */
    fun resetContexts() {
        contextClient.deleteAllContexts(SessionName.format(PROJECT_ID, getSession()))
    }
}
```

おお！動いた！

# さいごに
Android でも DetectIntent はできました。
多分、REST API の機能くらいはできるんでは？と思ってます。

結構走り書きでしたが、最後まで読んでいただきありがとうございます。

[次回、音声入力ストリーム編。](https://qiita.com/r-hashioka/items/a9d0ca0704ef8dd739a2)

(追記) 
ソースコードを GitHub に公開しました。（音声入力ストリームも含む）
https://github.com/ryohashioka/DialogflowSampleForAndroid