AWS AmplifyでAndroidアプリ構築

はじめに
--

業務でサーバーサイドとのやり取りをGraphQLを使ってやってみようということになり、AndroidアプリにAWS Amplify CLIとAndroid用AWS SDKを導入しました。

今回AWSの各サービスを統一して利用するため、Cognito認証を使います。

### 開発環境
- MacBook Pro 10.14.1  
- Android Studio 3.2.1

公式ドキュメント
--
Amplify Android SDK Getting Start
https://aws-amplify.github.io/docs/android/start

事前準備
--
1. Node.jsとnpmがマシンにインストールされていない場合はインストールします。
2. 導入したAndroidアプリのディレクトリへ移動しAmplify Command Line Interface (Amplify CLI)をインストールします。　

```bash
$ cd ./YOUR_PROJECT_FOLDER
$ npm install -g @aws-amplify/cli  
```

導入手順
--
事前準備に引き続き同ディレクトリにてAmplifyの初期化を行います。

```bash

$ amplify init　　
```

上記のコマンドを実行すると、使用エディタ、AWSアカウント情報などを聞かれるので入力。エディタは`IDEA 14 CE`を選択。

```bash

Note: It is recommended to run this command from the root of your app directory
? Enter a name for the project (<YOUR_PROJECT_NAME>)
? Choose your default editor: IDEA 14 CE
? Choose the type of app that you're building android
Describe your project:
? Where is your Res directory:  app/src/main/res
Using default provider awscloudformation
AWS access credentials can not be detected.
? Setup new user No

For more information on AWS Profiles, see:
https://docs.aws.amazon.com/cli/latest/userguide/cli-multiple-profiles.html

? accessKeyId:  (<YOUR_ACCESS_KEY_ID>)
? secretAccessKey:  (<YOUR_SECRET_ACCESS_KEY>)
? region:  ap-northeast-1
⠧ Initializing project in the cloud... //　以下略
```

デプロイ用の初期AWSクラウドリソース作成しました。プロジェクトは初期化されクラウドに接続されました！

```bash

$ amplify push
```

上記のコマンドを実行すると`./app/src/main/res/raw` に`awsconfiguration.json`が追加されました。
この時点ではまだ`awsconfiguration.json`にはデフォルトの情報しかありません。

```json:awsconfiguration.json
{
    "UserAgent": "aws-amplify-cli/0.1.0",
    "Version": "1.0",
    "IdentityManager": {
        "Default": {}
    }
}
```

以下は`awsconfiguration.json`にAPIキーを使用する場合の記述を追加しています。

```json:awsconfiguration.json
{
  "UserAgent": "aws-amplify-cli/0.1.0",
  "Version": "1.0",
  "IdentityManager": {
    "Default": {}
  },
  "AppSync": {
    "Default": {
      "ApiUrl": "https://<xxxxxxxxxx>.appsync-api.ap-northeast-1.amazonaws.com/graphql",
      "Region": "ap-northeast-1",
      "AuthMode": "API_KEY",
      "ApiKey": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    }
  }
}
```

次に、GraphQLスキーマやレスポンスのデータクラスなどのコード生成を実行します。 

```bash

$ amplify add codegen --apiId XXXXXXXXXXXXXXXXXXXXX
```

実行すると、`./app/src/main/graphql/../queries.graphql`や
`./app/build/generated` 配下にGetPostQueryなどクラスが生成されました。
<img width="461" alt="スクリーンショット 2018-12-04 1.04.52.png" src="https://qiita-image-store.s3.amazonaws.com/0/15651/a51d9ced-c5de-33b2-db17-b09dc1ad2d7d.png">

次からはAndroidStudioを起動し、コードの追加していきます。
プロジェクト配下の build.gradle の dependencies に以下を追加します。

```gradle:projectDir/.build.gradle
  dependencies {
      // other classpath...
      
      classpath "com.amazonaws:aws-android-sdk-appsync-gradle-plugin:$versions.appsync"
      }
```

appのbuild.gradle の 一番上に以下を追加。

```gradle

apply plugin: 'com.amazonaws.appsync'
```

~~ここでなんでEclipseなんだろうと思ってしまった。~~
AWS Mobile SDK、AppSync SDK、MQTT(Message Queuing Telemetry Transport) アプリを実装する上で必要なライブラリも依存関係に追加します。

```gradle:app/.build.gradle

  //Base SDK
  implementation 'com.amazonaws:aws-android-sdk-core:2.8.2'

  //AppSync SDK
  implementation 'com.amazonaws:aws-android-sdk-appsync:2.6.28'

  implementation 'org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.0'
  implementation 'org.eclipse.paho:org.eclipse.paho.android.service:1.1.1'
```

AndroidManifest.xml  に以下パーミッションとサービスを追加します。

```xml:AndroidManifest.xml
  <uses-permission android:name="android.permission.INTERNET"/>
  <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
  <uses-permission android:name="android.permission.WAKE_LOCK" />
  <uses-permission android:name="android.permission.READ_PHONE_STATE" />
  <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
  <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>

  <application ... // 略

    <service android:name="org.eclipse.paho.android.service.MqttService" />
```

以下はパーミッションとその使用目的を一覧にしています。各パーミッションの使用目的が何か随時わかれば更新していきます。

|       permission       |使用目的 |
|:--------------------------------|:-------------------|
| INTERNET,<br>ACCESS_NETWORK_STATE | SDKの初期化に必要 |
| WAKE_LOCK |`MqttService.NetworkConnectionIntentReceiver`にて必要.<br>サーバーへの接続が失われた後、再び使用可能なデータ接続が確立するまで待機するために必要 |
| READ_PHONE_STATE | 不明|
| WRITE_EXTERNAL_STORAGE | 不明 |
| READ_EXTERNAL_STORAGE | 不明 |


MainActivity#onCreate()に以下を追加します。
AWSAppSyncClientの初期化処理にてawsconfiguration.jsonからserverUrl、regionおよびAuth Modeを読み込みます

```kotlin:MainActiviti.kt

    // apikey で認証
    awsAppSyncClient = AWSAppSyncClient.builder()
      .context(applicationContext)
      .awsConfiguration(AWSConfiguration(applicationContext))
      .build()
```
上記までで、一度実行してみます。
AWS AppSync SDKの初期化が成功しました。

### GraphQL実行
GraphQLでは以下三つの操作があります。
- Query 読み取り専用フェッチ
- Mutation 書き込み、その後のフェッチ
- Subscription サーバと長時間接続し、データを継続して受信する
 
#### query 
Callback　インスタンスの作成と　`AwsAppSyncClient#enqueue()`をコールするだけでフェッチができました。
ネイティブアプリっぽいコードでかけているでしょうか。
responseFetcher()では値をキャッシュから得るのか、ネットワークから得るのかなど指定でき、
用途によって使い分けることができるようですが、ドキュメントでは`AppSyncResponseFetchers.CACHE_AND_NETWORK`を推奨しています。
ネットワークを介してデータを取得する前に、まずローカルキャッシュから結果を取得するためとのことです。
オフラインサポートが可能とのことですが、ここは別途検証が必要そうです。

```kotlin:MainActiviti.kt

  fun query() {
    val builder = GetPostQuery.builder()
      .id("XXXXXXXXXXXXXXXXXXXXX")

    awsAppSyncClient.query(builder.build())
      .responseFetcher(AppSyncResponseFetchers.CACHE_AND_NETWORK)
      .enqueue(getPostCallback)
  }

  private val getPostCallback = object: GraphQLCall.Callback<GetPostQuery.Data>() {
    override fun onResponse(response: Response<GetPostQuery.Data>) {
      Timber.d("GetPostQuery %s", response.data().toString())
    }

    override fun onFailure(e: ApolloException) {
      Timber.e(e.message)
    }
  }

```

responceをログ出力した結果

```
GetPostQuery Data{getPost=GetPost{__typename=Post, id=XXXXXXXXXXXXXXXXXXXXX, author=iridge, content=appsync, views=1, comments=[]}}
```

#### mutation 

```kotlin:MainActiviti.kt

  fun runMutation() {
    val createCommentMutation =
      CreateCommentMutation.builder()
        .author("iridge")
        .postId("XXXXXXXXXXXXXXXXXXXXX")
        .content("Use AppSync! content")
        .build()

    awsAppSyncClient.mutate(createCommentMutation)
      .enqueue(mutationCallback)
  }

  private val mutationCallback = object: GraphQLCall.Callback<CreateCommentMutation.Data>() {
    override fun onResponse(response: Response<CreateCommentMutation.Data>) {
      Timber.d("CreateCommentMutation", response.data().toString())
    }

    override fun onFailure(e: ApolloException) {
      Timber.e("Error", e.toString())
    }
  }

```
responceをログ出力した結果

```
demo.app D/AppSync: Adding object: CreateCommentMutation 
    
     {"query":"mutation CreateComment($postId: String!, $author: String!, $content: String!) {  createComment(postId: $postId, author: $author, content: $content) {    __typename    id    postId    author    content    upvotes    downvotes  }}","variables":{"postId":"XXXXXXXXXXXXXXXXXXXXX","author":"iridge","content":"Use AppSync! content"}}
```

