diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b382f787..c8bfb09f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -31,12 +31,14 @@ android { val amplitudeApiKey: String = properties.getProperty("amplitude.api.key") val googleAdmobAppId: String = properties.getProperty("GOOGLE_ADMOB_APP_ID", "") val googleAdmobUnitId: String = properties.getProperty("GOOGLE_ADMOB_UNIT_ID", "") + val googleAuthWebClientId: String = properties.getProperty("GOOGLE_AUTH_WEB_CLIENT_ID", "") val allowedDomains: String = properties.getProperty("allowed.webview.domains", "notion.so,google.com") buildConfigField("String", "GOOGLE_ADMOB_APP_ID", "\"$googleAdmobAppId\"") buildConfigField("String", "GOOGLE_ADMOB_UNIT_ID", "\"$googleAdmobUnitId\"") buildConfigField("String", "KAKAO_API_KEY", "\"$kakaoApiKey\"") buildConfigField("String", "AMPLITUDE_API_KEY", "\"$amplitudeApiKey\"") + buildConfigField("String", "GOOGLE_AUTH_WEB_CLIENT_ID", "\"$googleAuthWebClientId\"") buildConfigField("String", "ALLOWED_WEBVIEW_DOMAINS", "\"$allowedDomains\"") manifestPlaceholders["kakaoRedirectUri"] = "kakao$kakaoApiKey" manifestPlaceholders["GOOGLE_ADMOB_APP_ID"] = googleAdmobAppId @@ -153,4 +155,8 @@ dependencies { implementation(libs.coil) implementation(libs.kakao.user) implementation(libs.kotlinx.datetime) + + implementation(libs.androidx.credentials.play.services.auth) + implementation(libs.google.auth) + implementation(libs.androidx.datastore.preferences) } diff --git a/app/src/main/java/com/sopt/clody/data/datastore/DataStoreModule.kt b/app/src/main/java/com/sopt/clody/data/datastore/DataStoreModule.kt new file mode 100644 index 00000000..68f1472c --- /dev/null +++ b/app/src/main/java/com/sopt/clody/data/datastore/DataStoreModule.kt @@ -0,0 +1,22 @@ +package com.sopt.clody.data.datastore + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DataStoreModule { + + @Provides + @Singleton + fun provideOAuthDataStore( + @ApplicationContext context: Context, + ): OAuthDataStore { + return OAuthDataStore(context) + } +} diff --git a/app/src/main/java/com/sopt/clody/data/datastore/OAuthDataStore.kt b/app/src/main/java/com/sopt/clody/data/datastore/OAuthDataStore.kt new file mode 100644 index 00000000..9fbaf319 --- /dev/null +++ b/app/src/main/java/com/sopt/clody/data/datastore/OAuthDataStore.kt @@ -0,0 +1,38 @@ +package com.sopt.clody.data.datastore + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +class OAuthDataStore @Inject constructor(@ApplicationContext context: Context) { + private val Context.dataStore by preferencesDataStore(name = "oauth_pref") + private val dataStore = context.dataStore + + suspend fun saveIdToken(token: String) { + dataStore.edit { it[OAuthDataStoreKeys.GOOGLE_ID_TOKEN] = token } + } + + suspend fun getIdToken(): String? { + return dataStore.data.first()[OAuthDataStoreKeys.GOOGLE_ID_TOKEN] + } + + suspend fun savePlatform(provider: OAuthProvider) { + dataStore.edit { it[OAuthDataStoreKeys.OAUTH_PLATFORM] = provider.name } + } + + suspend fun getPlatform(): OAuthProvider? { + return dataStore.data.first()[OAuthDataStoreKeys.OAUTH_PLATFORM]?.let { + runCatching { OAuthProvider.valueOf(it) }.getOrNull() + } + } + + suspend fun clear() { + dataStore.edit { + it.remove(OAuthDataStoreKeys.GOOGLE_ID_TOKEN) + it.remove(OAuthDataStoreKeys.OAUTH_PLATFORM) + } + } +} diff --git a/app/src/main/java/com/sopt/clody/data/datastore/OAuthDataStoreKeys.kt b/app/src/main/java/com/sopt/clody/data/datastore/OAuthDataStoreKeys.kt new file mode 100644 index 00000000..9bdac71b --- /dev/null +++ b/app/src/main/java/com/sopt/clody/data/datastore/OAuthDataStoreKeys.kt @@ -0,0 +1,8 @@ +package com.sopt.clody.data.datastore + +import androidx.datastore.preferences.core.stringPreferencesKey + +object OAuthDataStoreKeys { + val GOOGLE_ID_TOKEN = stringPreferencesKey("google_id_token") + val OAUTH_PLATFORM = stringPreferencesKey("oauth_platform") +} diff --git a/app/src/main/java/com/sopt/clody/data/datastore/OAuthProvider.kt b/app/src/main/java/com/sopt/clody/data/datastore/OAuthProvider.kt new file mode 100644 index 00000000..c8c57020 --- /dev/null +++ b/app/src/main/java/com/sopt/clody/data/datastore/OAuthProvider.kt @@ -0,0 +1,6 @@ +package com.sopt.clody.data.datastore + +enum class OAuthProvider(val apiValue: String) { + GOOGLE("google"), + KAKAO("kakao"), +} diff --git a/app/src/main/java/com/sopt/clody/data/remote/api/AuthService.kt b/app/src/main/java/com/sopt/clody/data/remote/api/AuthService.kt index af1ae417..7229df5a 100644 --- a/app/src/main/java/com/sopt/clody/data/remote/api/AuthService.kt +++ b/app/src/main/java/com/sopt/clody/data/remote/api/AuthService.kt @@ -1,6 +1,7 @@ package com.sopt.clody.data.remote.api import com.sopt.clody.data.remote.dto.base.ApiResponse +import com.sopt.clody.data.remote.dto.request.GoogleSignUpRequestDto import com.sopt.clody.data.remote.dto.request.LoginRequestDto import com.sopt.clody.data.remote.dto.request.SignUpRequestDto import com.sopt.clody.data.remote.dto.response.LoginResponseDto @@ -21,4 +22,9 @@ interface AuthService { @Header("Authorization") authorization: String, @Body signUpRequestDto: SignUpRequestDto, ): ApiResponse + + @POST("api/v1/auth/oauth2/google") + suspend fun signUpWithGoogle( + @Body body: GoogleSignUpRequestDto, + ): ApiResponse } diff --git a/app/src/main/java/com/sopt/clody/data/remote/datasource/AuthDataSource.kt b/app/src/main/java/com/sopt/clody/data/remote/datasource/AuthDataSource.kt index e4dfe411..d3f03fc1 100644 --- a/app/src/main/java/com/sopt/clody/data/remote/datasource/AuthDataSource.kt +++ b/app/src/main/java/com/sopt/clody/data/remote/datasource/AuthDataSource.kt @@ -1,6 +1,7 @@ package com.sopt.clody.data.remote.datasource import com.sopt.clody.data.remote.dto.base.ApiResponse +import com.sopt.clody.data.remote.dto.request.GoogleSignUpRequestDto import com.sopt.clody.data.remote.dto.request.LoginRequestDto import com.sopt.clody.data.remote.dto.request.SignUpRequestDto import com.sopt.clody.data.remote.dto.response.LoginResponseDto @@ -9,4 +10,5 @@ import com.sopt.clody.data.remote.dto.response.SignUpResponseDto interface AuthDataSource { suspend fun signIn(authorization: String, requestSignInDto: LoginRequestDto): ApiResponse suspend fun signUp(authorization: String, requestSignUpDto: SignUpRequestDto): ApiResponse + suspend fun signUpWithGoogle(googleSignUpRequestDto: GoogleSignUpRequestDto): ApiResponse } diff --git a/app/src/main/java/com/sopt/clody/data/remote/datasourceimpl/AuthDataSourceImpl.kt b/app/src/main/java/com/sopt/clody/data/remote/datasourceimpl/AuthDataSourceImpl.kt index 91670e80..926d01a1 100644 --- a/app/src/main/java/com/sopt/clody/data/remote/datasourceimpl/AuthDataSourceImpl.kt +++ b/app/src/main/java/com/sopt/clody/data/remote/datasourceimpl/AuthDataSourceImpl.kt @@ -3,6 +3,7 @@ package com.sopt.clody.data.remote.datasourceimpl import com.sopt.clody.data.remote.api.AuthService import com.sopt.clody.data.remote.datasource.AuthDataSource import com.sopt.clody.data.remote.dto.base.ApiResponse +import com.sopt.clody.data.remote.dto.request.GoogleSignUpRequestDto import com.sopt.clody.data.remote.dto.request.LoginRequestDto import com.sopt.clody.data.remote.dto.request.SignUpRequestDto import com.sopt.clody.data.remote.dto.response.LoginResponseDto @@ -17,4 +18,7 @@ class AuthDataSourceImpl @Inject constructor( override suspend fun signUp(authorization: String, requestSignUpDto: SignUpRequestDto): ApiResponse = authService.signUp(authorization, requestSignUpDto) + + override suspend fun signUpWithGoogle(googleSignUpRequestDto: GoogleSignUpRequestDto): ApiResponse = + authService.signUpWithGoogle(googleSignUpRequestDto) } diff --git a/app/src/main/java/com/sopt/clody/data/remote/dto/request/GoogleSignUpRequestDto.kt b/app/src/main/java/com/sopt/clody/data/remote/dto/request/GoogleSignUpRequestDto.kt new file mode 100644 index 00000000..f4ed6c28 --- /dev/null +++ b/app/src/main/java/com/sopt/clody/data/remote/dto/request/GoogleSignUpRequestDto.kt @@ -0,0 +1,10 @@ +package com.sopt.clody.data.remote.dto.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class GoogleSignUpRequestDto( + @SerialName("idToken") val idToken: String, + @SerialName("fcmToken") val fcmToken: String, +) diff --git a/app/src/main/java/com/sopt/clody/data/remote/dto/response/UserInfoResponseDto.kt b/app/src/main/java/com/sopt/clody/data/remote/dto/response/UserInfoResponseDto.kt index 558e2797..ba4297e9 100644 --- a/app/src/main/java/com/sopt/clody/data/remote/dto/response/UserInfoResponseDto.kt +++ b/app/src/main/java/com/sopt/clody/data/remote/dto/response/UserInfoResponseDto.kt @@ -1,5 +1,6 @@ package com.sopt.clody.data.remote.dto.response +import com.sopt.clody.data.datastore.OAuthProvider import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -7,5 +8,5 @@ import kotlinx.serialization.Serializable data class UserInfoResponseDto( @SerialName("email") val email: String, @SerialName("name") val name: String, - @SerialName("platform") val platform: String, + @SerialName("platform") val platform: OAuthProvider?, ) diff --git a/app/src/main/java/com/sopt/clody/data/remote/util/AuthInterceptor.kt b/app/src/main/java/com/sopt/clody/data/remote/util/AuthInterceptor.kt index f3e1fac4..7ebc6934 100644 --- a/app/src/main/java/com/sopt/clody/data/remote/util/AuthInterceptor.kt +++ b/app/src/main/java/com/sopt/clody/data/remote/util/AuthInterceptor.kt @@ -49,6 +49,7 @@ class AuthInterceptor @Inject constructor( private fun shouldAddAuthorization(url: String): Boolean { return !url.contains("api/v1/auth/signin") && !url.contains("api/v1/auth/signup") && + !url.contains("api/v1/auth/oauth2/google") && !url.contains("api/v1/auth/reissue") } diff --git a/app/src/main/java/com/sopt/clody/data/repositoryimpl/AuthRepositoryImpl.kt b/app/src/main/java/com/sopt/clody/data/repositoryimpl/AuthRepositoryImpl.kt index 854fcaf7..824acfcd 100644 --- a/app/src/main/java/com/sopt/clody/data/repositoryimpl/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/sopt/clody/data/repositoryimpl/AuthRepositoryImpl.kt @@ -1,6 +1,7 @@ package com.sopt.clody.data.repositoryimpl import com.sopt.clody.data.remote.datasource.AuthDataSource +import com.sopt.clody.data.remote.dto.request.GoogleSignUpRequestDto import com.sopt.clody.data.remote.dto.request.LoginRequestDto import com.sopt.clody.data.remote.dto.request.SignUpRequestDto import com.sopt.clody.data.remote.dto.response.LoginResponseDto @@ -21,4 +22,9 @@ class AuthRepositoryImpl @Inject constructor( runCatching { authDataSource.signUp(authorization, requestSignUpDto).handleApiResponse().getOrThrow() } + + override suspend fun signUpWithGoogle(googleSignUpRequestDto: GoogleSignUpRequestDto): Result = + runCatching { + authDataSource.signUpWithGoogle(googleSignUpRequestDto).handleApiResponse().getOrThrow() + } } diff --git a/app/src/main/java/com/sopt/clody/domain/repository/AuthRepository.kt b/app/src/main/java/com/sopt/clody/domain/repository/AuthRepository.kt index e9b822ec..55f1053c 100644 --- a/app/src/main/java/com/sopt/clody/domain/repository/AuthRepository.kt +++ b/app/src/main/java/com/sopt/clody/domain/repository/AuthRepository.kt @@ -1,5 +1,6 @@ package com.sopt.clody.domain.repository +import com.sopt.clody.data.remote.dto.request.GoogleSignUpRequestDto import com.sopt.clody.data.remote.dto.request.LoginRequestDto import com.sopt.clody.data.remote.dto.request.SignUpRequestDto import com.sopt.clody.data.remote.dto.response.LoginResponseDto @@ -8,4 +9,5 @@ import com.sopt.clody.data.remote.dto.response.SignUpResponseDto interface AuthRepository { suspend fun signIn(authorization: String, requestSignInDto: LoginRequestDto): Result suspend fun signUp(authorization: String, requestSignUpDto: SignUpRequestDto): Result + suspend fun signUpWithGoogle(googleSignUpRequestDto: GoogleSignUpRequestDto): Result } diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/SignUpContract.kt b/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/SignUpContract.kt index 746f35e7..e94baaf4 100644 --- a/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/SignUpContract.kt +++ b/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/SignUpContract.kt @@ -2,6 +2,7 @@ package com.sopt.clody.presentation.ui.auth.signup import android.content.Context import com.airbnb.mvrx.MavericksState +import com.sopt.clody.data.datastore.OAuthProvider class SignUpContract { data class SignUpState( @@ -15,8 +16,9 @@ class SignUpContract { val errorMessage: String? = null, val serviceChecked: Boolean = false, val serviceUrl: String = "", - val privacyChecked: Boolean = false, val privacyUrl: String = "", + val privacyChecked: Boolean = false, + val platform: OAuthProvider = OAuthProvider.KAKAO, ) : MavericksState { val allChecked: Boolean get() = serviceChecked && privacyChecked @@ -33,7 +35,6 @@ class SignUpContract { data object ProceedTerms : SignUpIntent() data class CompleteSignUp(val context: Context) : SignUpIntent() data object ClearError : SignUpIntent() - data class ToggleAllChecked(val checked: Boolean) : SignUpIntent() data class ToggleServiceChecked(val checked: Boolean) : SignUpIntent() data class TogglePrivacyChecked(val checked: Boolean) : SignUpIntent() diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/SignUpScreen.kt b/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/SignUpScreen.kt index 0e744aa3..160e48f2 100644 --- a/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/SignUpScreen.kt +++ b/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/SignUpScreen.kt @@ -29,13 +29,10 @@ fun SignUpRoute( viewModel.sideEffects.collect { effect -> when (effect) { is SignUpContract.SignUpSideEffect.NavigateToTimeReminder -> navigateToHome() - is SignUpContract.SignUpSideEffect.ShowMessage -> { - // TODO: Snackbar나 Dialog로 에러 메시지 처리 - } - is SignUpContract.SignUpSideEffect.NavigateToWebView -> { navigateToWebView(effect.url) // ✅ WebView 이동 처리 } + is SignUpContract.SignUpSideEffect.ShowMessage -> {} } } } @@ -83,7 +80,9 @@ fun SignUpScreen( NickNamePage( nickname = state.nickname, onNicknameChange = { onIntent(SignUpContract.SignUpIntent.SetNickname(it)) }, - onCompleteClick = { onIntent(SignUpContract.SignUpIntent.CompleteSignUp(context)) }, + onCompleteClick = { + onIntent(SignUpContract.SignUpIntent.CompleteSignUp(context)) + }, onBackClick = { onIntent(SignUpContract.SignUpIntent.BackToTerms) }, isLoading = state.isLoading, isValidNickname = state.isValidNickname, diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/SignUpViewModel.kt b/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/SignUpViewModel.kt index 0909bcd7..f96389c0 100644 --- a/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/SignUpViewModel.kt +++ b/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/SignUpViewModel.kt @@ -8,7 +8,10 @@ import com.airbnb.mvrx.hilt.hiltMavericksViewModelFactory import com.airbnb.mvrx.withState import com.sopt.clody.core.fcm.FcmTokenProvider import com.sopt.clody.core.login.LoginSdk +import com.sopt.clody.data.datastore.OAuthDataStore +import com.sopt.clody.data.datastore.OAuthProvider import com.sopt.clody.data.remote.dto.request.SignUpRequestDto +import com.sopt.clody.data.remote.dto.response.SignUpResponseDto import com.sopt.clody.data.remote.util.NetworkUtil import com.sopt.clody.domain.repository.AuthRepository import com.sopt.clody.domain.repository.TokenRepository @@ -31,6 +34,7 @@ class SignUpViewModel @AssistedInject constructor( private val authRepository: AuthRepository, private val tokenRepository: TokenRepository, private val fcmTokenProvider: FcmTokenProvider, + private val oAuthDataStore: OAuthDataStore, private val networkUtil: NetworkUtil, private val languageProvider: LanguageProvider, ) : MavericksViewModel(initialState) { @@ -139,39 +143,67 @@ class SignUpViewModel @AssistedInject constructor( } } - private fun signUp(context: Context) { - viewModelScope.launch { - val state = withState(this@SignUpViewModel) { it } - if (!networkUtil.isNetworkAvailable()) { - setState { copy(errorMessage = "네트워크 연결을 확인해주세요.") } - return@launch + private suspend fun signUp(context: Context) { + val state = withState(this@SignUpViewModel) { it } + + if (!networkUtil.isNetworkAvailable()) { + setState { copy(errorMessage = "네트워크 연결을 확인해주세요.") } + return + } + + setState { copy(isLoading = true) } + + val platform = oAuthDataStore.getPlatform() + val fcmToken = fcmTokenProvider.getToken().orEmpty() + + if (platform == OAuthProvider.GOOGLE) { + val idToken = oAuthDataStore.getIdToken() + if (idToken.isNullOrBlank()) { + setState { copy(errorMessage = "Google ID Token이 없습니다.", isLoading = false) } + return } - setState { copy(isLoading = true) } + val request = SignUpRequestDto( + platform = OAuthProvider.GOOGLE.apiValue, + name = state.nickname, + fcmToken = fcmToken, + ) + + val result = authRepository.signUp("Bearer $idToken", request) + handleSignUpResult(result, isGoogle = true) + } else { loginSdk.login(context).fold( - onSuccess = { accessToken -> - launch { - val fcm = fcmTokenProvider.getToken().orEmpty() - val req = SignUpRequestDto("kakao", state.nickname, fcm) - authRepository.signUp("Bearer ${accessToken.value}", req).fold( - onSuccess = { - tokenRepository.setTokens(it.accessToken, it.refreshToken) - _sideEffects.send(SignUpContract.SignUpSideEffect.NavigateToTimeReminder) - }, - onFailure = { - setState { copy(errorMessage = it.message ?: "알 수 없는 오류") } - }, - ) - - setState { copy(isLoading = false) } - } + onSuccess = { token -> + val request = SignUpRequestDto( + platform = OAuthProvider.KAKAO.apiValue, + name = state.nickname, + fcmToken = fcmToken, + ) + val result = authRepository.signUp("Bearer ${token.value}", request) + handleSignUpResult(result, isGoogle = false) }, onFailure = { - setState { copy(errorMessage = it.message ?: "로그인 실패", isLoading = false) } + setState { copy(errorMessage = "로그인에 실패했어요~", isLoading = false) } }, ) } } + private suspend fun handleSignUpResult( + result: Result, + isGoogle: Boolean, + ) { + result.fold( + onSuccess = { + tokenRepository.setTokens(it.accessToken, it.refreshToken) + if (isGoogle) oAuthDataStore.clear() + _sideEffects.send(SignUpContract.SignUpSideEffect.NavigateToTimeReminder) + }, + onFailure = { + setState { copy(errorMessage = "회원가입에 실패했어요~") } + }, + ) + setState { copy(isLoading = false) } + } private fun validateNickname(nickname: String): Boolean { val state = withState(this@SignUpViewModel) { it } setState { copy(nicknameMaxLength = languageProvider.getNicknameMaxLength()) } diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/login/GoogleSignInHelper.kt b/app/src/main/java/com/sopt/clody/presentation/ui/login/GoogleSignInHelper.kt new file mode 100644 index 00000000..67dc80f1 --- /dev/null +++ b/app/src/main/java/com/sopt/clody/presentation/ui/login/GoogleSignInHelper.kt @@ -0,0 +1,49 @@ +package com.sopt.clody.presentation.ui.login + +import android.content.Context +import androidx.activity.result.IntentSenderRequest +import com.google.android.gms.auth.api.identity.BeginSignInRequest +import com.google.android.gms.auth.api.identity.Identity +import com.google.android.gms.auth.api.identity.SignInClient +import com.sopt.clody.BuildConfig + +class GoogleSignInHelper(context: Context) { + + private val signInClient: SignInClient = Identity.getSignInClient(context.applicationContext) + + fun buildSignInRequest(): BeginSignInRequest { + return BeginSignInRequest.Builder() + .setGoogleIdTokenRequestOptions( + BeginSignInRequest.GoogleIdTokenRequestOptions.builder() + .setSupported(true) + .setServerClientId(BuildConfig.GOOGLE_AUTH_WEB_CLIENT_ID) + .setFilterByAuthorizedAccounts(false) + .build(), + ) + .setAutoSelectEnabled(false) + .build() + } + + fun requestSignIn( + onSuccess: (IntentSenderRequest) -> Unit, + onFailure: (Exception) -> Unit, + ) { + val request = buildSignInRequest() + signInClient.beginSignIn(request) + .addOnSuccessListener { result -> + val intentSenderRequest = + IntentSenderRequest.Builder(result.pendingIntent.intentSender).build() + onSuccess(intentSenderRequest) + } + .addOnFailureListener { e -> + onFailure(e) + } + } + + fun extractIdToken(data: android.content.Intent?): String? { + return runCatching { + val credential = signInClient.getSignInCredentialFromIntent(data) + credential.googleIdToken + }.getOrNull() + } +} diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/login/LoginContract.kt b/app/src/main/java/com/sopt/clody/presentation/ui/login/LoginContract.kt index 1a9da0a9..9daaa695 100644 --- a/app/src/main/java/com/sopt/clody/presentation/ui/login/LoginContract.kt +++ b/app/src/main/java/com/sopt/clody/presentation/ui/login/LoginContract.kt @@ -2,19 +2,18 @@ package com.sopt.clody.presentation.ui.login import android.content.Context import com.airbnb.mvrx.MavericksState +import com.sopt.clody.data.datastore.OAuthProvider class LoginContract { data class LoginState( - val loginType: LoginType = LoginType.GOOGLE, + val loginType: OAuthProvider? = null, val isLoading: Boolean = false, val errorMessage: String? = null, ) : MavericksState sealed class LoginIntent { - data object SetLoginType : LoginIntent() - data class LoginWithKakao(val context: Context) : LoginIntent() - data class LoginWithGoogle(val context: Context) : LoginIntent() + data class LoginOAuth(val provider: OAuthProvider, val context: Context? = null, val idToken: String? = null) : LoginIntent() data object ClearError : LoginIntent() } diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/login/LoginScreen.kt b/app/src/main/java/com/sopt/clody/presentation/ui/login/LoginScreen.kt index 249a873c..476e81be 100644 --- a/app/src/main/java/com/sopt/clody/presentation/ui/login/LoginScreen.kt +++ b/app/src/main/java/com/sopt/clody/presentation/ui/login/LoginScreen.kt @@ -1,5 +1,8 @@ package com.sopt.clody.presentation.ui.login +import android.app.Activity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column @@ -13,6 +16,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -24,10 +28,11 @@ import com.airbnb.mvrx.compose.collectAsState import com.airbnb.mvrx.compose.mavericksViewModel import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.sopt.clody.R +import com.sopt.clody.data.datastore.OAuthProvider import com.sopt.clody.presentation.ui.auth.component.button.GoogleButton import com.sopt.clody.presentation.ui.auth.component.button.KaKaoButton -import com.sopt.clody.presentation.ui.component.LoadingScreen import com.sopt.clody.presentation.ui.component.dialog.FailureDialog +import com.sopt.clody.presentation.ui.login.LoginContract.LoginIntent import com.sopt.clody.presentation.utils.base.BasePreview import com.sopt.clody.presentation.utils.base.ClodyPreview import com.sopt.clody.presentation.utils.extension.heightForScreenPercentage @@ -44,6 +49,18 @@ fun LoginRoute( val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current + val googleSignInHelper = remember { GoogleSignInHelper(context) } + + val googleSignInLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartIntentSenderForResult(), + ) { result -> + + if (result.resultCode == Activity.RESULT_OK) { + val idToken = googleSignInHelper.extractIdToken(result.data) + viewModel.postIntent(LoginIntent.LoginOAuth(OAuthProvider.GOOGLE, idToken = idToken)) + } + } + LaunchedEffect(viewModel) { lifecycleOwner.repeatOnStarted { viewModel.sideEffects.collect { sideEffect -> @@ -60,8 +77,17 @@ fun LoginRoute( LoginScreen( state = state, - onKaKaoLoginClick = { viewModel.postIntent(LoginContract.LoginIntent.LoginWithKakao(context)) }, - onGoogleLoginClick = { viewModel.postIntent(LoginContract.LoginIntent.LoginWithGoogle(context)) }, + onKaKaoLoginClick = { + viewModel.postIntent(LoginIntent.LoginOAuth(OAuthProvider.KAKAO, context = context)) + }, + onGoogleLoginClick = { + googleSignInHelper.requestSignIn( + onSuccess = { intentSenderRequest -> googleSignInLauncher.launch(intentSenderRequest) }, + onFailure = { + viewModel.postIntent(LoginIntent.ClearError) + }, + ) + }, ) // 에러 메시지 추가로 다이얼로그로 처리하고 싶다면? @@ -90,7 +116,7 @@ fun LoginScreen( Scaffold( bottomBar = { - if (state.loginType == LoginType.KAKAO) { + if (state.loginType == OAuthProvider.KAKAO) { KaKaoButton( text = stringResource(id = R.string.signup_btn_kakao), onClick = onKaKaoLoginClick, @@ -128,10 +154,6 @@ fun LoginScreen( ) } } - - if (state.isLoading) { - LoadingScreen() - } } @ClodyPreview @@ -139,7 +161,11 @@ fun LoginScreen( fun LoginScreenPreview() { BasePreview { LoginScreen( - state = LoginContract.LoginState(), + state = LoginContract.LoginState( + isLoading = false, + loginType = OAuthProvider.KAKAO, + errorMessage = null, + ), onKaKaoLoginClick = {}, onGoogleLoginClick = {}, ) diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/login/LoginViewModel.kt b/app/src/main/java/com/sopt/clody/presentation/ui/login/LoginViewModel.kt index 32ff7083..e5d53217 100644 --- a/app/src/main/java/com/sopt/clody/presentation/ui/login/LoginViewModel.kt +++ b/app/src/main/java/com/sopt/clody/presentation/ui/login/LoginViewModel.kt @@ -1,15 +1,18 @@ package com.sopt.clody.presentation.ui.login -import android.content.Context import com.airbnb.mvrx.MavericksViewModel import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.hilt.AssistedViewModelFactory import com.airbnb.mvrx.hilt.hiltMavericksViewModelFactory import com.sopt.clody.core.fcm.FcmTokenProvider import com.sopt.clody.core.login.LoginSdk +import com.sopt.clody.data.datastore.OAuthDataStore +import com.sopt.clody.data.datastore.OAuthProvider +import com.sopt.clody.data.remote.dto.request.GoogleSignUpRequestDto import com.sopt.clody.data.remote.dto.request.LoginRequestDto import com.sopt.clody.domain.repository.AuthRepository import com.sopt.clody.domain.repository.TokenRepository +import com.sopt.clody.presentation.ui.login.LoginContract.LoginIntent import com.sopt.clody.presentation.utils.language.LanguageProvider import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -27,78 +30,111 @@ class LoginViewModel @AssistedInject constructor( private val authRepository: AuthRepository, private val tokenRepository: TokenRepository, private val fcmTokenProvider: FcmTokenProvider, + private val oauthDataStore: OAuthDataStore, private val languageProvider: LanguageProvider, ) : MavericksViewModel(initialState) { - private val _intents = Channel(BUFFERED) + private val _intents = Channel(BUFFERED) private val _sideEffects = Channel(BUFFERED) val sideEffects = _sideEffects.receiveAsFlow() init { + setState { copy(loginType = languageProvider.getLoginType()) } _intents .receiveAsFlow() .onEach(::handleIntent) .launchIn(viewModelScope) - postIntent(LoginContract.LoginIntent.SetLoginType) } - fun postIntent(intent: LoginContract.LoginIntent) { + fun postIntent(intent: LoginIntent) { viewModelScope.launch { _intents.send(intent) } } - private suspend fun handleIntent(intent: LoginContract.LoginIntent) { + private suspend fun handleIntent(intent: LoginIntent) { when (intent) { - is LoginContract.LoginIntent.SetLoginType -> { setState { copy(loginType = languageProvider.getLoginType()) } } - is LoginContract.LoginIntent.LoginWithKakao -> loginWithKakao(intent.context) - is LoginContract.LoginIntent.LoginWithGoogle -> loginWithGoogle(intent.context) - is LoginContract.LoginIntent.ClearError -> setState { copy(errorMessage = null) } + is LoginIntent.ClearError -> setState { copy(errorMessage = null) } + is LoginIntent.LoginOAuth -> handleLoginOAuth(intent) } } - private suspend fun loginWithKakao(context: Context) { + private suspend fun handleLoginOAuth(intent: LoginIntent.LoginOAuth) { setState { copy(isLoading = true, errorMessage = null) } - loginSdk.login(context).fold( - onSuccess = { accessToken -> - validateUser("Bearer ${accessToken.value}") + when (intent.provider) { + OAuthProvider.KAKAO -> { + loginSdk.login(intent.context!!).fold( + onSuccess = { token -> + validateKakaoUser(token.value) + }, + onFailure = { error -> + setState { copy(isLoading = false) } + _sideEffects.send(LoginContract.LoginSideEffect.ShowError("로그인에 실패했습니다")) + }, + ) + } + + OAuthProvider.GOOGLE -> { + val idToken = intent.idToken + if (idToken.isNullOrBlank()) { + _sideEffects.send(LoginContract.LoginSideEffect.ShowError("로그인에 실패했습니다.")) + return + } + + validateGoogleUser(idToken) + } + } + } + + private suspend fun validateKakaoUser(kakaoToken: String) { + val fcmToken = fcmTokenProvider.getToken().orEmpty() + val request = LoginRequestDto(platform = OAuthProvider.KAKAO.apiValue, fcmToken = fcmToken) + + authRepository.signIn("Bearer $kakaoToken", request).fold( + onSuccess = { + tokenRepository.setTokens(it.accessToken, it.refreshToken) + setState { copy(isLoading = false) } + _sideEffects.send(LoginContract.LoginSideEffect.NavigateToHome) }, onFailure = { error -> setState { copy(isLoading = false) } - _sideEffects.send( - LoginContract.LoginSideEffect.ShowError( - error.message ?: "로그인에 실패했습니다.", - ), - ) + val msg = error.message.orEmpty() + if (msg.contains("404") || msg.contains("유저가 없습니다")) { + _sideEffects.send(LoginContract.LoginSideEffect.NavigateToSignUp) + } else { + _sideEffects.send(LoginContract.LoginSideEffect.ShowError(msg)) + } }, ) } - private suspend fun validateUser(token: String) { + private suspend fun validateGoogleUser(idToken: String) { val fcmToken = fcmTokenProvider.getToken().orEmpty() - val request = LoginRequestDto(platform = "kakao", fcmToken = fcmToken) + val request = GoogleSignUpRequestDto( + idToken = idToken, + fcmToken = fcmToken, + ) - authRepository.signIn(token, request).fold( + authRepository.signUpWithGoogle(request).fold( onSuccess = { tokenRepository.setTokens(it.accessToken, it.refreshToken) setState { copy(isLoading = false) } _sideEffects.send(LoginContract.LoginSideEffect.NavigateToHome) }, onFailure = { error -> - val message = error.message.orEmpty() setState { copy(isLoading = false) } - if (message.contains("404") || message.contains("유저가 없습니다")) { + val msg = error.message.orEmpty() + if (msg.contains("500") || msg.contains("유저가 없습니다")) { + oauthDataStore.saveIdToken(idToken) + oauthDataStore.savePlatform(OAuthProvider.GOOGLE) _sideEffects.send(LoginContract.LoginSideEffect.NavigateToSignUp) } else { - _sideEffects.send(LoginContract.LoginSideEffect.ShowError("로그인 실패")) + _sideEffects.send(LoginContract.LoginSideEffect.ShowError(msg)) } }, ) } - private suspend fun loginWithGoogle(context: Context) { - } - @AssistedFactory interface Factory : AssistedViewModelFactory { override fun create(state: LoginContract.LoginState): LoginViewModel diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/setting/component/AccountManagementLogoutOption.kt b/app/src/main/java/com/sopt/clody/presentation/ui/setting/component/AccountManagementLogoutOption.kt index 927b01be..435d7fcf 100644 --- a/app/src/main/java/com/sopt/clody/presentation/ui/setting/component/AccountManagementLogoutOption.kt +++ b/app/src/main/java/com/sopt/clody/presentation/ui/setting/component/AccountManagementLogoutOption.kt @@ -16,21 +16,27 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.sopt.clody.R +import com.sopt.clody.data.datastore.OAuthProvider import com.sopt.clody.ui.theme.ClodyTheme @Composable fun AccountManagementLogoutOption( userEmail: String, + platform: OAuthProvider?, updateLogoutDialog: (Boolean) -> Unit, ) { + val platformIconRes = when (platform) { + OAuthProvider.KAKAO -> R.drawable.img_account_management_kakao + OAuthProvider.GOOGLE -> R.drawable.img_google_button_logo + else -> R.drawable.img_google_button_logo // 서버에서 google을 어떻게 내려줄까요? + } + Row( - modifier = Modifier - .padding(top = 12.dp, bottom = 24.dp, start = 22.dp, end = 24.dp), + modifier = Modifier.padding(top = 12.dp, bottom = 24.dp, start = 22.dp, end = 24.dp), ) { Image( - painter = painterResource(id = R.drawable.img_account_management_kakao), - modifier = Modifier - .size(24.dp), + painter = painterResource(id = platformIconRes), + modifier = Modifier.size(24.dp), contentDescription = null, ) Spacer(modifier = Modifier.width(10.dp)) diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/setting/screen/AccountManagementScreen.kt b/app/src/main/java/com/sopt/clody/presentation/ui/setting/screen/AccountManagementScreen.kt index 168dbfbb..70e258d4 100644 --- a/app/src/main/java/com/sopt/clody/presentation/ui/setting/screen/AccountManagementScreen.kt +++ b/app/src/main/java/com/sopt/clody/presentation/ui/setting/screen/AccountManagementScreen.kt @@ -135,12 +135,11 @@ fun AccountManagementScreen( updateNicknameChangeBottomSheet = updateNicknameChangeBottomSheet, ) - if (userInfo.platform == "kakao") { - AccountManagementLogoutOption( - userEmail = userInfo.email, - updateLogoutDialog = updateLogoutDialog, - ) - } + AccountManagementLogoutOption( + userEmail = userInfo.email, + platform = userInfo.platform, + updateLogoutDialog = updateLogoutDialog, + ) SettingSeparateLine() diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/setting/screen/AccountManagementViewModel.kt b/app/src/main/java/com/sopt/clody/presentation/ui/setting/screen/AccountManagementViewModel.kt index 26efca67..95f0afde 100644 --- a/app/src/main/java/com/sopt/clody/presentation/ui/setting/screen/AccountManagementViewModel.kt +++ b/app/src/main/java/com/sopt/clody/presentation/ui/setting/screen/AccountManagementViewModel.kt @@ -67,8 +67,9 @@ class AccountManagementViewModel @Inject constructor( val result = accountManagementRepository.getUserInfo() _userInfoState.value = result.fold( onSuccess = { + val platformEnum = it.platform retryCount = 0 - UserInfoState.Success(it) + UserInfoState.Success(it.copy(platform = platformEnum)) }, onFailure = { retryCount++ diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashContract.kt b/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashContract.kt index c120e0c0..ae19ecff 100644 --- a/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashContract.kt +++ b/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashContract.kt @@ -6,7 +6,6 @@ import com.sopt.clody.domain.model.AppUpdateState class SplashContract { data class SplashState( - val isUserLoggedIn: Boolean? = null, val updateState: AppUpdateState? = null, val showInspectionDialog: Boolean = false, val inspectionTimeText: String? = null, diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashViewModel.kt b/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashViewModel.kt index 5d3deac1..db366f59 100644 --- a/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashViewModel.kt +++ b/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashViewModel.kt @@ -4,7 +4,6 @@ import com.airbnb.mvrx.MavericksViewModel import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.hilt.AssistedViewModelFactory import com.airbnb.mvrx.hilt.hiltMavericksViewModelFactory -import com.airbnb.mvrx.withState import com.sopt.clody.BuildConfig import com.sopt.clody.domain.appupdate.AppUpdateChecker import com.sopt.clody.domain.model.AppUpdateState @@ -58,8 +57,9 @@ class SplashViewModel @AssistedInject constructor( AmplitudeUtils.trackEvent(AmplitudeConstraints.ALARM) } if (!BuildConfig.DEBUG && checkInspectionAndHandle()) return - checkVersionAndNavigate() - attemptAutoLogin() + + val isLoggedIn = attemptAutoLogin() + checkVersionAndNavigate(isLoggedIn) } private suspend fun checkInspectionAndHandle(): Boolean { @@ -76,21 +76,19 @@ class SplashViewModel @AssistedInject constructor( return false } - private fun attemptAutoLogin() { + private fun attemptAutoLogin(): Boolean { val isLoggedIn = tokenRepository.getAccessToken().isNotBlank() && tokenRepository.getRefreshToken().isNotBlank() - setState { copy(isUserLoggedIn = isLoggedIn) } + return isLoggedIn } - private suspend fun checkVersionAndNavigate() { + private suspend fun checkVersionAndNavigate(isLoggedIn: Boolean) { val updateState = appUpdateChecker.getAppUpdateState(BuildConfig.VERSION_NAME) setState { copy(updateState = updateState) } if (updateState == AppUpdateState.Latest) { delay(1000) - val isLoggedIn = withState(this@SplashViewModel) { it.isUserLoggedIn } - - if (isLoggedIn == true) { + if (isLoggedIn) { _sideEffects.send(SplashContract.SplashSideEffect.NavigateToHome) } else { _sideEffects.send(SplashContract.SplashSideEffect.NavigateToLogin) diff --git a/app/src/main/java/com/sopt/clody/presentation/utils/language/LanguageProvider.kt b/app/src/main/java/com/sopt/clody/presentation/utils/language/LanguageProvider.kt index 1ca49f26..4c697c74 100644 --- a/app/src/main/java/com/sopt/clody/presentation/utils/language/LanguageProvider.kt +++ b/app/src/main/java/com/sopt/clody/presentation/utils/language/LanguageProvider.kt @@ -1,10 +1,10 @@ package com.sopt.clody.presentation.utils.language -import com.sopt.clody.presentation.ui.login.LoginType +import com.sopt.clody.data.datastore.OAuthProvider import com.sopt.clody.presentation.ui.setting.screen.SettingOptionUrls interface LanguageProvider { - fun getLoginType(): LoginType + fun getLoginType(): OAuthProvider fun getNicknameMaxLength(): Int fun getDiaryMaxLength(): Int fun getWebViewUrlFor(option: SettingOptionUrls): String diff --git a/app/src/main/java/com/sopt/clody/presentation/utils/language/LanguageProviderImpl.kt b/app/src/main/java/com/sopt/clody/presentation/utils/language/LanguageProviderImpl.kt index 0a1976d2..6bf0749f 100644 --- a/app/src/main/java/com/sopt/clody/presentation/utils/language/LanguageProviderImpl.kt +++ b/app/src/main/java/com/sopt/clody/presentation/utils/language/LanguageProviderImpl.kt @@ -1,7 +1,7 @@ package com.sopt.clody.presentation.utils.language import android.content.Context -import com.sopt.clody.presentation.ui.login.LoginType +import com.sopt.clody.data.datastore.OAuthProvider import com.sopt.clody.presentation.ui.setting.screen.SettingOptionUrls import dagger.hilt.android.qualifiers.ApplicationContext import java.util.Locale @@ -15,8 +15,11 @@ class LanguageProviderImpl @Inject constructor( private fun isKorean(): Boolean = locale.language == LANGUAGE_KO - override fun getLoginType(): LoginType { - return if (isKorean()) LoginType.KAKAO else LoginType.GOOGLE + override fun getLoginType(): OAuthProvider { + return when (locale.language) { + LANGUAGE_KO -> OAuthProvider.KAKAO + else -> OAuthProvider.GOOGLE + } } override fun getNicknameMaxLength(): Int { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1e3c001a..a95a7d14 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -53,6 +53,10 @@ mockk = "1.13.10" play-review = "2.0.2" +androidxCredentials = "1.5.0" +googleAuth = "21.3.0" +datastore = "1.1.7" + [libraries] # AndroidX Core core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } @@ -136,6 +140,10 @@ mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } play-review = { group = "com.google.android.play", name = "review", version.ref = "play-review"} play-review-ktx = { group = "com.google.android.play", name = "review-ktx", version.ref = "play-review"} +androidx-credentials-play-services-auth = { module = "androidx.credentials:credentials-play-services-auth", version.ref = "androidxCredentials" } +google-auth = { module = "com.google.android.gms:play-services-auth", version.ref = "googleAuth" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }