Android

소셜 로그인 모듈화 과정 ( 추상화 및 액티비티 의존성 제거 )

그란. 2022. 9. 29. 21:49

소셜 로그인할때 Util 로 만들어 액티비티에 종속되지 않는 방법으로 만들어 보려고 한다

  • 현재 시점에서 최신 버전 SDK 이용 
  • 한꺼번에 컨트롤 
  • 토큰만 가져오는게 아닌 토큰으로 이메일등의 정보까지 가져올 수 있도록 한다.

한꺼번에 컨트롤이란 의미는 아래와 같은 사용법을 말한다.

Activity
    google.setOnClickListener {
        viewModel.login(AuthType.GOOGLE)
    }

    kakao.setOnClickListener {
        viewModel.login(AuthType.KAKAO)
    }

    naver.setOnClickListener {
        viewModel.login(AuthType.NAVER)
    }


ViewModel
fun login(type: AuthType) = viewModelScope.launch {
	socialLoginUseCase.invoke(type)
}

 

enum class AuthType {
    EMAIL, APPLE, GOOGLE, KAKAO, NAVER;

    override fun toString(): String {
        return when (this) {
            EMAIL -> "이메일 로그인"
            APPLE -> "애플 로그인"
            GOOGLE -> "구글 로그인"
            KAKAO -> "카카오 로그인"
            NAVER -> "네이버 로그인"
        }
    }
}

 

사전 작업 

  • 아래의 인터페이스를 만들어 로그인, 로그아웃을 추상화
  • latestLoginResult의 존재이유 
    • 로그인화면에서 소셜토큰을 받은 후 서버에 통신
    • 서버에서 Not_Found 에러를 준 경우 회원가입 화면으로 이동하여 다시 토큰 및 이메일 정보들을 가져올 수 있도록.
interface SocialLoginProvider {
    suspend fun latestLoginResult(type: AuthType): SocialLoginResult?

    suspend fun login(type: AuthType): SocialLoginResult

    suspend fun logout(type: AuthType)
}
data class SocialLoginResult(
    val type: AuthType,
    val token: String,
    val email: String?,
    val birth: String?
) : Serializable
internal class SocialLoginProviderImpl @Inject constructor(
    private val providers: Map<AuthType, @JvmSuppressWildcards Provider<SocialLoginProvider>>
) : SocialLoginProvider {

    override suspend fun latestLoginResult(type: AuthType): SocialLoginResult {
        return providers[type]?.run {
            get().latestLoginResult(type)
        } ?: throw IllegalArgumentException("$type of parameter type is not allowed value.")
    }

    override suspend fun login(type: AuthType): SocialLoginResult {
        return providers[type]?.run {
            get().login(type)
        } ?: throw IllegalArgumentException("$type of parameter type is not allowed value.")
    }

    override suspend fun logout(type: AuthType) {
        providers.values.map { it.get().logout(type) }
    }
}

이게 좀 어려운 개념인데 구현체들을 구현한 애들을 Map으로 받아놓고 key값으로 해당 타입만 실행할 수 있게 한다.

 

 

1. 네이버 로그인

@Singleton
internal class NaverLoginProviderImpl @Inject constructor(
    @ApplicationContext
    private val applicationContext: Context,
) : SocialLoginProvider {

    init {
        NaverIdLoginSDK.initialize(
            applicationContext,
            naverClientId,
            naverClientSecret,
            applicationContext.getString(R.string.app_name)
        )
        NaverIdLoginSDK.showDevelopersLog(BuildConfig.DEBUG)
    }

    override suspend fun latestLoginResult(type: AuthType): SocialLoginResult? {
        return getProfile(NaverIdLoginSDK.getAccessToken() ?: return null)
    }

    override suspend fun login(type: AuthType): SocialLoginResult {
        return getProfile(getToken())
    }

    @OptIn(ExperimentalCoroutinesApi::class)
    private suspend fun getToken(): String = suspendCancellableCoroutine { continuation ->
        val callback = object : OAuthLoginCallback {
            override fun onError(errorCode: Int, message: String) {
                onFailure(errorCode, message)
            }

            override fun onFailure(httpStatus: Int, message: String) {
                continuation.resumeWithException(DataException(httpStatus, message))
            }

            override fun onSuccess() {
                NaverIdLoginSDK.getAccessToken()?.let {
                    continuation.resume(it) { e ->
                        continuation.resumeWithException(e)
                    }
                }
            }
        }

        NaverIdLoginSDK.logout()
        NaverIdLoginSDK.authenticate(applicationContext, callback)
    }

    override suspend fun logout(type: AuthType) {
        NaverIdLoginSDK.logout()
    }

    @OptIn(ExperimentalCoroutinesApi::class)
    private suspend fun getProfile(token: String): SocialLoginResult =
        suspendCancellableCoroutine { continuation ->
            val callback = object : NidProfileCallback<NidProfileResponse> {
                override fun onError(errorCode: Int, message: String) {
                    onFailure(errorCode, message)
                }

                override fun onFailure(httpStatus: Int, message: String) {
                    continuation.resumeWithException(DataException(httpStatus, message))
                }

                override fun onSuccess(result: NidProfileResponse) {
                    continuation.resume(
                        SocialLoginResult(
                            type = AuthType.NAVER,
                            token = token,
                            email = result.profile?.email,
                            birth = ""
                        )
                    ) {
                        continuation.resumeWithException(it)
                    }
                }
            }
            NidOAuthLogin().callProfileApi(callback)
        }
}

 

