mune diary

Hello, World!

ApolloClient(GraphQL) ApolloInterceptor での token の更新処理

概要

GraphQL で ApolloClient を用いている場合の token の更新処理は ApolloInterceptor を利用して実行させることができます。

kotlin: 1.6.0
apollo-kotlin(apollo3): 3.2.2

Interceptor(ApolloInterceptor) とは

「送出されるリクエストとそれに対応するレスポンスが戻ってくるのを観察し、変更し、そして短絡させることができる」(OkHttp のドキュメントより)OkHttp または Apollo クライアントにひとつまたは複数のインターセプターを追加すると、リクエストとレスポンスはそれらを経由するようになります。つまり Interceptor でレスポンスを購読して AUTHENTICATION_ERROR などの Error code が含まれていれば token の更新処理をその都度実行することができます。ApolloInterceptor も役割として同じです。

ApolloInterceptor の実装例

以下のような GraphQL のドキュメント通りのエラーレスポンスが返ることを前提に紹介します。

{
  "errors": [
    {
      "message": "Name for character with ID 1002 could not be fetched.",
      "locations": [{ "line": 6, "column": 7 }],
      "path": ["hero", "heroFriends", 1, "name"],
      "extensions": {
        "code": "AUTHENTICATION_ERROR",
        "timestamp": "Fri Feb 9 14:33:09 UTC 2018"
      }
    }
  ]
}

次のコードは ApolloInterceptor の実装例です。リクエストにAuthorization Headerで token を付与する必要がある API を想定しています。(token 更新は REST API )

class ApolloRefreshTokenInterceptor(
  private val authorizationRepository: AuthorizationRepository
) : ApolloInterceptor {

  private val mutex = Mutex()

  private var retryCount = 0

  @OptIn(FlowPreview::class)
  override fun <D : Operation.Data> intercept(
    request: ApolloRequest<D>,
    chain: ApolloInterceptorChain
  ): Flow<ApolloResponse<D>> {
    return chain.proceed(request).flatMapConcat { response ->
      when {
        retryCount >= MAX_RETRY_COUNT -> flowOf(response)
        response.hasAuthenticationError() -> {
          val token = mutex.withLock {
            // 更新処理
            authorizationRepository.refreshToken()
          }
          retryCount += 1

          chain.proceed(
          request.newBuilder()
            .addHttpHeader("Authorization", "Bearer $token")
            .build()
          )
        }
        else -> flowOf(response)
      }
    }
  }

  private fun <D : Operation.Data> ApolloResponse<D>.hasAuthenticationError(): Boolean =
    errors?.any { error ->
      error.extensions.orEmpty().containsValue(AUTHENTICATION_ERROR)
    } ?: false

  companion object {
    private const val MAX_RETRY_COUNT = 3
    private const val AUTHENTICATION_ERROR = "AUTHENTICATION_ERROR"
  }
}

errors の extensions の code が認証エラーであるかどうかを確認します。

private fun <D : Operation.Data> ApolloResponse<D>.hasAuthenticationError(): Boolean =
  errors?.any { error ->
    error.extensions.orEmpty().containsValue(AUTHENTICATION_ERROR)
  } ?: false

authorizationRepository.refreshToken() は suspend 関数という体なので suspend 関数である Mutex.withLock を使用して排他制御で実行します。

val token = mutex.withLock {
  // 更新処理
  authorizationRepository.refreshToken()
}

使い方

Apollo Clientを作成するときに addInterceptor に実装した ApolloInterceptor をセットすることで使用できます。

@Provides
@Singleton
fun provideApolloClient(
  okHttpClient: OkHttpClient,
  serverUrl: String,
  apolloRefreshTokenInterceptor: ApolloInterceptor
): ApolloClient {
return ApolloClient.Builder().apply { 
    okHttpClient(okHttpClient)
    serverUrl(serverUrl)
    addInterceptor(apolloRefreshTokenInterceptor)
  }.build()
}

参考

ConstraintHelper で ConstraintLayout な RadioGroup を実装する

ConstraintHelper とは

指定した複数の View に対して同様な処理をさせることができます。ConstraintHelper を拡張したクラスとして Barrier, Flow, Group などがすでにあります。
今回は既存の拡張クラスにならって独自の拡張クラスを作成しようと思います。

素の RadioGroup 内では ConstraintLayout を使用できない

RadioGroup は LinearLayout を継承しているため、ConstraintLayout では RadioButton のスコープを設定することができません。なので、ConstraintLayout を継承した独自 RadioGroup クラスを作成する方法がありますが、正直面倒くさいです。
Y.A.M の 雑記帳: Android TableLayout, RelativeLayout で RadioButton を使う

今回は複数の RadioButton を同一のスコープに設定する、つまり OnCheckedChangeListener を共通化したいだけですので、上記の方法だとちょっと重い気がします。

そこで ConstraintHelper の出番です。リスナーを共通化する処理を拡張クラスとして書けば ConstraintLayout で RadioButton を同一のスコープ(リスナー)で扱うことができます。

コード

import android.content.Context
import android.util.AttributeSet
import android.widget.CompoundButton
import androidx.constraintlayout.widget.ConstraintHelper
import androidx.constraintlayout.widget.ConstraintLayout

