Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<String> =
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()) {
Expand All @@ -43,26 +51,30 @@ 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("")
}
}
}

private suspend fun loginWithKakaoAccount(): String =
private suspend fun loginWithKakaoAccount(context: Context): String =
suspendCancellableCoroutine { continuation ->
UserApiClient.instance.loginWithKakaoAccount(context) { token, error ->
handleLoginResult(token, error, continuation)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}

Original file line number Diff line number Diff line change
@@ -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?
}

Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import kotlinx.coroutines.flow.Flow

interface AuthRepository {
// 소셜 로그인 수행
suspend fun performSocialLogin(providerType: ProviderType): Result<Unit>
suspend fun performSocialLogin(providerType: ProviderType): Result<String>

/**
* 소셜 로그인 수행 (토큰 직접 전달)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ class AuthRepositoryImpl
private val socialLoginProcessor: SocialLoginProcessor,
private val tokenManager: TokenManager,
) : AuthRepository {
override suspend fun performSocialLogin(providerType: ProviderType): Result<Unit> =
override suspend fun performSocialLogin(providerType: ProviderType): Result<String> =
try {
val result = socialLoginProcessor.processLogin(providerType)

if (result.isSuccess) {
val accessToken = result.getOrThrow()
socialLogin(accessToken, providerType)
Result.success(accessToken)
} else {
Result.failure(
createLoginException(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ class LoginViewModel
private val _loginState = MutableStateFlow(LoginState())
val loginState: StateFlow<LoginState> = _loginState.asStateFlow()

// 임시 토큰 저장 (개인정보 동의 전)
private var tempAccessToken: String? = null
private var tempProviderType: ProviderType? = null

// 개별 상태들을 편의를 위해 노출
val termsAgreementState: StateFlow<TermsAgreementState> =
_loginState
Expand All @@ -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)
Expand All @@ -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!!)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

tempAccessTokentempProviderType!! 연산자를 사용하는 것은 잠재적인 NullPointerException 위험이 있습니다. 예를 들어, 안드로이드 시스템이 메모리 확보를 위해 앱 프로세스를 종료했다가 사용자가 다시 앱으로 돌아오면 ViewModel이 재생성되면서 이 변수들이 null이 될 수 있습니다. 이 경우 !! 연산자로 인해 앱이 비정상 종료됩니다.

이러한 상황을 방지하기 위해, socialLogin을 호출하기 전에 accessTokenproviderTypenull이 아닌지 확인하는 방어 코드를 추가하는 것이 좋습니다. null일 경우, 사용자에게 오류 메시지를 보여주고 다시 로그인을 시도하도록 안내하는 것이 안전합니다.

                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))
}
}
}

Expand All @@ -86,6 +109,8 @@ class LoginViewModel
*/
fun dismissPrivacyBottomSheet() {
_loginState.value = _loginState.value.copy(showPrivacyBottomSheet = false)
tempAccessToken = null
tempProviderType = null
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -37,6 +46,12 @@ class MainActivity : ComponentActivity() {
}
}

override fun onDestroy() {
super.onDestroy()
// Activity Context 제거
activityContextProvider.setActivityContext(null)
}

/**
* 스플래시 스크린을 설정하고 MainViewModel의 상태를 관찰합니다.
* API 스플래시가 표시되는 동안 백그라운드에서 검증을 수행합니다.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Context>? = null

/**
* Activity Context 설정
* MainActivity의 onCreate/onDestroy에서 호출됨
*/
fun setActivityContext(context: Context?) {
activityContextRef =
if (context != null) {
WeakReference(context)
} else {
null
}
}

override fun getActivityContext(): Context? = activityContextRef?.get()
}