Android / Programming

JetpackでGoogleログインからAPIコール 【コピペOK!】

Jetpack Compose を使って Google Drive API v3 のメソッドを実行する方法を解説します
例では Google Drive の API を使用しますが、Spreadsheet API なども API コール以外は同様の手順が必要になります

やりたいこと

Jetpack Compose で Google ログイン(サインイン)画面を表示したい!
そして Google Drive API v3 を呼びたい!

準備

Google Cloud Platform 設定

コーディングの前に Google Cloud Platform の設定が必要になります
前回の記事で紹介しているためこちらを参考にしてください

重要なのは、認証情報は Android 用とウェブ用の両方を設定し、ウェブ用のクライアント ID を使用する点です

Dependency 設定

下記の Dependency を Build.gradle(.kts) に設定してください
また下記のバージョンでしか試してないため、ご了承ください

dependencies {
    implementation("com.google.api-client:google-api-client-android:2.2.0") {
        exclude("org.apache.httpcomponents")
    }
    implementation("com.google.apis:google-api-services-drive:v3-rev136-1.25.0") {
        exclude("org.apache.httpcomponents")
    }
    implementation("com.google.http-client:google-http-client-gson:1.42.3")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.8.0-RC2")
    implementation("com.google.android.gms:play-services-auth:20.7.0")
}

エラーの解消

同期後にビルドをすると「2 files found with path ‘META-INF/DEPENDENCIES’」エラーが表示されると思います
これを解消するために、app モジュールにある build.gradle(.kts) の resources に「excludes += “/META-INF/DEPENDENCIES”」を追加します

// build.gradle(.kts)(:app)
android {
    ...
    
    packaging {
        resources {
            excludes += "/META-INF/{AL2.0,LGPL2.1}" // 既存
            excludes += "/META-INF/DEPENDENCIES" // 追加
        }
    }
}

実装

ここからは実装方法を解説しています
コードは問題なく動きますが、全体的に実装途中のため、DataSource に ViewModel から直接アクセスしてたりと色々おかしな部分がありますがご容赦ください
各ファイルの全体像は、それぞれのファイル名タイトルの下にあります

MVVM に部分的に沿った構成になっているため、MainActivity、GoogleSignIn、MainActivityViewModel の3つのファイルに分かれています
アーキテクチャに関しては Android 公式サイトを参考にしてください

MainActivity.kt

package your.package.name // ご自身のものに変更してください

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential
import dagger.hilt.android.AndroidEntryPoint

@AndroidEntryPoint
class MainActivity: ComponentActivity() {
    private val viewModel: MainActivityViewModel by viewModels()

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

        setContent {
            YourAppTheme { // ご自身のアプリのものに変更してください
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    Column {
                        Test(
                            viewModel.credential.value,
                            viewModel::deleteAllTrash
                        )

                        ShowDialog(viewModel.intent.value)

                        GoogleSignIn(
                            serverClientId = "your_client_ID_for_web",// ご自身のウェブ用クライアント ID に変更してください
                            onSuccessListener = viewModel::saveLoginSuccess,
                            onFailureListener = viewModel::setLoginFailure,
                            content = {
                                Text(text = "Google sign in")
                            }
                        )
                    }
                }
            }
        }
    }
}

@Composable
fun Test(googleSignInState: GoogleSignInState, op: (GoogleAccountCredential) -> Unit) {
    val displayText = when (googleSignInState) {
        is GoogleSignInState.ActivityNotFoundException -> "activity exception"
        is GoogleSignInState.ApiException -> "api exception"
        is GoogleSignInState.TaskFailed -> "task failed"
        is GoogleSignInState.SendIntentException -> "intent exception"
        is GoogleSignInState.Success -> "succeeded"
        is GoogleSignInState.Loading -> "loading"
    }

    Text(
        text = displayText
    )
    
    if (googleSignInState is GoogleSignInState.Success) {
        op(googleSignInState.credential)
    }
}


