diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1599634..a39498f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,4 +1,3 @@ - import java.util.Properties plugins { @@ -30,6 +29,9 @@ android { useSupportLibrary = true } buildConfigField("String", "BASE_URL", properties["base.url"].toString()) + buildConfigField("String", "KAKAO_API_KEY", properties["KAKAO_API_KEY"].toString()) + buildConfigField("String", "GOOGLE_CLIENT_ID", properties["GOOGLE_CLIENT_ID"].toString()) + manifestPlaceholders["KAKAO_NATIVE_APP_KEY"] = properties["KAKAO_NATIVE_APP_KEY"].toString() } buildTypes { @@ -130,4 +132,8 @@ dependencies { // Kakao implementation(libs.kakao.all) implementation(libs.kakao.user) + + // Google + implementation(libs.play.services.auth) + implementation(libs.google.id) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index efb91d4..5be0f34 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,10 +2,11 @@ - - + - + + + + + + + + + + diff --git a/app/src/main/java/com/sopt/noostak/MyApp.kt b/app/src/main/java/com/sopt/noostak/MyApp.kt index 30d6058..db483bb 100644 --- a/app/src/main/java/com/sopt/noostak/MyApp.kt +++ b/app/src/main/java/com/sopt/noostak/MyApp.kt @@ -1,6 +1,7 @@ package com.sopt.noostak import android.app.Application +import com.kakao.sdk.common.KakaoSdk import dagger.hilt.android.HiltAndroidApp import timber.log.Timber @@ -9,9 +10,14 @@ class MyApp : Application() { override fun onCreate() { super.onCreate() setTimber() + initKakao() } private fun setTimber() { Timber.plant(Timber.DebugTree()) } + + private fun initKakao() { + KakaoSdk.init(this, BuildConfig.KAKAO_API_KEY) + } } diff --git a/app/src/main/java/com/sopt/noostak/di/AuthModule.kt b/app/src/main/java/com/sopt/noostak/di/AuthModule.kt new file mode 100644 index 0000000..b06adc9 --- /dev/null +++ b/app/src/main/java/com/sopt/noostak/di/AuthModule.kt @@ -0,0 +1,20 @@ +package com.sopt.noostak.di + +import com.sopt.noostak.BuildConfig +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Named +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AuthModule { + @Provides + @Singleton + @Named("GoogleClientId") + fun provideGoogleClientId(): String { + return BuildConfig.GOOGLE_CLIENT_ID + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9112c35..fc741a9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -53,6 +53,10 @@ landscapist = "2.3.6" # Kakao kakao = "2.20.1" +# Google +google-id = "1.1.1" +play-services-auth = "21.1.0" + viewpager-indicator = "5.0" # Hilt @@ -155,6 +159,10 @@ landscapist-animation = { group = "com.github.skydoves", name = "landscapist-ani kakao-all = { group = "com.kakao.sdk", name = "v2-all", version.ref = "kakao" } # Kakao SDK 라이브러리 kakao-user = { group = "com.kakao.sdk", name = "v2-user", version.ref = "kakao" } # Kakao 로그인 라이브러리 +# Google +play-services-auth = { group = "com.google.android.gms", name = "play-services-auth", version.ref = "play-services-auth" } +google-id = { group = "com.google.android.libraries.identity.googleid", name = "googleid", version.ref = "google-id" } + # Hilt hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } # Hilt Android 라이브러리 hilt-core = { group = "com.google.dagger", name = "hilt-core", version.ref = "hilt" } # Hilt Core 라이브러리 diff --git a/presentation/build.gradle.kts b/presentation/build.gradle.kts index 5d4324a..beb9d82 100644 --- a/presentation/build.gradle.kts +++ b/presentation/build.gradle.kts @@ -111,4 +111,8 @@ dependencies { // Kakao implementation(libs.kakao.all) implementation(libs.kakao.user) + + // Google + implementation(libs.play.services.auth) + implementation(libs.google.id) } diff --git a/presentation/src/main/java/com/sopt/presentation/auth/login/LogInRoute.kt b/presentation/src/main/java/com/sopt/presentation/auth/login/LogInRoute.kt index 0b6eeff..09df110 100644 --- a/presentation/src/main/java/com/sopt/presentation/auth/login/LogInRoute.kt +++ b/presentation/src/main/java/com/sopt/presentation/auth/login/LogInRoute.kt @@ -1,26 +1,34 @@ package com.sopt.presentation.auth.login +import android.app.Activity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.core.Animatable import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import com.google.android.gms.auth.api.identity.Identity import com.sopt.core.designsystem.theme.NoostakAndroidTheme import com.sopt.core.designsystem.theme.NoostakTheme +import com.sopt.core.extension.toast import com.sopt.presentation.R import com.sopt.presentation.auth.component.LoginButton @@ -30,18 +38,37 @@ fun LoginRoute( navigateToSignUp: (String) -> Unit, loginViewModel: LoginViewModel = hiltViewModel() ) { + val context = LocalContext.current + + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartIntentSenderForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK && result.data != null) { + val credential = Identity.getSignInClient(context) + .getSignInCredentialFromIntent(result.data) + loginViewModel.handleGoogleLoginResult(credential) + } else { + context.toast(R.string.toast_login_cancelled) + } + } + + LaunchedEffect(Unit) { + loginViewModel.initializeGoogleSignIn(context) + } + LaunchedEffect(loginViewModel.sideEffects) { loginViewModel.sideEffects.collect { sideEffect -> when (sideEffect) { is LoginSideEffect.NavigateToHome -> navigateToHome() is LoginSideEffect.NavigateSignUp -> navigateToSignUp(sideEffect.authId) + is LoginSideEffect.ShowToast -> context.toast(sideEffect.message) } } } LoginScreen( - onKakaoLoginClick = loginViewModel::kakaoLogin, - onGoogleLoginClick = loginViewModel::googleLogin + onKakaoLoginClick = { loginViewModel.kakaoLogin(context) }, + onGoogleLoginClick = { loginViewModel.googleLogin(launcher) } ) } @@ -55,7 +82,9 @@ fun LoginScreen( Column( modifier = Modifier .fillMaxSize() - .padding(dimensionResource(R.dimen.horizontal_padding)), + .padding(dimensionResource(R.dimen.horizontal_padding)) + .statusBarsPadding() + .navigationBarsPadding(), horizontalAlignment = Alignment.CenterHorizontally ) { Spacer(modifier = Modifier.weight(1f)) diff --git a/presentation/src/main/java/com/sopt/presentation/auth/login/LoginSideEffect.kt b/presentation/src/main/java/com/sopt/presentation/auth/login/LoginSideEffect.kt index dbed59c..849d43e 100644 --- a/presentation/src/main/java/com/sopt/presentation/auth/login/LoginSideEffect.kt +++ b/presentation/src/main/java/com/sopt/presentation/auth/login/LoginSideEffect.kt @@ -1,6 +1,12 @@ package com.sopt.presentation.auth.login +import androidx.annotation.StringRes + sealed class LoginSideEffect { data object NavigateToHome : LoginSideEffect() data class NavigateSignUp(val authId: String) : LoginSideEffect() + data class ShowToast( + @StringRes val message: Int, + val args: String? = null + ) : LoginSideEffect() } diff --git a/presentation/src/main/java/com/sopt/presentation/auth/login/LoginViewModel.kt b/presentation/src/main/java/com/sopt/presentation/auth/login/LoginViewModel.kt index 74066c6..2e26b40 100644 --- a/presentation/src/main/java/com/sopt/presentation/auth/login/LoginViewModel.kt +++ b/presentation/src/main/java/com/sopt/presentation/auth/login/LoginViewModel.kt @@ -1,26 +1,94 @@ package com.sopt.presentation.auth.login +import android.content.Context +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.IntentSenderRequest +import androidx.annotation.StringRes +import com.google.android.gms.auth.api.identity.Identity +import com.google.android.gms.auth.api.identity.SignInClient +import com.google.android.gms.auth.api.identity.SignInCredential +import com.kakao.sdk.auth.model.OAuthToken +import com.kakao.sdk.common.model.ClientError +import com.kakao.sdk.common.model.ClientErrorCause +import com.kakao.sdk.user.UserApiClient import com.sopt.core.util.BaseViewModel +import com.sopt.presentation.R import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import javax.inject.Inject +import javax.inject.Named @HiltViewModel -class LoginViewModel @Inject constructor() : BaseViewModel() { +class LoginViewModel @Inject constructor( + @Named("GoogleClientId") private val googleClientId: String +) : BaseViewModel() { + private val _authId = MutableStateFlow("") + val authId: StateFlow = _authId + private lateinit var oneTapClient: SignInClient - private val _authId = MutableStateFlow(null) - val authId: StateFlow = _authId + fun initializeGoogleSignIn(context: Context) { + oneTapClient = Identity.getSignInClient(context) + } + + // Kakao Login + fun kakaoLogin(context: Context) { + val loginCallback: (OAuthToken?, Throwable?) -> Unit = { token, error -> + if (error != null) { + handleError(error, R.string.toast_kakao_login_failed) + } else if (token != null) { + handleSuccess(token.accessToken, R.string.toast_kakao_login_success) + } + } + + if (UserApiClient.instance.isKakaoTalkLoginAvailable(context)) { + UserApiClient.instance.loginWithKakaoTalk(context, callback = loginCallback) + } else { + UserApiClient.instance.loginWithKakaoAccount(context, callback = loginCallback) + } + } + + // Google Login + fun googleLogin(launcher: ActivityResultLauncher) { + val signInRequest = com.google.android.gms.auth.api.identity.BeginSignInRequest.builder() + .setGoogleIdTokenRequestOptions( + com.google.android.gms.auth.api.identity.BeginSignInRequest.GoogleIdTokenRequestOptions.builder() + .setSupported(true) + .setServerClientId(googleClientId) + .setFilterByAuthorizedAccounts(false) + .build() + ) + .build() - fun kakaoLogin() { - // TODO: 카카오 로그인 - navigateToHome() + oneTapClient.beginSignIn(signInRequest) + .addOnSuccessListener { result -> + launcher.launch(IntentSenderRequest.Builder(result.pendingIntent).build()) + } + .addOnFailureListener { exception -> + handleError(exception, R.string.toast_google_login_failed) + } } - fun googleLogin() { - // TODO: 구글 로그인 - _authId.value = "google_access_token" - navigateToSignup(_authId.value.orEmpty()) + fun handleGoogleLoginResult(credential: SignInCredential) { + if (!credential.googleIdToken.isNullOrEmpty()) { + handleSuccess(credential.googleIdToken.toString(), R.string.toast_google_login_success) + } else { + showToast(R.string.toast_google_login_failed) + } + } + + private fun handleSuccess(authId: String, successMessageResId: Int) { + _authId.value = authId + showToast(successMessageResId) + navigateToSignup(authId) + } + + private fun handleError(error: Throwable, @StringRes errorMessageResId: Int) { + if (error is ClientError && error.reason == ClientErrorCause.Cancelled) { + showToast(R.string.toast_login_cancelled) + } else { + showToast(errorMessageResId, error.localizedMessage.orEmpty()) + } } private fun navigateToSignup(authId: String) { @@ -30,4 +98,13 @@ class LoginViewModel @Inject constructor() : BaseViewModel() { private fun navigateToHome() { emitSideEffect(LoginSideEffect.NavigateToHome) } + + private fun showToast(@StringRes messageResId: Int, vararg formatArgs: String) { + emitSideEffect( + LoginSideEffect.ShowToast( + message = messageResId, + args = formatArgs.joinToString(separator = ", ") + ) + ) + } } diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index 72ba89c..c6b9ff6 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -29,6 +29,11 @@ 이름을 입력해주세요 %1$d / %2$d 갤러리 권한이 필요합니다. + 카카오 로그인 성공 + 카카오 로그인 실패: %s + 로그인이 취소되었습니다. + 구글 로그인 성공 + 구글 로그인 실패: %s 그룹 초대 설명 이미지