정리하자면

NaverIdLoginSdk.authenticate() : 로그인

NidOAuthLogin().callProfileApi() : 프로필을 가져온다

 

 

네이버는 콜백을 잘 추상화하여 잘 만들어 놓아서 깔끔한 편이다. ( 버그 대응이 빠르고 업데이트가 잘되는 편 ) 

액티비티의 의존성은 없다 ( 아마 웹뷰방식으로 하는것 같음, 확인 필요  ) 

별도로 해시키 등록이 필요없다.

 

 

2. 카카오 로그인

@Singleton
internal class KakaoLoginProviderImpl @Inject constructor(
    @ApplicationContext
    private val applicationContext: Context
) : SocialLoginProvider {

    init {
        KakaoSdk.init(
            applicationContext,
            kakaoNativeAppKey
        )
    }

    override suspend fun latestLoginResult(type: AuthType): SocialLoginResult? {
        val authToken = TokenManagerProvider.instance.manager.getToken() ?: return null
        return accessTokenToResult(authToken.accessToken)
    }

    override suspend fun login(type: AuthType): SocialLoginResult {
        val authToken = loginWithKakao(LastActivityUtils.requireLastActivity())
        return accessTokenToResult(authToken.accessToken)
    }

    private suspend fun loginWithKakao(context: Context): OAuthToken {
        return if (UserApiClient.instance.isKakaoTalkLoginAvailable(context)) {
            kotlin.runCatching {
                loginWithKakaoApp(context)
            }.onFailure {
                if (isNotSupported(it)) {
                    loginWithKakaoWeb(context)
                }
            }.getOrThrow()
        } else {
            loginWithKakaoWeb(context)
        }
    }

    @OptIn(ExperimentalCoroutinesApi::class)
    private suspend fun loginWithKakaoApp(context: Context): OAuthToken =
        suspendCancellableCoroutine { continuation ->
            UserApiClient.instance.loginWithKakaoTalk(context) { token, error ->
                error?.let {
                    continuation.resumeWithException(it)
                    return@loginWithKakaoTalk
                }
                continuation.resume(token ?: return@loginWithKakaoTalk) {
                    continuation.resumeWithException(it)
                }
            }
        }


    @OptIn(ExperimentalCoroutinesApi::class)
    private suspend fun loginWithKakaoWeb(context: Context): OAuthToken =
        suspendCancellableCoroutine { continuation ->
            UserApiClient.instance.loginWithKakaoAccount(context) { token, error ->
                error?.let {
                    continuation.resumeWithException(it)
                    return@loginWithKakaoAccount
                }
                continuation.resume(token ?: return@loginWithKakaoAccount) {
                    continuation.resumeWithException(it)
                }
            }
        }

    private fun isNotSupported(throwable: Throwable): Boolean {
        return throwable is AuthError &&
                throwable.response.error == "NotSupportError"
    }

    @OptIn(ExperimentalCoroutinesApi::class)
    private suspend fun accessTokenToResult(accessToken: String): SocialLoginResult =
        suspendCancellableCoroutine { continuation ->
            UserApiClient.instance.me { user, error ->
                error?.let {
                    continuation.resumeWithException(it)
                    return@me
                }
                continuation.resume(
                    SocialLoginResult(
                        type = AuthType.KAKAO,
                        token = accessToken,
                        email = user?.kakaoAccount?.email,
                        birth = ""
                    )
                ) {
                    continuation.resumeWithException(it)
                }
            }
        }


    @OptIn(ExperimentalCoroutinesApi::class)
    override suspend fun logout(type: AuthType): Unit =
        suspendCancellableCoroutine { continuation ->
            UserApiClient.instance.logout {
                it?.let {
                    continuation.resumeWithException(it)
                    return@logout
                }
                continuation.resume(Unit) {
                    continuation.resumeWithException(it)
                }
            }
        }
}

카카오 로그인은 

앱으로 로그인 하는 경우와 웹으로 로그인 하는 경우가 있다. 

 

앱으로 로그인할때 NotSupport 에러가 발생한 경우엔 웹으로 할 수 있게 한번더 랩핑

카카오 로그인은 액티비티가 필요하다 

LastActivityUtil은 현재 액티비티를 알고있는 Util이다 ( Application.ActivityLifecycleCallbacks 이용 ) 

 