#### subscription 

```java

  fun subscription() {
    val subscription = AddedCommentByAuthorSubscription.builder().build()
    subscriptionWatcher = awsAppSyncClient.subscribe(subscription)
    subscriptionWatcher.execute(subCallback)
  }

  private val subCallback =
    object : AppSyncSubscriptionCall.Callback<AddedCommentByAuthorSubscription.Data> {
      override fun onResponse(response: Response<AddedCommentByAuthorSubscription.Data>) {
        Timber.d("Response", response.data()!!.toString())
      }

      override fun onFailure(e: ApolloException) {
        Timber.e("Error", e.toString())
      }

      override fun onCompleted() {
        Timber.d("Completed", "Subscription completed")
      }
    }
```

<details><summary>subscriptionの結果ログ</summary><div>

```java

demo.app D/RealSubscriptionManager: Adding 
demo.app.MainActivity$subCallback$1@4410fd4 listener to subObject: com.amazonaws.amplify.generated.graphql.AddedCommentByAuthorSubscription@c3336ec got: com.amazonaws.amplify.generated.graphql.AddedCommentByAuthorSubscription@c3336ec
demo.app D/SubscriptionObject: Adding listener to com.amazonaws.mobileconnectors.appsync.subscription.SubscriptionObject@3127ab5
demo.app D/RetryInterceptor: Retry Interceptor called
demo.app D/AppSyncSigV4SignerInterceptor: Signer Interceptor called
demo.app D/AppSyncSigV4SignerInterceptor: Subscriber ID is 111AAA2222-BB33-CC44-DD55-EEEEEE666666
demo.app I/RetryInterceptor: Returning network response: success
demo.app D/RealSubscriptionManager: subscribe called [/addedCommentByAuthor/]
demo.app D/RealSubscriptionManager: Adding subscription watcher com.amazonaws.mobileconnectors.appsync.subscription.SubscriptionObject@3127ab5 to topic /addedCommentByAuthor/ total topics: 1
demo.app D/RealSubscriptionManager: Attempting to make [1] MQTT clients]
demo.app D/MqttSubscriptionClient: Set transmit false com.amazonaws.mobileconnectors.appsync.subscription.mqtt.MqttSubscriptionClient@957f997
demo.app D/MqttSubscriptionClient: Set transmit false com.amazonaws.mobileconnectors.appsync.subscription.mqtt.MqttSubscriptionClient@957f997
demo.app D/MqttSubscriptionClient: Calling MQTT Connect with actual endpoint
demo.app D/AlarmPingSender: Register alarmreceiver to MqttServiceMqttService.pingSender.6g57fvwpo5ewjo7yw6ftys5nky
demo.app D/AlarmPingSender: Schedule next alarm at 1542680090686
demo.app D/AlarmPingSender: Alarm scheule using setExactAndAllowWhileIdle, next: 30000
demo.app D/RealSubscriptionManager: Connection successful. Will subscribe up to 1 topics
demo.app D/RealSubscriptionManager: Connecting to topic:[/addedCommentByAuthor/]
demo.app D/MqttSubscriptionClient: com.amazonaws.mobileconnectors.appsync.subscription.mqtt.MqttSubscriptionClient@957f997 Attempt to subscribe to topic /addedCommentByAuthor/
demo.app D/RealSubscriptionManager: Made [1] MQTT clients
demo.app D/RealSubscriptionManager: Muting the old clients [ 1] in total
demo.app D/MqttSubscriptionClient: Set transmit false com.amazonaws.mobileconnectors.appsync.subscription.mqtt.MqttSubscriptionClient@20d2ce7
demo.app D/RealSubscriptionManager: Unmuting the new clients [1] in total
demo.app D/MqttSubscriptionClient: Set transmit true com.amazonaws.mobileconnectors.appsync.subscription.mqtt.MqttSubscriptionClient@957f997
demo.app D/RealSubscriptionManager: Closing the old clients [1] in total
demo.app D/RealSubscriptionManager: Closing client: com.amazonaws.mobileconnectors.appsync.subscription.mqtt.MqttSubscriptionClient@20d2ce7
demo.app D/AlarmPingSender: Unregister alarmreceiver to MqttServicekcyggvo75zaonbq33hgkcuttle
demo.app D/MqttSubscriptionClient: Successfully closed the connection.
demo.app D/MqttSubscriptionClient: connection lost isTransmitting: false
demo.app D/AlarmPingSender: Sending Ping at:1542680090691
demo.app D/AlarmPingSender: Schedule next alarm at 1542680120695
demo.app D/AlarmPingSender: Alarm scheule using setExactAndAllowWhileIdle, next: 30000
demo.app D/AlarmPingSender: Success. Release 
demo.app D/AlarmPingSender: Success. Release lock(MqttService.client...
```

</div></details>

Amazon Cognito User Poolsでの認証
--
Cognito認証はユーザーを認証し、AWS のサービスに対するアクセスをユーザーに許可するための認証方式です。
詳しい説明はこちらをみていただくと良いかと思います。
https://docs.aws.amazon.com/cognito/index.html#lang/ja_jp

appのbuild.gradle の dependencies に以下を追加します。

```gradle:app/.build.gradle

  // AWS Cognito
  implementation 'com.amazonaws:aws-android-sdk-cognitoidentityprovider:2.8.2'

  // For the drop-in UI also:
  implementation 'com.amazonaws:aws-android-sdk-auth-userpools:2.8.2'
```

`awsconfiguration.json`をCognito認証方式に書き換えます。
`AppClientSecret`の要素はサーバ側の仕様として不要ということで指定していません。

```json:awsconfiguration.json

{
    "CredentialsProvider": {
        "CognitoIdentity": {
            "Default": {
                "PoolId": "XX-XXXX-X:XXXXXXXX-XXXX-1234-abcd-1234567890ab",
                "Region": "XX-XXXX-X"
            }
        }
    },
    "CognitoUserPool": {
        "Default": {
            "PoolId": "XX-XXXX-X_abcd1234",
            "AppClientId": "XXXXXXXX",
            "Region": "XX-XXXX-X"
        }
    },
    "AppSync": {
        "Default": {
            "ApiUrl": "https://XXXXXX.appsync-api.XX-XXXX-X.amazonaws.com/graphql",
            "Region": "XX-XXXX-X",
            "AuthMode": "AMAZON_COGNITO_USER_POOLS"
        }
    }
}
```

以下のようにCognitoUserPoolを引数にAWS AppSync SDKの初期化処理をします。

```java:MainActivity.kt

  val cognitoUserPool =
          CognitoUserPool(
            context,
            BuildConfig.COGNITO_USER_POOL_ID,
            BuildConfig.COGNITO_CLIENT_ID,
            null,
            Regions.fromName(BuildConfig.COGNITO_REGION)
          )
  val cognitoUserPoolsAuthProvider = BasicCognitoUserPoolsAuthProvider(cognitoUserPool)
  // AWS AppSync SDKの初期化
  val client = AWSAppSyncClient.builder()
          .context(context)
          .awsConfiguration(AWSConfiguration(context))
          .cognitoUserPoolsAuthProvider(cognitoUserPoolsAuthProvider) // Cognito で認証
          .build()
          
  // AWS Mobile SDKのイニシャライズ
  AWSMobileClient.getInstance()
      .initialize(
        applicationContext,
        object : com.amazonaws.mobile.client.Callback<UserStateDetails> {
          override fun onResult(result: UserStateDetails?) {
            if (result != null) {
              Timber.d("initialize %s", result.userState)
              when (result.userState) {
                UserState.GUEST -> Timber.i("userState %s", "user is in guest mode")
                UserState.SIGNED_OUT -> {
                  Timber.i("userState %s", "user is signed out")
                  // サインアップおよびログイン処理を行う
                  signUpOrSignIn()
                }
                UserState.SIGNED_IN -> Timber.i("userState %s", "user is signed in")
                UserState.SIGNED_OUT_USER_POOLS_TOKENS_INVALID -> Timber.i(
                  "userState %s",
                  "need to login again"
                )
                UserState.SIGNED_OUT_FEDERATED_TOKENS_INVALID -> Timber.i(
                  "userState %s",
                  "user logged in via federation, but currently needs new tokens"
                )
                else -> Timber.i("userState %s", "unsupported")
              }
            }
          }

          override fun onError(e: java.lang.Exception?) {
            Timber.e("initialize %s", e!!)
          }
        }
      )
```

