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..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 @@ -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,24 @@ 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 { + unlinkKakaoAccount() + + // 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 +51,22 @@ class KakaoDataSource Result.failure(exception) } - private suspend fun loginWithKakaoTalk(): String = + /** + * 카카오 계정 연결 끊기 + */ + 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 -> 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 +74,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/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..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 @@ -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,31 @@ class LoginViewModel */ fun onPrivacyConsentComplete() { viewModelScope.launch { - _loginState.value = _loginState.value.copy(showPrivacyBottomSheet = false) - _event.send(LoginEvent.NavigateToHome) + 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) + .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 +117,8 @@ class LoginViewModel */ fun dismissPrivacyBottomSheet() { _loginState.value = _loginState.value.copy(showPrivacyBottomSheet = false) + tempAccessToken = null + tempProviderType = null } /** 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() + }