@Composable
fun ShowDialog(userRecoverableAuthIntentSate: UserRecoverableAuthIntentSate) {
    when (userRecoverableAuthIntentSate) {
        is UserRecoverableAuthIntentSate.Failed -> {
            val context = LocalContext.current
            LaunchedEffect(key1 = Unit) {
                context.startActivity(
                    userRecoverableAuthIntentSate.e
                )
            }
        }
        else -> {}
    }
}

MainActivity の重要な点は、42行目のクライアント ID です
こちらは「準備」パートで取得したウェブ用のクライアント ID を指定してください

41行目から48行目で Google サインインを行っています
82行目から86行目は、 アプリ実行後に指定した Google アカウントとこのアプリの連携が初めての場合に実行されます

MainActivityViewModel.kt

package your.package.name // ご自身のものに変更してください

import android.accounts.Account
import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.IntentSender
import android.util.Log
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.android.gms.common.api.ApiException
import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential
import com.google.api.client.googleapis.extensions.android.gms.auth.UserRecoverableAuthIOException
import com.google.api.client.http.javanet.NetHttpTransport
import com.google.api.client.json.gson.GsonFactory
import com.google.api.services.drive.Drive
import com.google.api.services.drive.DriveScopes
import dagger.assisted.AssistedFactory
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.launch
import java.util.Collections
import javax.inject.Inject

@HiltViewModel
@SuppressLint("StaticFieldLeak")
class MainActivityViewModel @Inject constructor(
    @ApplicationContext private val context: Context,
): ViewModel() {
    var credential: MutableState<GoogleSignInState> = mutableStateOf(GoogleSignInState.Loading)
        private set

    var intent: MutableState<UserRecoverableAuthIntentSate> =
        mutableStateOf(UserRecoverableAuthIntentSate.Loading)
        private set

    private val tag = "MainActivityViewModel"

    @Inject lateinit var googleDriveDataSourceFactory: GoogleDriveDataSourceFactory

    private fun setupDataSource(credential: GoogleAccountCredential): GoogleDriveDataSource {
        val service = Drive.Builder(
            NetHttpTransport(),
            GsonFactory.getDefaultInstance(),
            credential
        )
            .setApplicationName("Bill Splitter")
            .build()

        return googleDriveDataSourceFactory.create(service)
    }

    fun deleteAllTrash(credential: GoogleAccountCredential) {
        viewModelScope.launch {
            try {
                val googleDriveDataSource = setupDataSource(credential)
                googleDriveDataSource.deleteAllTrash()
                intent.value = UserRecoverableAuthIntentSate.Success
            } catch (e: Exception) {
                Log.e(tag, e.toString())
                if (e is UserRecoverableAuthIOException) {
                    intent.value = UserRecoverableAuthIntentSate.Failed(e.intent)
                }
            }
        }
    }
    
    fun saveLoginSuccess(credentialId: String) {
        val packageName = "your.package.name" // ご自身のものに変更してください
        val account = Account(credentialId, packageName)
        val googleAccountCredential =
            GoogleAccountCredential
                .usingOAuth2(
                    context,
                    Collections.singleton(DriveScopes.DRIVE)
                )
                .setSelectedAccount(account)

        this.credential.value = GoogleSignInState.Success(googleAccountCredential)
    }

    fun setLoginFailure(e: Exception) {
        credential.value = when (e) {
            is ActivityNotFoundException -> {
                GoogleSignInState.ActivityNotFoundException(e)
            }

            is ApiException -> {
                GoogleSignInState.ApiException(e)
            }

            is IntentSender.SendIntentException -> {
                GoogleSignInState.SendIntentException(e)
            }

            else -> {
                GoogleSignInState.TaskFailed(e)
            }
        }
    }
}

sealed interface GoogleSignInState {
    data class ActivityNotFoundException(val e: android.content.ActivityNotFoundException): GoogleSignInState
    data class ApiException(val e: com.google.android.gms.common.api.ApiException): GoogleSignInState
    data class TaskFailed(val e: Exception): GoogleSignInState
    data class SendIntentException(val e: IntentSender.SendIntentException): GoogleSignInState
    data class Success(val credential: GoogleAccountCredential): GoogleSignInState
    data object Loading: GoogleSignInState
}

sealed interface UserRecoverableAuthIntentSate {
    data class Failed(val e: Intent): UserRecoverableAuthIntentSate
    data object Success: UserRecoverableAuthIntentSate
    data object Loading: UserRecoverableAuthIntentSate
}

@AssistedFactory
interface GoogleDriveDataSourceFactory {
    fun create(driveService: Drive): GoogleDriveDataSource
}

情報量が多いですが、重要な部分は下記の3点です
・44行目の setupDataSource()
・56行目の deleteAllTrash()
・71行目の saveLoginSuccess()
この中でも特に重要なのは saveLoginSuccess() です

setupDataSource()
こちらの目的は Hilt の Assisted Injection パターンを使い、GoogleDriveDataSource クラスを DI する際に動的な値をコンストラクタに渡すことです
※Assisted Injection パターンについてはこちら
※GoogleDriveDataSource クラスについては下で解説

この関数の注目点は、動的に渡している Google Drive API v3 の Drive クラスです
Drive クラスのインスタンス化を Builder クラスで行っており、setupDataSource() のパラメーターとして設定している credential を48行目で使用しています

deleteAllTrash()
※後日更新予定

saveLoginSuccess()
※後日更新予定
実現したいことは、受け取った credentialId を使用して GoogleAccountCredential クラスを作成することです
GoogleAccountCredential は Google アカウントの認証と選択を管理するクラスです

GoogleSignIn.kt

package your.package.name // ご自身のものに変更してください

import android.content.ActivityNotFoundException
import android.content.IntentSender
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.IntentSenderRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material3.Button
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.google.android.gms.auth.api.identity.BeginSignInRequest
import com.google.android.gms.auth.api.identity.Identity
import com.google.android.gms.common.api.ApiException

@Composable
fun GoogleSignIn(
    modifier: Modifier = Modifier,
    serverClientId: String,
    onSuccessListener: (String) -> Unit,
    onFailureListener: (Exception) -> Unit,
    content: @Composable (RowScope.() -> Unit)
) {
    val context = LocalContext.current
    val oneTapClient = Identity.getSignInClient(context)
    val signInRequest =
        BeginSignInRequest
            .builder()
            .setGoogleIdTokenRequestOptions(
                BeginSignInRequest
                    .GoogleIdTokenRequestOptions
                    .builder()
                    .setSupported(true)
                    .setServerClientId(serverClientId)
                    .setFilterByAuthorizedAccounts(false)
                    .build()
            )
            .setAutoSelectEnabled(true)
            .build()

    val launcher = rememberLauncherForActivityResult(
        ActivityResultContracts.StartIntentSenderForResult()
    ) {
        try {
            val oneTapCredential = oneTapClient.getSignInCredentialFromIntent(it.data)
            onSuccessListener(oneTapCredential.id)
        } catch (e: ApiException) {
            onFailureListener(e)
        }
    }

    val startGoogleLogin = {
        oneTapClient
            .beginSignIn(signInRequest)
            .addOnSuccessListener { result ->
                try {
                    launcher.launch(
                        IntentSenderRequest.Builder(result.pendingIntent.intentSender).build()
                    )
                } catch (e: IntentSender.SendIntentException) {
                    onFailureListener(e)
                } catch (e: ActivityNotFoundException) {
                    onFailureListener(e)
                }
            }
            .addOnFailureListener { e ->
                onFailureListener(e)
            }
    }

    Button(
        onClick = {
            startGoogleLogin()
        },
        modifier = modifier,
        content = content,
    )
}

このコンポーザブル関数の目的は、認証情報の取得をすることです
42行目から51行目で宣言している launcher を58行目の launch() で実行することにより、ViewModel に認証情報を渡しています
取得の全体的な流れは公式ドキュメントで推奨されている方法に沿っています

launcher の宣言

    val launcher = rememberLauncherForActivityResult(
        ActivityResultContracts.StartIntentSenderForResult()
    ) {
        try {
            val oneTapCredential = oneTapClient.getSignInCredentialFromIntent(it.data)
            onSuccessListener(oneTapCredential.id)
        } catch (e: ApiException) {
            onFailureListener(e)
        }
    }

