diff --git a/.github/workflows/develop_PR_builder.yml b/.github/workflows/develop_PR_builder.yml index f7a9ee1b..1094dda6 100644 --- a/.github/workflows/develop_PR_builder.yml +++ b/.github/workflows/develop_PR_builder.yml @@ -43,11 +43,3 @@ jobs: BASE_URL: ${{ secrets.BASE_URL }} run: | echo base.url=\"$BASE_URL\" >> local.properties - - name: Notify Slack on Pull Request - uses: 8398a7/action-slack@v3 - with: - status: ${{ job.status }} - fields: repo,message,commit,author,action,eventName,ref,workflow,job,took,pullRequest - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - if: always() diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a39498f3..2c0b06a0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -31,6 +31,7 @@ android { 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()) + buildConfigField("String", "GOOGLE_CLIENT_SECRET", properties["GOOGLE_CLIENT_SECRET"].toString()) manifestPlaceholders["KAKAO_NATIVE_APP_KEY"] = properties["KAKAO_NATIVE_APP_KEY"].toString() } diff --git a/app/src/main/java/com/sopt/noostak/di/GoogleIdModule.kt b/app/src/main/java/com/sopt/noostak/di/GoogleIdModule.kt index 7ea0c188..d0a01345 100644 --- a/app/src/main/java/com/sopt/noostak/di/GoogleIdModule.kt +++ b/app/src/main/java/com/sopt/noostak/di/GoogleIdModule.kt @@ -17,4 +17,11 @@ object GoogleIdModule { fun provideGoogleClientId(): String { return BuildConfig.GOOGLE_CLIENT_ID } + + @Provides + @Singleton + @Named("GoogleClientSecret") + fun provideGoogleClientSecret(): String { + return BuildConfig.GOOGLE_CLIENT_SECRET + } } diff --git a/app/src/main/java/com/sopt/noostak/di/TokenInterceptor.kt b/app/src/main/java/com/sopt/noostak/di/TokenInterceptor.kt index 56b35764..fb9c4c4c 100644 --- a/app/src/main/java/com/sopt/noostak/di/TokenInterceptor.kt +++ b/app/src/main/java/com/sopt/noostak/di/TokenInterceptor.kt @@ -51,18 +51,18 @@ class TokenInterceptor @Inject constructor( val refreshToken = preferenceDatasource.refreshToken.first() return try { - val tokenResult = runBlocking(Dispatchers.IO) { + val tokenResult = withContext(Dispatchers.IO) { authService.postReissueToken(refreshToken) } - when (tokenResult.status == SUCCESS) { - true -> { - preferenceDatasource.updateAccessToken( - BEARER + tokenResult.result?.accessToken - ) - true - } - false -> false + if (tokenResult.status == SUCCESS && tokenResult.result != null) { + tokenResult.result?.let { result -> + preferenceDatasource.updateAccessToken(BEARER + result.accessToken) + preferenceDatasource.updateRefreshToken(BEARER + result.refreshToken) + } + true + } else { + false } } catch (e: Exception) { false @@ -103,7 +103,7 @@ class TokenInterceptor @Inject constructor( ).build() companion object { - const val SUCCESS = 200 + const val SUCCESS = 201 const val CODE_TOKEN_EXPIRE = 401 const val AUTHORIZATION = "Authorization" const val BEARER = "Bearer " diff --git a/data/src/main/java/com/sopt/data/dto/response/ResponsePostReissueTokenDto.kt b/data/src/main/java/com/sopt/data/dto/response/ResponsePostReissueTokenDto.kt index bd179d83..ad015267 100644 --- a/data/src/main/java/com/sopt/data/dto/response/ResponsePostReissueTokenDto.kt +++ b/data/src/main/java/com/sopt/data/dto/response/ResponsePostReissueTokenDto.kt @@ -7,5 +7,5 @@ import kotlinx.serialization.Serializable data class ResponsePostReissueTokenDto( @SerialName("accessToken") val accessToken: String, @SerialName("refreshToken") val refreshToken: String, - @SerialName("authType") val authType: String + @SerialName("authId") val authId: String ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ca6fbfd6..4259ebb5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -56,8 +56,8 @@ kakao = "2.20.1" # Google google-id = "1.1.1" play-services-auth = "21.1.0" - viewpager-indicator = "5.0" +credentials = "1.2.0-alpha02" # Hilt hilt = "2.52" @@ -164,6 +164,8 @@ kakao-user = { group = "com.kakao.sdk", name = "v2-user", version.ref = "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" } +credentials-core = { group = "androidx.credentials", name = "credentials", version.ref = "credentials" } +credentials-play-services-auth = { group = "androidx.credentials", name = "credentials-play-services-auth", version.ref = "credentials" } # Hilt hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } # Hilt Android 라이브러리 diff --git a/presentation/build.gradle.kts b/presentation/build.gradle.kts index 0ab4258f..59dfade1 100644 --- a/presentation/build.gradle.kts +++ b/presentation/build.gradle.kts @@ -116,4 +116,6 @@ dependencies { // Google implementation(libs.play.services.auth) implementation(libs.google.id) + implementation(libs.credentials.core) + implementation(libs.credentials.play.services.auth) } 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 5d8d1686..e4d2c1c9 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,7 +1,11 @@ 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.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -24,6 +28,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInAccount +import com.google.android.gms.common.api.ApiException import com.sopt.core.designsystem.component.dialog.NoostakDialog import com.sopt.core.designsystem.theme.NoostakAndroidTheme import com.sopt.core.designsystem.theme.NoostakTheme @@ -31,6 +38,9 @@ import com.sopt.core.extension.toast import com.sopt.core.type.DialogType import com.sopt.presentation.R import com.sopt.presentation.auth.component.LoginButton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch @Composable fun LoginRoute( @@ -40,6 +50,38 @@ fun LoginRoute( ) { val context = LocalContext.current val showDialog by loginViewModel.showDialog.collectAsStateWithLifecycle() + val googleSignInIntent by loginViewModel.googleSignInIntent.collectAsStateWithLifecycle() + + val googleSignInLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode != Activity.RESULT_OK) { + loginViewModel.showDialog(DialogType.NETWORK_LOGIN_GOOGLE_FAILURE, true) + return@rememberLauncherForActivityResult + } + + val task = GoogleSignIn.getSignedInAccountFromIntent(result.data) + CoroutineScope(Dispatchers.Main).launch { + try { + val account: GoogleSignInAccount = task.getResult(ApiException::class.java) + val authCode = account.serverAuthCode + if (!authCode.isNullOrBlank()) { + loginViewModel.exchangeAuthCodeForAccessToken(authCode) + } else { + loginViewModel.showDialog(DialogType.NETWORK_LOGIN_GOOGLE_FAILURE, true) + } + } catch (e: Exception) { + loginViewModel.showDialog(DialogType.NETWORK_LOGIN_GOOGLE_FAILURE, true) + } + } + loginViewModel.clearGoogleSignInIntent() + } + + LaunchedEffect(googleSignInIntent) { + googleSignInIntent?.let { intent -> + googleSignInLauncher.launch(intent) + } + } LaunchedEffect(loginViewModel.sideEffects) { loginViewModel.sideEffects.collect { sideEffect -> @@ -63,7 +105,10 @@ fun LoginRoute( loginViewModel.showDialog(dialogType, false) when (dialogType) { DialogType.NETWORK_LOGIN_KAKAO_FAILURE -> loginViewModel.kakaoLogin(context) - DialogType.NETWORK_LOGIN_GOOGLE_FAILURE -> loginViewModel.googleLogin(context) + DialogType.NETWORK_LOGIN_GOOGLE_FAILURE -> loginViewModel.prepareGoogleSignInIntent( + context + ) + else -> Unit } }, @@ -74,7 +119,7 @@ fun LoginRoute( LoginScreen( onKakaoLoginClick = { loginViewModel.kakaoLogin(context) }, - onGoogleLoginClick = { loginViewModel.googleLogin(context) } + onGoogleLoginClick = { loginViewModel.prepareGoogleSignInIntent(context) } ) } @@ -88,6 +133,7 @@ fun LoginScreen( Column( modifier = Modifier .fillMaxSize() + .background(NoostakTheme.colors.blue600) .statusBarsPadding() .navigationBarsPadding() .padding(dimensionResource(R.dimen.horizontal_padding)), @@ -122,8 +168,8 @@ private fun SocialLoginBottom( ) { Text( text = stringResource(R.string.tv_login_description), - color = NoostakTheme.colors.gray900, - style = NoostakTheme.typography.c3Regular, + color = NoostakTheme.colors.white, + style = NoostakTheme.typography.c3SemiBold, modifier = Modifier.padding(bottom = 8.dp) ) LoginButton( 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 1b944a55..38a436ed 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,13 +1,12 @@ package com.sopt.presentation.auth.login import android.content.Context +import android.content.Intent import androidx.annotation.StringRes -import androidx.credentials.Credential -import androidx.credentials.CredentialManager -import androidx.credentials.GetCredentialRequest import androidx.lifecycle.viewModelScope -import com.google.android.libraries.identity.googleid.GetGoogleIdOption -import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInClient +import com.google.android.gms.auth.api.signin.GoogleSignInOptions import com.kakao.sdk.auth.model.OAuthToken import com.kakao.sdk.common.model.ClientError import com.kakao.sdk.common.model.ClientErrorCause @@ -19,17 +18,23 @@ import com.sopt.domain.repository.UserInfoRepository import com.sopt.domain.usecase.PostSocialLoginUseCase import com.sopt.presentation.R import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import timber.log.Timber +import okhttp3.FormBody +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONObject import javax.inject.Inject import javax.inject.Named @HiltViewModel class LoginViewModel @Inject constructor( @Named("GoogleClientId") private val googleClientId: String, + @Named("GoogleClientSecret") private val googleClientSecret: String, private val userInfoRepository: UserInfoRepository, private val postSocialLoginUseCase: PostSocialLoginUseCase ) : BaseViewModel() { @@ -37,10 +42,26 @@ class LoginViewModel @Inject constructor( private val _showDialog = MutableStateFlow(Pair(DialogType.NETWORK_LOGIN_GOOGLE_FAILURE, false)) val showDialog: StateFlow> get() = _showDialog + private val _googleSignInIntent = MutableStateFlow(null) + val googleSignInIntent: StateFlow get() = _googleSignInIntent + fun showDialog(dialogType: DialogType, isVisible: Boolean) { _showDialog.update { it.copy(first = dialogType, second = isVisible) } } + fun prepareGoogleSignInIntent(context: Context) { + val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestEmail() + .requestServerAuthCode(googleClientId, true) + .build() + val client: GoogleSignInClient = GoogleSignIn.getClient(context, gso) + _googleSignInIntent.value = client.signInIntent + } + + fun clearGoogleSignInIntent() { + _googleSignInIntent.value = null + } + // Kakao Login fun kakaoLogin(context: Context) { val loginCallback: (OAuthToken?, Throwable?) -> Unit = this::handleKakaoLoginResult @@ -69,39 +90,45 @@ class LoginViewModel @Inject constructor( } } - // Google Login - fun googleLogin(context: Context) { - val credentialManager = CredentialManager.create(context) - - val googleIdOption = GetGoogleIdOption.Builder() - .setFilterByAuthorizedAccounts(false) - .setServerClientId(googleClientId) - .setAutoSelectEnabled(true) - .build() - - val request = GetCredentialRequest.Builder() - .addCredentialOption(googleIdOption) - .build() - - viewModelScope.launch { - runCatching { - val result = credentialManager.getCredential(context, request) - handleGoogleLoginResult(result.credential) - }.onFailure { exception -> - handleError(exception, R.string.toast_google_login_failed) + // Get Google AccessToken + fun exchangeAuthCodeForAccessToken(authCode: String) { + CoroutineScope(Dispatchers.IO).launch { + try { + val requestBody = FormBody.Builder() + .add("grant_type", "authorization_code") + .add("code", authCode) + .add("client_id", googleClientId) + .add("client_secret", googleClientSecret) + .add("redirect_uri", "") + .build() + + val request = Request.Builder() + .url("https://oauth2.googleapis.com/token") + .post(requestBody) + .build() + + val client = OkHttpClient() + val response = client.newCall(request).execute() + val jsonString = response.body?.string() + val json = JSONObject(jsonString ?: "") + + val accessToken = json.optString("access_token") + + if (accessToken.isNotBlank()) { + onGoogleAccessTokenReceived(accessToken) + } else { + showDialog(DialogType.NETWORK_LOGIN_GOOGLE_FAILURE, true) + } + } catch (e: Exception) { showDialog(DialogType.NETWORK_LOGIN_GOOGLE_FAILURE, true) } } } - private fun handleGoogleLoginResult(credential: Credential) { - if (credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) { - val googleIdTokenCredential = GoogleIdTokenCredential.createFrom(credential.data) - postSocialLogin(googleIdTokenCredential.id, GOOGLE) - showToast(R.string.toast_google_login_success) - } else { - showDialog(DialogType.NETWORK_LOGIN_GOOGLE_FAILURE, true) - } + // Google Login + private fun onGoogleAccessTokenReceived(accessToken: String) { + postSocialLogin(BEARER + accessToken, GOOGLE) + showToast(R.string.toast_google_login_success) } private fun handleError(error: Throwable?, @StringRes errorMessageResId: Int) { @@ -134,7 +161,6 @@ class LoginViewModel @Inject constructor( }, onFailure = { error -> emitSideEffect(LoginSideEffect.NavigateToOnboarding(accessToken, socialType)) - Timber.e("postSocialLogin Failed: ${error.message}") } ) } diff --git a/presentation/src/main/res/drawable/ic_login_logo.xml b/presentation/src/main/res/drawable/ic_login_logo.xml index 4479c566..374ebbba 100644 --- a/presentation/src/main/res/drawable/ic_login_logo.xml +++ b/presentation/src/main/res/drawable/ic_login_logo.xml @@ -1,9 +1,34 @@ + android:width="255dp" + android:height="79dp" + android:viewportWidth="255" + android:viewportHeight="79"> + android:pathData="M17.07,25.8C17.07,22.2 19.99,19.29 23.59,19.29H55.52C59.12,19.29 62.03,22.2 62.03,25.8V26.32C62.03,29.85 59.22,32.73 55.72,32.84C59.19,32.98 61.96,35.84 61.96,39.35V39.87C61.96,43.36 59.21,46.22 55.75,46.38C59.21,46.54 61.96,49.39 61.96,52.89V53.41C61.96,57.01 59.04,59.92 55.44,59.92H23.52C19.92,59.92 17,57.01 17,53.41V52.89C17,49.39 19.75,46.54 23.21,46.38C19.75,46.22 17,43.36 17,39.87V39.35C17,35.81 19.81,32.94 23.32,32.83C19.84,32.69 17.07,29.83 17.07,26.32V25.8Z" + android:fillColor="#ffffff" + android:fillType="evenOdd"/> + + + + + + + +