26
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

iRidgeAdvent Calendar 2018

Day 5

AWS AmplifyでAndroidアプリ構築

Last updated at Posted at 2018-12-04

はじめに

業務でサーバーサイドとのやり取りを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)をインストールします。 
$ cd ./YOUR_PROJECT_FOLDER
$ npm install -g @aws-amplify/cli  

導入手順

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


$ amplify init  

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


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クラウドリソース作成しました。プロジェクトは初期化されクラウドに接続されました!


$ amplify push

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

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

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

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スキーマやレスポンスのデータクラスなどのコード生成を実行します。


$ amplify add codegen --apiId XXXXXXXXXXXXXXXXXXXXX

実行すると、./app/src/main/graphql/../queries.graphql
./app/build/generated 配下にGetPostQueryなどクラスが生成されました。
スクリーンショット 2018-12-04 1.04.52.png

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

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

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


apply plugin: 'com.amazonaws.appsync'

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

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 に以下パーミッションとサービスを追加します。

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

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

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を推奨しています。
ネットワークを介してデータを取得する前に、まずローカルキャッシュから結果を取得するためとのことです。
オフラインサポートが可能とのことですが、ここは別途検証が必要そうです。

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

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


  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")
      }
    }
subscriptionの結果ログ

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...

Amazon Cognito User Poolsでの認証

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

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

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の要素はサーバ側の仕様として不要ということで指定していません。

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の初期化処理をします。

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!!)
          }
        }
      )
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) })
  }
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が空でエラーが返ってしまいます。

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のままです。

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

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の仕様も知る必要があると考えています。

26
15
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
26
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?