소셜 로그인할때 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 |