From 9fb49ee5e3b6399c338c913b2c9d52c390d337c5 Mon Sep 17 00:00:00 2001 From: StopStone Date: Sun, 19 Oct 2025 23:08:15 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=8B=9C=20Activity=20Context?= =?UTF-8?q?=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 카카오 로그인과 같이 Activity Context가 필요한 기능을 위해 `ActivityContextProvider`를 도입했습니다. - Hilt를 사용하여 `ActivityContextProvider`의 구현체인 `ActivityContextProviderImpl`을 싱글톤으로 주입하도록 설정했습니다. - `MainActivity`의 생명주기에 맞춰 `onCreate`에서 Context를 설정하고 `onDestroy`에서 해제하도록 변경했습니다. - `KakaoDataSource`에서 카카오 로그인 시 `Application Context` 대신 `ActivityContextProvider`를 통해 `Activity Context`를 우선적으로 사용하도록 수정했습니다. --- .../near/data/datasource/KakaoDataSource.kt | 24 +++++++------- .../near/data/di/ContextProviderModule.kt | 22 +++++++++++++ .../data/provider/ActivityContextProvider.kt | 16 +++++++++ .../presentation/feature/main/MainActivity.kt | 15 +++++++++ .../provider/ActivityContextProviderImpl.kt | 33 +++++++++++++++++++ 5 files changed, 98 insertions(+), 12 deletions(-) create mode 100644 Near/app/src/main/java/com/alarmy/near/data/di/ContextProviderModule.kt create mode 100644 Near/app/src/main/java/com/alarmy/near/data/provider/ActivityContextProvider.kt create mode 100644 Near/app/src/main/java/com/alarmy/near/presentation/provider/ActivityContextProviderImpl.kt diff --git a/Near/app/src/main/java/com/alarmy/near/data/datasource/KakaoDataSource.kt b/Near/app/src/main/java/com/alarmy/near/data/datasource/KakaoDataSource.kt index 17005d8c..c5bb01f7 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/datasource/KakaoDataSource.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/datasource/KakaoDataSource.kt @@ -1,6 +1,7 @@ package com.alarmy.near.data.datasource import android.content.Context +import com.alarmy.near.data.provider.ActivityContextProvider import com.alarmy.near.model.ProviderType import com.kakao.sdk.auth.model.OAuthToken import com.kakao.sdk.common.model.ClientError @@ -21,17 +22,22 @@ import kotlin.coroutines.resume class KakaoDataSource @Inject constructor( - @ApplicationContext private val context: Context, + @ApplicationContext private val applicationContext: Context, + private val activityContextProvider: ActivityContextProvider, ) : SocialLoginDataSource { override val supportedType: ProviderType = ProviderType.KAKAO override suspend fun login(): Result = try { + // Activity Context 우선 사용, 없으면 Application Context 사용 + val activityContext = activityContextProvider.getActivityContext() + val context = activityContext ?: applicationContext + val token = if (UserApiClient.instance.isKakaoTalkLoginAvailable(context)) { - loginWithKakaoTalk() + loginWithKakaoTalk(context) } else { - loginWithKakaoAccount() + loginWithKakaoAccount(context) } if (token.isNotEmpty()) { @@ -43,18 +49,12 @@ class KakaoDataSource Result.failure(exception) } - private suspend fun loginWithKakaoTalk(): String = + private suspend fun loginWithKakaoTalk(context: Context): String = suspendCancellableCoroutine { continuation -> UserApiClient.instance.loginWithKakaoTalk(context) { token, error -> when { error != null -> { - if (error is ClientError && error.reason == ClientErrorCause.Cancelled) { - continuation.resume("") - } else { - UserApiClient.instance.loginWithKakaoAccount(context) { retryToken, retryError -> - handleLoginResult(retryToken, retryError, continuation) - } - } + continuation.resume("") } token != null -> continuation.resume(token.accessToken) else -> continuation.resume("") @@ -62,7 +62,7 @@ class KakaoDataSource } } - private suspend fun loginWithKakaoAccount(): String = + private suspend fun loginWithKakaoAccount(context: Context): String = suspendCancellableCoroutine { continuation -> UserApiClient.instance.loginWithKakaoAccount(context) { token, error -> handleLoginResult(token, error, continuation) diff --git a/Near/app/src/main/java/com/alarmy/near/data/di/ContextProviderModule.kt b/Near/app/src/main/java/com/alarmy/near/data/di/ContextProviderModule.kt new file mode 100644 index 00000000..70eef0bc --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/di/ContextProviderModule.kt @@ -0,0 +1,22 @@ +package com.alarmy.near.data.di + +import com.alarmy.near.data.provider.ActivityContextProvider +import com.alarmy.near.presentation.provider.ActivityContextProviderImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * Context Provider 모듈 + * Activity Context 제공을 위한 의존성 주입 설정 + */ +@Module +@InstallIn(SingletonComponent::class) +interface ContextProviderModule { + @Binds + @Singleton + fun bindActivityContextProvider(impl: ActivityContextProviderImpl): ActivityContextProvider +} + diff --git a/Near/app/src/main/java/com/alarmy/near/data/provider/ActivityContextProvider.kt b/Near/app/src/main/java/com/alarmy/near/data/provider/ActivityContextProvider.kt new file mode 100644 index 00000000..67d1bcc6 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/provider/ActivityContextProvider.kt @@ -0,0 +1,16 @@ +package com.alarmy.near.data.provider + +import android.content.Context + +/** + * Activity Context 제공 인터페이스 + * 카카오 로그인 등 Activity Context가 필요한 경우에 사용 + */ +interface ActivityContextProvider { + /** + * 현재 Activity의 Context를 반환 + * Activity Context, 없으면 null + */ + fun getActivityContext(): Context? +} + diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/MainActivity.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/MainActivity.kt index c79559a9..a507aeab 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/MainActivity.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/MainActivity.kt @@ -10,18 +10,27 @@ import androidx.core.splashscreen.SplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope +import com.alarmy.near.presentation.provider.ActivityContextProviderImpl import com.alarmy.near.presentation.ui.theme.NearTheme import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch +import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { private val mainViewModel: MainViewModel by viewModels() + @Inject + lateinit var activityContextProvider: ActivityContextProviderImpl + override fun onCreate(savedInstanceState: Bundle?) { val splashScreen = installSplashScreen() super.onCreate(savedInstanceState) + + // Activity Context 설정 + activityContextProvider.setActivityContext(this) + enableEdgeToEdge() setupSplashScreen(splashScreen) @@ -37,6 +46,12 @@ class MainActivity : ComponentActivity() { } } + override fun onDestroy() { + super.onDestroy() + // Activity Context 제거 + activityContextProvider.setActivityContext(null) + } + /** * 스플래시 스크린을 설정하고 MainViewModel의 상태를 관찰합니다. * API 스플래시가 표시되는 동안 백그라운드에서 검증을 수행합니다. diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/provider/ActivityContextProviderImpl.kt b/Near/app/src/main/java/com/alarmy/near/presentation/provider/ActivityContextProviderImpl.kt new file mode 100644 index 00000000..51fbc518 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/provider/ActivityContextProviderImpl.kt @@ -0,0 +1,33 @@ +package com.alarmy.near.presentation.provider + +import android.content.Context +import com.alarmy.near.data.provider.ActivityContextProvider +import java.lang.ref.WeakReference +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Activity Context 제공 구현체 + * MainActivity에서 직접 Context를 설정하는 방식 + */ +@Singleton +class ActivityContextProviderImpl + @Inject + constructor() : ActivityContextProvider { + private var activityContextRef: WeakReference? = null + + /** + * Activity Context 설정 + * MainActivity의 onCreate/onDestroy에서 호출됨 + */ + fun setActivityContext(context: Context?) { + activityContextRef = + if (context != null) { + WeakReference(context) + } else { + null + } + } + + override fun getActivityContext(): Context? = activityContextRef?.get() + } From 07e5e78d29faac637b5eea6537f92fd260b39c78 Mon Sep 17 00:00:00 2001 From: StopStone Date: Sun, 19 Oct 2025 23:18:26 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor:=20=EC=86=8C=EC=85=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=ED=9B=84=20=EA=B0=9C=EC=9D=B8=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=EC=B2=98=EB=A6=AC=EB=B0=A9=EC=B9=A8=20=EB=8F=99?= =?UTF-8?q?=EC=9D=98=20=EC=8B=9C=EC=A0=90=EC=97=90=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=A0=84=EC=86=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존에 소셜 로그인 성공 즉시 서버로 토큰을 전송하던 로직을 변경했습니다. - 이제 소셜 로그인 플랫폼에서 액세스 토큰을 먼저 받아온 후, 사용자가 개인정보처리방침에 동의하는 시점에 해당 토큰을 서버로 전송하여 최종 로그인을 완료하도록 수정했습니다. --- .../near/data/repository/AuthRepository.kt | 2 +- .../data/repository/AuthRepositoryImpl.kt | 4 +-- .../feature/login/LoginViewModel.kt | 31 +++++++++++++++++-- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepository.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepository.kt index ded14dc6..6cb21843 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepository.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepository.kt @@ -5,7 +5,7 @@ import kotlinx.coroutines.flow.Flow interface AuthRepository { // 소셜 로그인 수행 - suspend fun performSocialLogin(providerType: ProviderType): Result + suspend fun performSocialLogin(providerType: ProviderType): Result /** * 소셜 로그인 수행 (토큰 직접 전달) diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt index 43f51f80..9f0d30ea 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt @@ -16,13 +16,13 @@ class AuthRepositoryImpl private val socialLoginProcessor: SocialLoginProcessor, private val tokenManager: TokenManager, ) : AuthRepository { - override suspend fun performSocialLogin(providerType: ProviderType): Result = + override suspend fun performSocialLogin(providerType: ProviderType): Result = try { val result = socialLoginProcessor.processLogin(providerType) if (result.isSuccess) { val accessToken = result.getOrThrow() - socialLogin(accessToken, providerType) + Result.success(accessToken) } else { Result.failure( createLoginException( diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt index 6df56eca..65859bf4 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt @@ -35,6 +35,10 @@ class LoginViewModel private val _loginState = MutableStateFlow(LoginState()) val loginState: StateFlow = _loginState.asStateFlow() + // 임시 토큰 저장 (개인정보 동의 전) + private var tempAccessToken: String? = null + private var tempProviderType: ProviderType? = null + // 개별 상태들을 편의를 위해 노출 val termsAgreementState: StateFlow = _loginState @@ -61,8 +65,12 @@ class LoginViewModel updateLoadingState(isLoading = true) authRepository .performSocialLogin(providerType) - .onSuccess { + .onSuccess { accessToken -> updateLoadingState(isLoading = false) + // 토큰을 ViewModel 내부에 임시 저장 + tempAccessToken = accessToken + tempProviderType = providerType + // 개인정보 동의 바텀시트 표시 _loginState.value = _loginState.value.copy(showPrivacyBottomSheet = true) }.onFailure { exception -> updateLoadingState(isLoading = false) @@ -76,8 +84,23 @@ class LoginViewModel */ fun onPrivacyConsentComplete() { viewModelScope.launch { - _loginState.value = _loginState.value.copy(showPrivacyBottomSheet = false) - _event.send(LoginEvent.NavigateToHome) + val accessToken = tempAccessToken + val providerType = tempProviderType + + updateLoadingState(isLoading = true) + authRepository + .socialLogin(accessToken!!, providerType!!) + .onSuccess { + updateLoadingState(isLoading = false) + _loginState.value = _loginState.value.copy(showPrivacyBottomSheet = false) + tempAccessToken = null + tempProviderType = null + _event.send(LoginEvent.NavigateToHome) + }.onFailure { exception -> + updateLoadingState(isLoading = false) + _loginState.value = _loginState.value.copy(showPrivacyBottomSheet = false) + _event.send(LoginEvent.ShowError(exception)) + } } } @@ -86,6 +109,8 @@ class LoginViewModel */ fun dismissPrivacyBottomSheet() { _loginState.value = _loginState.value.copy(showPrivacyBottomSheet = false) + tempAccessToken = null + tempProviderType = null } /** From 71dcf3a00f59abf49a14d66b57acf7c4a5786b2d Mon Sep 17 00:00:00 2001 From: StopStone Date: Sun, 19 Oct 2025 23:20:38 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=8B=9C=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=ED=95=B4=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../alarmy/near/data/datasource/KakaoDataSource.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Near/app/src/main/java/com/alarmy/near/data/datasource/KakaoDataSource.kt b/Near/app/src/main/java/com/alarmy/near/data/datasource/KakaoDataSource.kt index c5bb01f7..7dce69ba 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/datasource/KakaoDataSource.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/datasource/KakaoDataSource.kt @@ -29,6 +29,8 @@ class KakaoDataSource override suspend fun login(): Result = try { + unlinkKakaoAccount() + // Activity Context 우선 사용, 없으면 Application Context 사용 val activityContext = activityContextProvider.getActivityContext() val context = activityContext ?: applicationContext @@ -49,6 +51,16 @@ class KakaoDataSource Result.failure(exception) } + /** + * 카카오 계정 연결 끊기 + */ + private suspend fun unlinkKakaoAccount() = + suspendCancellableCoroutine { continuation -> + UserApiClient.instance.unlink { error -> + continuation.resume(Unit) + } + } + private suspend fun loginWithKakaoTalk(context: Context): String = suspendCancellableCoroutine { continuation -> UserApiClient.instance.loginWithKakaoTalk(context) { token, error -> From 2b33f906925edab4a8f7d493b91f58b482b148de Mon Sep 17 00:00:00 2001 From: StopStone Date: Mon, 20 Oct 2025 23:23:09 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EA=B3=BC=EC=A0=95=20=EC=A4=91=20=ED=94=84=EB=A1=9C=EC=84=B8?= =?UTF-8?q?=EC=8A=A4=20=EC=A2=85=EB=A3=8C=20=EC=8B=9C=20=EC=98=88=EC=99=B8?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 소셜 로그인 과정에서 프로세스 종료로 인해 `accessToken` 또는 `providerType` 정보가 유실되었을 경우, 에러 메시지를 표시하고 로그인 흐름이 중단되도록 예외 처리 로직을 추가했습니다. - 로그인 성공 시 임시로 저장했던 토큰 정보를 초기화하는 코드를 추가했습니다. --- .../near/presentation/feature/login/LoginViewModel.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt index 65859bf4..52f6c8f8 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt @@ -87,12 +87,20 @@ class LoginViewModel val accessToken = tempAccessToken val providerType = tempProviderType + // 프로세스 종료로 인한 데이터 유실 방어 + if (accessToken == null || providerType == null) { + _loginState.value = _loginState.value.copy(showPrivacyBottomSheet = false) + _event.send(LoginEvent.ShowError(Exception("로그인 정보가 유실되었습니다. 다시 시도해주세요."))) + return@launch + } + updateLoadingState(isLoading = true) authRepository - .socialLogin(accessToken!!, providerType!!) + .socialLogin(accessToken, providerType) .onSuccess { updateLoadingState(isLoading = false) _loginState.value = _loginState.value.copy(showPrivacyBottomSheet = false) + // 임시 토큰 초기화 tempAccessToken = null tempProviderType = null _event.send(LoginEvent.NavigateToHome)