그리고 웹뷰 콜백을 받기 위해 아래의 코드 필요 + 개발자 콘솔에 해시키 지정 필요 

<activity
    android:name="com.kakao.sdk.auth.AuthCodeHandlerActivity"
    android:exported="true">
    <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:host="oauth"
            android:scheme="kakao${KAKAO_NATIVE_APP_KEY}" />
    </intent-filter>
</activity>

 

 

3. 구글 로그인

internal class GoogleLoginProviderImpl @Inject constructor(
    @ApplicationContext
    private val applicationContext: Context,
) : SocialLoginProvider {
    private val googleSignInClient: GoogleSignInClient by lazy {
        val options = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
            .requestEmail()
            .requestIdToken(googleWebClientId)
            .requestScopes(Scope(Scopes.EMAIL), Scope(Scopes.OPEN_ID))
            .build()
        GoogleSignIn.getClient(applicationContext, options)
    }

    override suspend fun latestLoginResult(type: AuthType): SocialLoginResult? {
        val account = GoogleSignIn.getLastSignedInAccount(applicationContext) ?: return null
        return account.toResult()
    }

    override suspend fun login(type: AuthType): SocialLoginResult {
        logout(AuthType.GOOGLE)
        return loginInternal()
    }

    @OptIn(ExperimentalCoroutinesApi::class)
    private suspend fun loginInternal(): SocialLoginResult =
        suspendCancellableCoroutine { continuation ->
            TedOnActivityResult.with(LastActivityUtils.requireLastActivity())
                .setIntent(googleSignInClient.signInIntent)
                .setListener { resultCode, result ->
                    if (resultCode == RESULT_OK) {
                        runBlocking {
                            val info =
                                GoogleSignIn.getSignedInAccountFromIntent(result)
                            val withException = info.getResult(ApiException::class.java)
                            continuation.resume(withException.toResult()) { it.printStackTrace() }
                        }
                    } else {
                        continuation.resumeWithException(DataException(1, "user_cancel"))
                    }
                }.startActivityForResult()
        }

    override suspend fun logout(type: AuthType) {
        return googleSignInClient.signOut().await().run {}
    }

    private fun GoogleSignInAccount.toResult(): SocialLoginResult {
        return SocialLoginResult(
            type = AuthType.GOOGLE,
            token = this.idToken!!,
            email = this.email,
            birth = ""
        )
    }
}

 

구글 로그인은 하면서 이슈가 있었는데  

startActivity(googleSignInCllient.signInIntent)를 하고 콜백을 바로 받을수 있어야하는데 ( onActivityResult 방식이 아니니까 ) 

바로 받을수 있게 하려고 

val googleCallback =
    (LastActivityUtils.requireLastActivity() as ComponentActivity).registerForActivityResult(
        ActivityResultContracts.StartActivityForResult()
    ) { result ->
        if (result.resultCode == Activity.RESULT_OK) {
            runBlocking {
                val info =
                    GoogleSignIn.getSignedInAccountFromIntent(result.data).await()
                continuation.resume(info.toResult()) { it.printStackTrace() }
            }
        }
    }
googleCallback.launch(googleSignInClient.signInIntent)

이렇게 억지로 registerForActivityResult 를 넣었다.

하지만 registry (등록) 은 onStarted 이전에 해야한다는 에러가 발생했다. 이 방법은 onResume 이후에 등록하니까..

 

그래서 결국 라이브러리를 사용하게 되었다. 바로 콜백 받아올 수 있는 TedOnActivityResult.

( 해당 라이브러리의 개념은 ProxyActivity 라는 별도의 액티비티를 띄워 거기서 결과값을 받아와 리턴하는 방식 ) 

 

그런데 해당 라이브러리를 사용해서 구글 팝업창을 띄우면 아름답지 못하다. ( 테마가 검은색 화면이라 이질감이 느껴진다 ) 

 

그래서 해당 액티비티를 테마 덮어쓰기 했다 -> Light로 변경 

<activity
    android:name="com.gun0912.tedonactivityresult.ProxyActivity"
    android:configChanges="mcc|mnc|locale|keyboard|keyboardHidden|screenLayout|fontScale|uiMode|orientation|screenSize|layoutDirection"
    android:exported="false"
    android:screenOrientation="unspecified"
    android:theme="@style/Theme.AppCompat.Light.Dialog"
    tools:node="merge" />
<activity android:name=".view.search.SearchActivity" />

 

구글 로그인 역시 SHA1키 등록 필요


 

 

'Android' 카테고리의 다른 글

특정 뷰위에 BottomSheet 올리기  (0) 2022.09.29
카카오톡 잠금화면 구현  (0) 2022.09.29
transition animation 적용  (0) 2022.08.14
Room With Coroutine Flow  (0) 2022.07.30
DataStore 적용하기  (0) 2022.07.27