【Android】Twitter SDKを使ってOAuth認証~ブックマークを取得するまでの実装

Twitter APIのデフォルトがv2へ変更されてから1年ぐらいが経過しました。v2ではブックマークを取得したりスペースを取得したりなど様々なことができるので、そろそろAndroidでAPI v2を使ったアプリを作ってみたいなと思い調べてみたら、公式でJava版のSDKが公開されていました。そこで、今回は私のアカウントを用いて認証を行い、ブックマークを取得してみることにしてみました。この記事を読んで「これからAPI v2を使って何か作ってみたい！」と思う方の参考になれば嬉しいです。

## この記事で分かること/できること

- [Twitter公式SDK](https://github.com/twitterdev/twitter-api-java-sdk)(Twitter API v2)の導入方法
- OAuth認証~認証されたユーザーのブックマーク取得までの実装
- トークンの保管方法、無効化（ログアウト処理）
- トークンの自動更新

この内容を踏まえて下の動画にある簡易的なアプリを作成します。

![qiita-02](https://user-images.githubusercontent.com/73424654/201097338-c5d9432e-37b0-4ebd-9f8f-a18a07d6ff73.gif)

## この記事で話さないこと/前提知識

- Android、Kotlinの基本的な仕様
- OAuth2.0認証の詳細な内容
- Twitter API v2を使うまでのデベロッパーアカウント登録方法
- Twitter API v2の詳細な仕様

## 環境

Android Studio Flamingo | 2022.2.1 Canary 4
material3 : `1.0.0-beta03`  
Kotlin : `1.7.0`  
JetpackCompose : `1.2.1 `

## 認証からAPIコールまでの流れについて

今回実装する内容は以下の4点です。

1. サインイン
2. APIコール
3. ログアウト
4. トークンの更新

上記の3点を実装するためには以下の図の通り6つ内容を実装する必要があります。



![cb6c618f18c1febd904b2021079238af8c3d5d4d58f1386001a7a3a659c54bc0](https://user-images.githubusercontent.com/73424654/201092509-b7359cdc-e924-41e1-9d89-f6351efd7ad7.png)

図の作成にあたって[こちらのサイト](https://paonejp.github.io/2017/11/04/making_kotlin_appauth_android_demo_application.html)より参考にさせていただきました。
それぞれの内容は以下の処理を実行します。

1. 認証・認可コードの取得
    - ブラウザを使って認証ページにアクセスし、認可コードを取得します
2. アクセストークンの取得
    - 1.で取得したコードを基にアクセストークンとリフレッシュトークンを取得します
3. アクセストークンの保管
    - 一度アクセストークンを取得したら、アプリ再起動に1,2.の認証をする必要がないようトークンを永続化します
4. APIコール
    - 2.で取得したアクセストークンを使って認証が必要なAPIコールをする
5. トークンの更新
    - 1.で取得したリフレッシュトークンを用いてトークンを更新します
    - トークンの有効期限が切れたら自動で更新するようにします  
6. トークンの無効化（ログアウト）
    - 2.で取得したトークンをアプリ内で破棄
    - サーバー側でも無効化します

それでは各処理内容についてコードを解説していきます。

## コード

### 依存関係の追加
今回、依存関係で追加するライブラリは以下で公開されているTwitterSDKです。
https://github.com/twitterdev/twitter-api-java-sdk

まず、SDKの依存関係を追加するにはapp/gradle配下にて以下の実装が必要です。
```groovy:app/build.gradle
dependencies {

    …

	// Twitter-API-Java-SDK
	implementation ("com.twitter:twitter-api-java-sdk:2.0.3"){
        exclude group:'org.apache.oltu.oauth2' , module: 'org.apache.oltu.oauth2.common'
	    exclude module: 'listenablefuture'
	    exclude module: 'guava'
	}
}
```

ここでの注意点としては重複しているモジュールをexcludeしている点です。
そのまま追加してしまうと次の様なエラー(`DupplicateError`)が発生するため上記の内容を実装しています。

<details><summary>ビルドエラー詳細</summary>
Duplicate class com.google.common.util.concurrent.ListenableFuture found in modules guava-15.0 (com.google.guava:guava:15.0) and listenablefuture-1.0 (com.google.guava:listenablefuture:1.0)
Duplicate class org.apache.oltu.oauth2.common.OAuth found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.OAuth$ContentType found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.OAuth$HeaderType found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.OAuth$HttpMethod found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.OAuth$WWWAuthHeader found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.OAuthProviderType found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.domain.client.BasicClientInfo found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.domain.client.BasicClientInfoBuilder found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.domain.client.ClientInfo found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.domain.credentials.BasicCredentials found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.domain.credentials.BasicCredentialsBuilder found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.domain.credentials.Credentials found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.error.OAuthError found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.error.OAuthError$CodeResponse found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.error.OAuthError$ResourceResponse found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.error.OAuthError$TokenResponse found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.exception.OAuthProblemException found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.exception.OAuthRuntimeException found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.exception.OAuthSystemException found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.message.OAuthMessage found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.message.OAuthResponse found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.message.OAuthResponse$OAuthErrorResponseBuilder found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.message.OAuthResponse$OAuthResponseBuilder found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.message.types.GrantType found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.message.types.ParameterStyle found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.message.types.ResponseType found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.message.types.TokenType found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.parameters.BodyURLEncodedParametersApplier found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.parameters.FragmentParametersApplier found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.parameters.JSONBodyParametersApplier found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.parameters.OAuthParametersApplier found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.parameters.QueryParameterApplier found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.parameters.WWWAuthHeaderParametersApplier found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.token.BasicOAuthToken found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.token.OAuthToken found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.utils.JSONUtils found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.utils.OAuthUtils found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.validators.AbstractValidator found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)
Duplicate class org.apache.oltu.oauth2.common.validators.OAuthValidator found in modules org.apache.oltu.oauth2.client-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1) and org.apache.oltu.oauth2.common-1.0.1 (org.apache.oltu.oauth2:org.apache.oltu.oauth2.common:1.0.1)

</details>


### 1. 認証・認可コードの取得

#### 認証処理の初期化
`onCreate()`内で各オブジェクトを初期化をします。
また、今回のコードではOAuthオブジェクト初期化の各パラメーターについては[example](https://github.com/twitterdev/twitter-api-java-sdk/blob/main/examples/src/main/java/com/twitter/clientlib/auth/OAuth20GetAccessToken.java)を参考にして設定しました。
```kotlin
class MainActivity : ComponentActivity() {

    companion object {
        const val CALLBACK_URL = "app://"
        const val SCOPE = "offline.access tweet.read users.read bookmark.read"
        const val SECRET_STATE = "state"
        const val TWITTER_OAUTH2_CLIENT_ID = "Developer Portalで取得したCLIENT ID"
        const val TWITTER_OAUTH2_CLIENT_SECRET = "Developer Portalで取得したCLIENT SECRET ID"
        // 1時間半でトークンを更新(トークンの有効期限は2時間だが余裕を見るため)
        const val EXPIRY_TIME = 5400000
    }

    private lateinit var prefs: SharedPreferences

    private lateinit var credentials: TwitterCredentialsOAuth2
    private lateinit var service: TwitterOAuth20Service
    private lateinit var pkce: PKCE

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val prefs = getSharedPreferences("TwitterOauth", MODE_PRIVATE)

        credentials = TwitterCredentialsOAuth2(
            TWITTER_OAUTH2_CLIENT_ID,
            TWITTER_OAUTH2_CLIENT_SECRET,
            prefs.getString("OauthToken", ""),
            prefs.getString("OauthRefreshToken", "")
        )

        service = TwitterOAuth20Service(
            credentials.twitterOauth2ClientId,
            credentials.twitterOAuth2ClientSecret,
            CALLBACK_URL,
            SCOPE
        )

        pkce = PKCE().apply {
            codeChallenge = "challenge"
            codeChallengeMethod = PKCECodeChallengeMethod.PLAIN
            codeVerifier = "challenge"
        }

        …
    }
}
```




#### 認証用のURLを開く

続いて認証用のページを開く処理を実装します。

`onCreate()`で初期化した`pkce`と`state`を引数とした`getAuthorizationUrl()`
を使い認証用ページのURLを作成し、Intentを使ってデフォルトブラウザで認証ページへ遷移するという流れです。
今回はデフォルトブラウザーを使って認証ページへ遷移するため以下の内容を実装しました。

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

    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />


    <!--  Andoroid11以降は以下が必要  -->
    <queries>
        <intent>
            <action android:name="android.intent.action.VIEW"/>

            <data
                android:scheme="https"
                android:host="twitter.com"
                />
        </intent>
    </queries>

    <queries>
        <intent>
            <action android:name="android.intent.action.VIEW"/>
            <category android:name="android.intent.category.BROWSABLE"/>
            <data android:scheme="http"/>
        </intent>
    </queries>

    <application
        …
        >
        <activity
            …
            >

            <!--     コールバック用のIntent Filter      -->
            <intent-filter>
                <action android:name="android.intent.action.VIEW"/>
                <category android:name="android.intent.category.DEFAULT"/>
                <category android:name="android.intent.category.BROWSABLE"/>
                <data android:scheme="app" />
            </intent-filter>

        </activity>
    </application>

</manifest>
```


```kotlin
private fun openOAuthURL() {

    val authUrl = service.getAuthorizationUrl(pkce, SECRET_STATE)

    val intent = Intent(
        Intent.ACTION_VIEW,
        Uri.parse(authUrl)
    )
    val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://"))
    val twitterIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://twitter.com/"))

    val existsDefBrows =
        packageManager.resolveActivity(browserIntent, PackageManager.MATCH_DEFAULT_ONLY)

    // 端末にツイッターアプリが入っているか確認
    val existsTwitterApp =
        packageManager.resolveActivity(twitterIntent, PackageManager.MATCH_DEFAULT_ONLY)

    runCatching {
        if (existsTwitterApp != null) {
            // ブラウザアプリがインストールされていたら
            if (existsDefBrows != null) {

                intent.setPackage(existsDefBrows.activityInfo.packageName)
                intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK

                startActivity(intent)
            }
        } else {
            startActivity(intent)
        }
    }.getOrElse {
        Log.i("openOAuthURL_Error", "${it.localizedMessage}")
    }

}
```

まず、端末内のデフォルトブラウザーとTwitterアプリ（AppLinkでTwitterのURLに紐づいたアプリ）が無いか確認しています。ここでもし、Twitterアプリがインストールされていた場合には認証用ページを開く際にTwitterアプリが開いてしまいOAuth認証ができない状態になります。
これを避けるために、端末内の各アプリの有無を確認して、Twitterアプリがインストールされていれば、デフォルトブラウザを開き、なければ`startActivity()`を使ってURLを開くように実装しています。

:::note warn
Android 11以降の実装について

ここで注意していただきたいのは、端末内にインストールされたアプリを確認するために`resolveActivity()`を使っている点です。これはAndroid 11よりも前の端末では特に問題なく実装できますが、11以降の端末を対象としてアプリを配布する場合`AndrodManifest.xml`に次の記述が必要です。
```xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    …
    <queries>
        <intent>
            <action android:name="android.intent.action.VIEW"/>

            <data
                android:scheme="https"
                android:host="twitter.com"
                />
        </intent>
    </queries>

    <queries>
        <intent>
            <action android:name="android.intent.action.VIEW"/>
            <category android:name="android.intent.category.BROWSABLE"/>
            <data android:scheme="http"/>
        </intent>
    </queries>
    …

    <application>
        …
    </application>

</manifest>
```
詳しくはドキュメントの[パッケージの公開設定](https://developer.android.com/training/basics/intents/package-visibility?hl=ja)を確認下さい。
:::


記事全体を書いた後に思いましたが、認証用ページの表示はブラウザにこだわらず、WebView用のActivityを作成してその結果をonActivityResultを使って取得するのもいいのかなと思いました。

ここは皆さんのお好きなように適宜変えていただければと思います。

### 2.アクセストークンの取得

認可コードを`onNewIntent()`にて取得します。
```kotlin
override fun onNewIntent(intent: Intent?) {
        super.onNewIntent(intent)

        Log.i("onNewIntent", "onNewIntent")

        val uri = intent?.data
        val code = uri?.getQueryParameter("code")

        // 認可コードが正常に取得できていたらトークンを取得
        lifecycleScope.launch(Dispatchers.IO) {
            if(code != null) {
                getAccessToken(code)
            }
        }

}
```
この認可コードを`getAccessToken()`へ渡してあげる事によってアクセストークンとリフレッシュトークンを取得することができます。

```kotlin
 private fun getAccessToken(code: String?) {

    val accessToken = service.getAccessToken(pkce, code)

    storeToken(accessToken)

}
```

### 3.アクセストークンの保管

2.で取得したアクセストークンとリフレッシュトークンを`TwitterCredentialsOAuth2()`へ格納します。また、次回起動時に再認証が不要となるようにSharedPreferenceを使って内部ストレージへ保管します。

```kotlin
private fun storeToken(accessToken: OAuth2AccessToken) {

    val prefs = getSharedPreferences("TwitterOauth", MODE_PRIVATE)
    prefs.edit().apply {
        putString("OauthToken", accessToken.accessToken)
        putString("OauthRefreshToken", accessToken.refreshToken)
        putLong("getTokenTime", getTokenTime)
    }.apply()

    credentials.apply {
        twitterOauth2AccessToken = accessToken.accessToken
        twitterOauth2RefreshToken = accessToken.refreshToken
    }
}
```

### 4.APIコール（ブックマーク取得）
`getUsersIdBookmarks()`を使ってブックマークを取得します。
`getUsersIdBookmarks()`の引数には1,2で認証したユーザーのUserIdを設定します。
UserIdは[Postman](https://developer.twitter.com/en/docs/tutorials/postman-getting-started)などを使って確認することができます。

```kotlin
private fun getBookmark() {
    Log.i("getBookMark", credentials.twitterOauth2AccessToken)
    Log.i("getBookMark", credentials.twitterOauth2RefreshToken)

    val apiInstance = TwitterApi(credentials)

    runCatching {
        apiInstance.bookmarks().getUsersIdBookmarks("認証ユーザーのUserIdを入力").execute()
    }.onSuccess {
        Log.i("BookMark_Success", "$it")
    }.onFailure {
        Log.i("BookMark_Failure_local", it.localizedMessage)
        Log.i("BookMark_Failure_message", "${it.message}")
        Log.i("BookMark_Failure_cause", "${it.cause}")
    }
}
```
今回は私のアカウントのブックマークを取得しLogに表示する内容を実装してみました。
出力結果が以下の通りです。
![3bded91bfd781b2d64f7b1732a62e49b6e06e7b2c02c294a59a0b9afbb2faa1d](https://user-images.githubusercontent.com/73424654/201093152-4e2d5cf7-fe02-4c7b-999d-991e5a51b0cd.png)


無事に取得できているようですね🎉

### 5.トークンの更新


配布先のREADMEには`TwitterCredentialsOAuth2.isOAuth2AutoRefreshToken`を`true`にすることでトークンが自動で更新されるようですが、私が試した限りでは上手く動作しませんでした・・・(実装方法が間違っているかもしれないです)

そこで、今回は下記のコードを実装しトークンが自動で更新されるようにしました。

```kotlin
class MainActivity : ComponentActivity() {
    companion object {
        
        …

        // 1時間半でトークンを更新(トークンの有効期限は2時間だが余裕を見るため)
        const val EXPIRY_TIME = 5400000
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        …

    }

    …

    override fun onResume() {
        super.onResume()

        val getTokenTime = prefs.getLong("getTokenTime", 0)
        val now = System.currentTimeMillis()
        val elapsedTime = now - getTokenTime

        if (elapsedTime > EXPIRY_TIME) {
            lifecycleScope.launch(Dispatchers.IO) {
                refreshToken()
            }
        }
    }

    …

    private suspend fun refreshToken() {

        val apiInstance = TwitterApi(credentials)

        apiInstance.addCallback {
            credentials.apply {
                twitterOauth2AccessToken = it.accessToken
                twitterOauth2RefreshToken = it.refreshToken
            }

            val refreshTokenTime = System.currentTimeMillis()

            prefs.edit().apply {
                putString("OauthToken", it.accessToken)
                putString("OauthRefreshToken", it.refreshToken)
                putLong("getTokenTime", refreshTokenTime)
            }.apply()

        }

        runCatching {
            apiInstance.refreshToken()
            withContext(Dispatchers.Main) {
                val toast = Toast.makeText(applicationContext, "トークン更新成功！", Toast.LENGTH_SHORT)
                toast.show()
            }
        }.getOrElse {
            withContext(Dispatchers.Main) {
                val toast = Toast.makeText(applicationContext, "トークン更新失敗", Toast.LENGTH_SHORT)
                toast.show()
            }
            Log.i("refreshToken_Fail", "$it")
        }
    }
    
}
```

まずは`EXPIRY_TIME`にトークンの有効期限となる時間を設定します。

ドキュメントによるとTwitterの場合アクセストークンの有効期限は2時間となっておりますが、
今回は余裕をみて1時間半でトークンを更新するよう設定しています。

>How long will my credentials stay valid?  
>By default, the access token you create through the Authorization Code Flow >with PKCE will only stay valid **for two hours** unless you’ve used the offline.access scope.  
https://developer.twitter.com/en/docs/authentication/oauth-2-0/authorization-code  


```kotlin
class MainActivity : ComponentActivity() {
    companion object {
        
        …

        // 1時間半でトークンを更新(トークンの有効期限は2時間だが余裕を見るため)
        const val EXPIRY_TIME = 5400000
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        …

    }
}
```

次にリフレッシュトークンを使ってトークンを更新する実装です。
こちらのコードは[example](https://github.com/twitterdev/twitter-api-java-sdk/blob/main/examples/src/main/java/com/twitter/clientlib/auth/OAuth20RefreshToken.java)のコードを参考にしました。また、トークンを更新した時間を記録しておくためにSharedPreferencesへ格納しています。
```kotlin
    private suspend fun refreshToken() {

        val apiInstance = TwitterApi(credentials)

        apiInstance.addCallback {
            credentials.apply {
                twitterOauth2AccessToken = it.accessToken
                twitterOauth2RefreshToken = it.refreshToken
            }

            val refreshTokenTime = System.currentTimeMillis()

            prefs.edit().apply {
                putString("OauthToken", it.accessToken)
                putString("OauthRefreshToken", it.refreshToken)
                putLong("getTokenTime", refreshTokenTime)
            }.apply()

        }

        runCatching {
            apiInstance.refreshToken()
            withContext(Dispatchers.Main) {
                val toast = Toast.makeText(applicationContext, "トークン更新成功！", Toast.LENGTH_SHORT)
                toast.show()
            }
        }.getOrElse {
            withContext(Dispatchers.Main) {
                val toast = Toast.makeText(applicationContext, "トークン更新失敗", Toast.LENGTH_SHORT)
                toast.show()
            }
            Log.i("refreshToken_Fail", "$it")
        }
    }
```
最後に有効期限が切れたらトークンを更新するように、`onResume()`にて更新処理を実装しています。

```kotlin
    override fun onResume() {
        super.onResume()

        val getTokenTime = prefs.getLong("getTokenTime", 0)
        val now = System.currentTimeMillis()
        val elapsedTime = now - getTokenTime

        if (elapsedTime > EXPIRY_TIME) {
            lifecycleScope.launch(Dispatchers.IO) {
                refreshToken()
            }
        }
    }

```

### 6.トークンの無効化（ログアウト）

`revokeToken`使ってサーバー側でトークンを無効化します。
また、上記の実装ではアプリ内のトークンが残っているためSharedPreferenceと`TwitterCredentialsOAuth2`オブジェクトを空にする処理も実装しています。

```kotlin
private fun revokeToken() {

        service.revokeToken(credentials.twitterOauth2AccessToken, TokenTypeHint.ACCESS_TOKEN)

        val prefs = getSharedPreferences("TwitterOauth", MODE_PRIVATE)
        prefs.edit().apply {
            putString("OauthToken", "")
            putString("OauthRefreshToken", "")
        }.apply()

        credentials.apply {
            twitterOauth2AccessToken = ""
            twitterOauth2RefreshToken = ""
        }

    }
```

## まとめ

- 公式で公開しているライブラリを使ってOAuth認証~ブックマークの取得まで実装
- 依存関係の追加に一部注意が必要
- デフォルトブラウザを使って認証しアクセストークンの取得
- トークンの自動更新の実装

全体的なソースコードは以下で公開しておりますので、これからTwitterAPIを使ったアプリを開発しようと考えてる方のお役に立てたら嬉しいです。(Android,Kotlinヨワヨワ初心者が書いたコードなので、読み辛い分かりづらい点はご指摘いただければと・・・😂)
[CIDRA4023/TwitterAppTes](https://github.com/CIDRA4023/TwitterAppTest)



## 余談

OAuth認証の実装をする方法は様々ですが、[AppAuth for Android](https://github.com/openid/AppAuth-Android)というライブラリも選択肢にありました。
しかし、このライブラリを使った場合ログアウト処理を実装するにはひと手間必要なところや、API通信で別途retrofitなどを導入しなければいけない場面があります。そこで、今回は認証処理から各API処理が網羅されている公式のSDKを使うのが個人的なベストプラクティスかなと思い選定しました。

あと、認証ページへの遷移はデフォルトブラウザじゃなくてWebView用のActivity作ってその結果を受け取るのもいいかも…？

おそらく、他にも良い実装方法があるかと思いますので、今回は**公式のSDKを使った方法**ということで参考にしていただければ幸いです。

## 参考
### Android ドキュメント
- [パッケージの公開設定を管理する](https://developer.android.com/training/basics/intents/package-visibility?hl=ja)
### Twitter API ドキュメント
- [OAuth 2.0 Authorization Code Flow with PKCE | Docs | Twitter Developer Platform](https://developer.twitter.com/en/docs/authentication/oauth-2-0/authorization-code)
- [GET /2/users/:id/bookmarks](https://developer.twitter.com/en/docs/twitter-api/tweets/bookmarks/api-reference/get-users-id-bookmarks)
### OAuth認証について参考にした記事
- [Kotlin と AppAuth for Android でネイティブアプリの実装サンプルを作ってみた](https://paonejp.github.io/2017/11/04/making_kotlin_appauth_android_demo_application.html)