この変数宣言の目的は、activity の開始をリクエストすることと、リクエスト実施後の結果を用いて行う作業内容を記述することです

サインイン API クライアント(サインイン API を操作する側)

    val context = LocalContext.current
    val oneTapClient = Identity.getSignInClient(context)
    val signInRequest =

サインイン API の開始ポイントである Identity クラスを使用し、SignInClient クラスを取得しています

    val startGoogleLogin = {
        oneTapClient
            .beginSignIn(signInRequest)
            .addOnSuccessListener { result ->
                try {
                    launcher.launch(
                        IntentSenderRequest.Builder(result.pendingIntent).build()
                    )
                } catch (e: IntentSender.SendIntentException) {
                    onFailureListener(e)
                } catch (e: ActivityNotFoundException) {
                    onFailureListener(e)
                }
            }
            .addOnFailureListener { e ->
                onFailureListener(e)
            }
    }

SignInClient.beginSignIn() でサインインを開始し、結果を保持する Task を受け取ります
Task の成功時に activity を実行する(ローンチする)のが目的です

55行目の signInRequest についてはこちらです

BeginSignInRequest インスタンスの作成

    val signInRequest =
        BeginSignInRequest
            .builder()
            .setGoogleIdTokenRequestOptions(
                BeginSignInRequest
                    .GoogleIdTokenRequestOptions
                    .builder()
                    .setSupported(true)
                    .setServerClientId(serverClientId)
                    .setFilterByAuthorizedAccounts(false)
                    .build()
            )
            .setAutoSelectEnabled(true)
            .build()

サインインフロー開始時に設定するオプションを作成しています

            .setGoogleIdTokenRequestOptions(
                BeginSignInRequest
                    .GoogleIdTokenRequestOptions
                    .builder()
                    .setSupported(true)
                    .setServerClientId(serverClientId)
                    .setFilterByAuthorizedAccounts(false)
                    .build()
            )

サインイン実行中の設定や選択できるアカウントの制限などを設定しています

GoogleDriveDataSource.kt

package your.package.name // ご自身のものに変更してください

import com.google.api.services.drive.Drive
import your.package.name.GoogleDrive // ご自身のものに変更してください
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

class GoogleDriveDataSource @AssistedInject constructor(
    @Assisted private val driveService: Drive,
): BstGoogleDrive {
    suspend fun deleteAllTrash() {
        withContext(Dispatchers.IO) {
            driveService.files().emptyTrash().execute()
        }
    }
}

Drive API v3 を使用して、Google Drive のごみ箱からすべてのアイテムを消去しています

エラーの解消

上記の ViewModel では既に対応済みですが、アプリが Google Drive に対して行う予定の操作を、サインインした Google アカウントが承認済みでない場合、下記エラーが出力されます

// Logcat など
[GoogleAuthUtil] isUserRecoverableError status: NEED_REMOTE_CONSENT
com.google.api.client.googleapis.extensions.android.gms.auth.UserRecoverableAuthIOException

上記を例にすると、MainActivityViewModel.kt の78行目で DriveScopes.DRIVE を指定し、このアプリが Google Drive のファイル全般に対して操作することを表しています
この操作について、サインインしている Google アカウントに対して確認をとるため、上記エラーが出力されます

対処は MainActivity.kt の ShowDialog() で行っています
エラーとして返される Intent を使用して activity を実行しています

@Composable
fun ShowDialog(userRecoverableAuthIntentSate: UserRecoverableAuthIntentSate) {
    when (userRecoverableAuthIntentSate) {
        is UserRecoverableAuthIntentSate.Failed -> {
            val context = LocalContext.current
            LaunchedEffect(key1 = Unit) {
                context.startActivity(
                    userRecoverableAuthIntentSate.e
                )
            }
        }
        else -> {}
    }
}

おわりに

不明瞭なところなどが多々あると思いますので、定期的に更新しようと思います
また、コード自体も突貫工事感が強いので、修正後に何らかの形で載せようと思います

他にもご要望などあればお気軽にコメントまたはContactからご連絡ください

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

Enumのentriesについて

2024年3月20日