```java:MainActivity.kt

  fun signUpOrSignIn() {
    if (!preference.isSignUpCompleted()) {

      cognitoAuthenticator.signUpAsAws()
        .subscribeOn(schedulerProvider.io())
        .observeOn(schedulerProvider.io())
        .subscribeBy(
          onSuccess = {
            Timber.d("login success. $it")
            signIn()
          }
          ,
          onError = { Timber.e(it) })
    } else {

      cognitoAuthenticator.login()
        .subscribeOn(schedulerProvider.io())
        .observeOn(schedulerProvider.io())
        .subscribeBy(
          onSuccess = {
            Timber.d("login success. $it")
          }
          ,
          onError = { Timber.e(it) })
    }
  }

  private fun signUp() {
    cognitoAuthenticator.signUpAsAws()
      .subscribeOn(schedulerProvider.io())
      .observeOn(schedulerProvider.io())
      .subscribeBy(
        onSuccess = {
          //          Timber.d("login success. ${it.first?.username}")
          Timber.d("login success. $it")
          signIn()
        }
        ,
        onError = { Timber.e(it) })
  }

  private fun signIn() {
    cognitoAuthenticator.loginAsAws()
      .subscribeOn(schedulerProvider.io())
      .observeOn(schedulerProvider.io())
      .subscribeBy(
        onSuccess = {
          //          Timber.d("login success. ${it.first?.username}")
          Timber.d("login success. $it")
        }
        ,
        onError = { Timber.e(it) })
  }
```

```kotlin:CognitoAuthenticator.kt

   fun signUp(): Single<Optional<SignUpResult>> {

    val attribute = HashMap<String, String>()
      .also { it["phone_number"] = "+8109000000000" }

    return Single.create { emitter ->
     awsMobileClient.signUp(
        username,
        password,
        attribute,
        null,
        object : com.amazonaws.mobile.client.Callback<SignUpResult> {
          override fun onResult(result: SignUpResult?) {
            // サインアップ成功時の処理　
          }

          override fun onError(e: java.lang.Exception?) {
            // 例外処理
          }

        })
    }
  }

  fun login(): Single<Optional<SignInResult>> {
    return Single.create { emitter ->
      awsMobileClient.signIn(
        username,
        password,
        null,
        object: com.amazonaws.mobile.client.Callback<SignInResult> {
          override fun onResult(result: SignInResult?) {
            // サインアップ成功時の処理
          }

          override fun onError(e: java.lang.Exception?) {
            // 例外処理
          }

        }
      )
    }
  } 
```

`signUp()`での処理を実行したところ、アプリは落ちないものの以下のエラーが発生しました。
`awsMobileClient.signIn()`のなかで`CognitoSecretHash.getSecretHash(userId, clientId, clientSecret)`がコールされるのですが、`SecretKeySpec`インスタンスを生成する際にsecretKeyが空でエラーが返ってしまいます。

```java
demo.app E/MainActivity$signUp: java.lang.IllegalArgumentException: Empty key
        at javax.crypto.spec.SecretKeySpec.<init>(SecretKeySpec.java:96)
        at com.amazonaws.mobileconnectors.cognitoidentityprovider.util.CognitoSecretHash.getSecretHash(CognitoSecretHash.java:57)
        at com.amazonaws.mobileconnectors.cognitoidentityprovider.CognitoUserPool.signUpInternal(CognitoUserPool.java:416)
        at com.amazonaws.mobileconnectors.cognitoidentityprovider.CognitoUserPool.signUp(CognitoUserPool.java:378)
        at com.amazonaws.mobile.client.AWSMobileClient$7.run(AWSMobileClient.java:1097)
        at com.amazonaws.mobile.client.internal.InternalCallback$1.run(InternalCallback.java:101)
        at java.lang.Thread.run(Thread.java:764)
```

SDKのgithubを確認していたところ下記にてバグ報告されており、2018年12月4日時点でこちらのissueはまだOpenのままです。

https://github.com/aws-amplify/aws-sdk-android/issues/587

そこでサーバ側にクライアントIDとシークレットキーを発行しなおしてもらい`awsconfiguration.json`の
`AppClientSecret`の要素を追加することで、エラーが解消されました。

```json:awsconfiguration.json

{
    // Other　configurations

    "CognitoUserPool": {
        "Default": {
            "PoolId": "XX-XXXX-X_abcd1234",
            "AppClientId": "XXXXXXXX",
            "AppClientSecret": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", 
            "Region": "XX-XXXX-X"
        }
    }

    // Other　configurations
}
```

まとめ
--

今回GraphQLのスキーマはサーバより取得しましたが、正式にはクライアントが欲しい情報をスキーマにしサーバ側にプッシュしていきます。

AWS Mobile SDKを用いたCognito認証部分で、現状も修正されていないバグを発見したりと、ドキュメントを見るだけではすんなりとはいきませんでした。
リリースこそしているものの、issueも頻繁に更新されており、watchしていく必要があると感じました。

queryにて`AppSyncResponseFetchers.CACHE_AND_NETWORK`について触れましたが、これから実装していくにあたってネットワーク接続のオプションなど、ここでは触れていないApolloの仕様も知る必要があると考えています。