class ConstraintRadioGroup @JvmOverloads constructor(
    context: Context,
    attributeSet: AttributeSet? = null,
    defStyleAttr: Int = 0
) : ConstraintHelper(context, attributeSet, defStyleAttr) {

    private var radioButtonGroup: List<CompoundButton> = emptyList()

    override fun updatePreLayout(container: ConstraintLayout?) {
        super.updatePreLayout(container)
        container ?: return

        radioButtonGroup = mIds.take(mCount)
            .mapNotNull { container.findViewById(it) as? CompoundButton }
            .onEach { it.setOnCheckedChangeListener(listener) }
    }

    private val listener: CompoundButton.OnCheckedChangeListener =
        CompoundButton.OnCheckedChangeListener { button, isChecked ->
            clear()
            button.isChecked = isChecked
        }

    private fun clear() {
        radioButtonGroup.map { it.isChecked = false }
    }
}

使いたいところで app:constraint_referenced_ids に RadioButton の id を渡してあげれば同一のスコープ(リスナー)として扱うことができます。

<xxx.xxx.xxx.ConstraintRadioGroup
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  app:constraint_referenced_ids="radio1, radio2, radio3, radio4" />

注意点

ConstraintHelper 自体は ConstraintLayout 1.1.0 で追加された機能ですが、上記の拡張クラスは 1.1.2 でなぜか動作しませんでした🤔
2.0.0〜で使うことをお勧めします。

Android Autofill Framework を使ってみる

Autofill Framework is 何?

Autofill Framework とは、Google アカウントに登録されているパスワードやクレジットカードなどのデータをフォームに自動で入力してくれる便利なやつです。簡単に導入することができるのでサクッと紹介していきます。 developer.android.com

完成イメージ

今回はクレジットカードの登録画面を作り、自動入力に対応させていきます。(あんま作ることなさそうだけど…)
f:id:mune0903:20201230195021p:plain:w200
自前のクレカを登録しているので、Gif 等を貼り付けできずスクショになりますがこのように登録されているクレカが表示されます。完成コードは公開しているのでそちらで動作をみてください。
github.com

コード(MainActivity.kt)

.xml には入力フォームがあればOKです👌

class MainActivity : AppCompatActivity() {
    private val binding: ActivityMainBinding by lazy {
        DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main)
    }

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

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

    @RequiresApi(Build.VERSION_CODES.O)
    private fun setupAutofill() {
        binding.apply {
            activityMainCreditcardNumberEditText.setAutofillHints(View.AUTOFILL_HINT_CREDIT_CARD_NUMBER)
            activityMainCreditcardMonthExpire.setAutofillHints(View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH)
            activityMainCreditcardYearExpire.setAutofillHints(View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR)
            activityMainCreditcardSecurityCodeEditText.setAutofillHints(View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE)
        }

        val month = arrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)
        val months = (0..11)
            .map { month[it].toString() }
            .toTypedArray<CharSequence>()

        val year = Calendar.getInstance().get(Calendar.YEAR)
        val years = (0..10)
            .map { (year + it).toString() }
            .toTypedArray<CharSequence>()

        binding.activityMainCreditcardYearExpire.adapter = object : ArrayAdapter<CharSequence?>(this,
            android.R.layout.simple_spinner_item, years) {
            override fun getAutofillOptions() = years
        }

        binding.activityMainCreditcardMonthExpire.adapter = object : ArrayAdapter<CharSequence?>(this,
            android.R.layout.simple_spinner_item, months) {
            override fun getAutofillOptions() = months
        }
    }
}

フォームを自動入力に対応させる

コンポーネントsetAutofillHints()で自動入力に対応させますよと明示してあげるだけです!
クレカ以外にもパスワード・メアド・名前などの情報も自動入力させることができるみたいです。

Spinner への自動入力

クレカの有効期限入力は Spinner であることはよく見かけますが、activityMainCreditcardMonthExpire.setAutofillHints(View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH)させるだけでは自動入力されません。Adapter を使う Spinner だと、入力値ではなくその入力値に対応する index を渡してあげないといけないからです。この問題は Adapter インターフェースの getAutofillOptions() メソッドに Autofill が取得してきた値と一致する index を渡してあげるよう override させて実装してあげれば解決できます。

val month = arrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)
        val months = (0..11)
            .map { month[it].toString() }
            .toTypedArray<CharSequence>()

binding.activityMainCreditcardMonthExpire.adapter = object : ArrayAdapter<CharSequence?>(this,
            android.R.layout.simple_spinner_item, months) {
            override fun getAutofillOptions() = months
        }

Android 8.0(API レベル 26)未満だとクラッシュする😇

Build.VERSION.SDK_INTが 26 以上かどうかみてあげましょう。

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

RuboCop: Use the return of the conditional for variable assignment and comparison. と怒られた 【備忘録】

はじめに

4月に新卒入社をし様々な研修を経て、事業部に配属がされて1ヶ月が経ちました。普段業務では、Rails周りを扱っており今回は、RuboCopのWarningエラーから学んだことを備忘録として残します。

RuboCopとは、コーディン規約に準拠しているかどうかを解析してくれるgemです。

エラーの意味

仮にこんなコードを書いたとします。

if DateTime.now.hour < 12
  t = '午前'
else
  t = '午後'
end

RuboCopからRuboCop: Use the return of the conditional for variable assignment and comparison.といったWarningエラーがでると思います。これは、変数の代入には戻り値を使いましょうねってことでした。RubyもKotlin同様にif文は式であり、戻り値を返せるようです。

修正後

t = if DateTime.now.hour < 12
  '午前'
else
  '午後'
end

簡単な例であったため、あまりハッキリとした効果が見られませんが、複雑な条件分岐を扱うときなどにはDRYに書けて威力を発揮しそうです。

最後に

新卒エンジニアとしてまだまだインプットすることは多いですが、それを自分なりに要約してアウトプットすることは意識しないと習慣化できないので、今回のような小さな備忘録も習慣づけのために続けていこうと思います。