Skip to content
Merged
7 changes: 0 additions & 7 deletions app/src/main/java/com/into/websoso/data/remote/api/AuthApi.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.into.websoso.data.remote.api

import com.into.websoso.data.remote.request.FCMTokenRequestDto
import com.into.websoso.data.remote.request.LogoutRequestDto
import com.into.websoso.data.remote.request.UserProfileRequestDto
import com.into.websoso.data.remote.request.WithdrawRequestDto
import com.into.websoso.data.remote.response.UserNicknameValidityResponseDto
Expand All @@ -24,12 +23,6 @@ interface AuthApi {
@Body userProfileRequestDto: UserProfileRequestDto,
)

@POST("auth/logout")
suspend fun logout(
@Header("Authorization") authorization: String,
@Body loginResponseDto: LogoutRequestDto,
)

@POST("auth/withdraw")
suspend fun withdraw(
@Header("Authorization") authorization: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package com.into.websoso.data.repository
import android.content.SharedPreferences
import com.into.websoso.data.remote.api.AuthApi
import com.into.websoso.data.remote.request.FCMTokenRequestDto
import com.into.websoso.data.remote.request.LogoutRequestDto
import com.into.websoso.data.remote.request.UserProfileRequestDto
import com.into.websoso.data.remote.request.WithdrawRequestDto
import javax.inject.Inject
Expand Down Expand Up @@ -44,21 +43,6 @@ class AuthRepository
)
}

suspend fun logout(deviceIdentifier: String) {
runCatching {
if (accessToken.isNotEmpty() && refreshToken.isNotEmpty()) {
authApi.logout(
"Bearer $accessToken",
LogoutRequestDto(refreshToken, deviceIdentifier),
)
}
}.onSuccess {
clearTokens()
}.onFailure {
it.printStackTrace()
}
}

suspend fun withdraw(reason: String) {
runCatching {
if (accessToken.isNotEmpty() && refreshToken.isNotEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
package com.into.websoso.ui.accountInfo

import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.into.websoso.data.repository.AuthRepository
import com.into.websoso.core.auth.AuthPlatform
import com.into.websoso.data.account.AccountRepository
import com.into.websoso.data.repository.PushMessageRepository
import com.into.websoso.data.repository.UserRepository
import com.into.websoso.ui.accountInfo.UiEffect.NavigateToLogin
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject

Expand All @@ -16,14 +23,14 @@ class AccountInfoViewModel
@Inject
constructor(
private val userRepository: UserRepository,
private val authRepository: AuthRepository,
private val pushMessageRepository: PushMessageRepository,
private val accountRepository: AccountRepository,
) : ViewModel() {
private val _userEmail: MutableLiveData<String> = MutableLiveData()
val userEmail: LiveData<String> get() = _userEmail
private val _userEmail: MutableStateFlow<String> = MutableStateFlow("")
val userEmail: StateFlow<String> get() = _userEmail.asStateFlow()

private val _isLogoutSuccess: MutableLiveData<Boolean> = MutableLiveData(false)
val isLogoutSuccess: LiveData<Boolean> get() = _isLogoutSuccess
private var _uiEffect = Channel<UiEffect>(Channel.BUFFERED)
val uiEffect: Flow<UiEffect> get() = _uiEffect.receiveAsFlow()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

ChannelSharedFlow + val 로 일관성 확보
SplashViewModel 과 동일한 패턴입니다. 이벤트 스트림이면 MutableSharedFlow 사용이 더 직관적이며 var 제거로 재할당 위험을 없앨 수 있습니다.

🤖 Prompt for AI Agents
In app/src/main/java/com/into/websoso/ui/accountInfo/AccountInfoViewModel.kt
around lines 32 to 34, replace the Channel-based event stream with a
MutableSharedFlow to align with the SplashViewModel pattern. Change the private
mutable property to a val MutableSharedFlow and expose it as a SharedFlow to
prevent reassignment risks and improve clarity for event streams.

init {
updateUserEmail()
Expand All @@ -34,23 +41,27 @@ class AccountInfoViewModel
runCatching {
userRepository.fetchUserInfoDetail()
}.onSuccess { userInfo ->
_userEmail.value = userInfo.email
_userEmail.update { userInfo.email }
}
}
}

fun logout() {
fun signOut(signOutToPlatform: suspend (platform: AuthPlatform) -> Unit) {
viewModelScope.launch {
runCatching {
signOutToPlatform(AuthPlatform.KAKAO)
val userDeviceIdentifier = userRepository.fetchUserDeviceIdentifier()
authRepository.logout(userDeviceIdentifier)
accountRepository.deleteToken(userDeviceIdentifier)
}.onSuccess {
_isLogoutSuccess.value = true
authRepository.updateIsAutoLogin(false)
pushMessageRepository.clearFCMToken()
_uiEffect.send(NavigateToLogin)
}.onFailure {
_isLogoutSuccess.value = false
_uiEffect.send(NavigateToLogin)
}
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot May 15, 2025

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

FCM 토큰 제거가 실패 경로에서 누락될 수 있습니다
pushMessageRepository.clearFCMToken()onSuccess 에만 위치해 있어, 서버 로그아웃 실패 시 FCM 토큰이 남아버립니다. 성공‧실패와 무관하게 실행하도록 리팩터링해 주세요.

- }.onSuccess {
-     pushMessageRepository.clearFCMToken()
-     _uiEffect.send(NavigateToLogin)
- }.onFailure {
-     _uiEffect.send(NavigateToLogin)
- }
+ }.onSuccess {
+     /* no-op */
+ }.onFailure {
+     /* 로그 등 추가 */
+ }.also {
+     // 무조건 실행
+     pushMessageRepository.clearFCMToken()
+     _uiEffect.send(NavigateToLogin)
+ }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fun signOut(signOutToPlatform: suspend (platform: AuthPlatform) -> Unit) {
viewModelScope.launch {
runCatching {
signOutToPlatform(AuthPlatform.KAKAO)
val userDeviceIdentifier = userRepository.fetchUserDeviceIdentifier()
authRepository.logout(userDeviceIdentifier)
accountRepository.deleteToken(userDeviceIdentifier)
}.onSuccess {
_isLogoutSuccess.value = true
authRepository.updateIsAutoLogin(false)
pushMessageRepository.clearFCMToken()
_uiEffect.send(NavigateToLogin)
}.onFailure {
_isLogoutSuccess.value = false
_uiEffect.send(NavigateToLogin)
}
fun signOut(signOutToPlatform: suspend (platform: AuthPlatform) -> Unit) {
viewModelScope.launch {
runCatching {
signOutToPlatform(AuthPlatform.KAKAO)
val userDeviceIdentifier = userRepository.fetchUserDeviceIdentifier()
accountRepository.deleteToken(userDeviceIdentifier)
}.onSuccess {
/* no-op */
}.onFailure {
/* 로그 등 추가 */
}.also {
// 무조건 실행
pushMessageRepository.clearFCMToken()
_uiEffect.send(NavigateToLogin)
}
}
}
🤖 Prompt for AI Agents
In app/src/main/java/com/into/websoso/ui/accountInfo/AccountInfoViewModel.kt
around lines 49 to 60, the call to pushMessageRepository.clearFCMToken() is only
inside the onSuccess block, so it is not executed if signOutToPlatform or token
deletion fails. Move pushMessageRepository.clearFCMToken() outside of the
onSuccess and onFailure blocks so it runs regardless of success or failure,
ensuring the FCM token is always cleared.

Copy link
Copy Markdown
Member Author

@s9hn s9hn May 15, 2025

Choose a reason for hiding this comment

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

@coderabbitai 로그아웃이 실패했는데 왜 토큰을 지워?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@s9hn 말씀하신 내용이 맞습니다. 로그아웃이 실패한 경우에는 사용자가 여전히 로그인 상태로 남아있어야 하므로 FCM 토큰을 지우는 것이 적절하지 않습니다. 원래 구현대로 성공 경로에서만 FCM 토큰을 지우는 것이 맞습니다. 제안 내용을 철회합니다.


✏️ Learnings added
Learnt from: s9hn
PR: Team-WSS/WSS-Android#687
File: app/src/main/java/com/into/websoso/ui/accountInfo/AccountInfoViewModel.kt:0-0
Timestamp: 2025-05-15T17:11:19.809Z
Learning: 로그아웃 성공 케이스에서만 FCM 토큰을 제거해야 합니다. 로그아웃 실패 시에는 사용자가 여전히 로그인 상태이므로 FCM 토큰을 유지해야 합니다.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

}
}
}

sealed interface UiEffect {
data object NavigateToLogin : UiEffect
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,45 +4,55 @@ import android.os.Bundle
import android.view.View
import androidx.fragment.app.activityViewModels
import com.into.websoso.R
import com.into.websoso.core.auth.AuthClient
import com.into.websoso.core.auth.AuthPlatform
import com.into.websoso.core.common.navigator.NavigatorProvider
import com.into.websoso.core.common.ui.base.BaseDialogFragment
import com.into.websoso.core.common.util.SingleEventHandler
import com.into.websoso.core.common.util.collectWithLifecycle
import com.into.websoso.databinding.DialogLogoutBinding
import com.into.websoso.ui.login.LoginActivity
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject

@AndroidEntryPoint
class LogoutDialogFragment : BaseDialogFragment<DialogLogoutBinding>(R.layout.dialog_logout) {
private val accountInfoViewModel: AccountInfoViewModel by activityViewModels()
private val singleEventHandler: SingleEventHandler by lazy { SingleEventHandler.from() }

@Inject
lateinit var authClient: Map<AuthPlatform, @JvmSuppressWildcards AuthClient>

@Inject
lateinit var websosoNavigator: NavigatorProvider

override fun onViewCreated(
view: View,
savedInstanceState: Bundle?,
) {
super.onViewCreated(view, savedInstanceState)

binding.lifecycleOwner = this
onCancelButtonClick()
onLogoutButtonClick()
setupObserver()
setClickListener()
collectUiEffect()
}

private fun setupObserver() {
accountInfoViewModel.isLogoutSuccess.observe(viewLifecycleOwner) { isSuccess ->
if (isSuccess) {
startActivity(LoginActivity.getIntent(requireContext()))
private fun collectUiEffect() {
accountInfoViewModel.uiEffect.collectWithLifecycle(viewLifecycleOwner) { uiEffect ->
when (uiEffect) {
UiEffect.NavigateToLogin -> websosoNavigator.navigateToLoginActivity()
}
}
}

private fun onCancelButtonClick() {
private fun setClickListener() {
binding.tvLogoutCancelButton.setOnClickListener {
dismiss()
}
}

private fun onLogoutButtonClick() {
binding.tvLogoutButton.setOnClickListener {
singleEventHandler.throttleFirst {
accountInfoViewModel.logout()
accountInfoViewModel.signOut { platform ->
authClient[platform]?.signOut()
}
}
}
}
Expand Down
53 changes: 25 additions & 28 deletions app/src/main/java/com/into/websoso/ui/splash/SplashViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import com.into.websoso.ui.splash.UiEffect.NavigateToLogin
import com.into.websoso.ui.splash.UiEffect.NavigateToMain
import com.into.websoso.ui.splash.UiEffect.ShowDialog
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import javax.inject.Inject

Expand All @@ -23,12 +23,14 @@ class SplashViewModel
private val userRepository: UserRepository,
private val accountRepository: AccountRepository,
) : ViewModel() {
private var _uiEffect: MutableSharedFlow<UiEffect> = MutableSharedFlow(replay = 1)
val uiEffect: SharedFlow<UiEffect> get() = _uiEffect.asSharedFlow()
private var _uiEffect = Channel<UiEffect>(Channel.BUFFERED)
val uiEffect: Flow<UiEffect> get() = _uiEffect.receiveAsFlow()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Channel → SharedFlow로 전환 및 var 삭제를 고려해 주세요
Channel을 노출할 때마다 receiveAsFlow() 로 래핑해야 하고, var 로 선언되어 재할당 가능성이 열려 있습니다. 단방향 이벤트 스트림이면 MutableSharedFlow(+ extraBufferCapacity)가 보다 간결‧안정적이며, val 로 선언하면 재할당 우려도 사라집니다.

- private var _uiEffect = Channel<UiEffect>(Channel.BUFFERED)
- val uiEffect: Flow<UiEffect> get() = _uiEffect.receiveAsFlow()
+ private val _uiEffect = MutableSharedFlow<UiEffect>(extraBufferCapacity = 1)
+ val uiEffect: SharedFlow<UiEffect> get() = _uiEffect.asSharedFlow()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private var _uiEffect = Channel<UiEffect>(Channel.BUFFERED)
val uiEffect: Flow<UiEffect> get() = _uiEffect.receiveAsFlow()
private val _uiEffect = MutableSharedFlow<UiEffect>(extraBufferCapacity = 1)
val uiEffect: SharedFlow<UiEffect> get() = _uiEffect.asSharedFlow()
🤖 Prompt for AI Agents
In app/src/main/java/com/into/websoso/ui/splash/SplashViewModel.kt around lines
26 to 28, replace the private Channel<UiEffect> _uiEffect and its public
Flow<UiEffect> uiEffect with a private val MutableSharedFlow<UiEffect>
initialized with an appropriate extraBufferCapacity, and expose it as a public
val SharedFlow<UiEffect>. Change the declaration from var to val to prevent
reassignment and simplify usage by removing the need to call receiveAsFlow()
each time.

init {
checkMinimumVersion()
handleAutoLogin()
viewModelScope.launch {
val isUpdateRequired = checkMinimumVersion()
if (isUpdateRequired.not()) handleAutoLogin()
}
}

fun updateUserDeviceIdentifier(deviceIdentifier: String) {
Expand All @@ -41,30 +43,25 @@ class SplashViewModel
}
}

private fun checkMinimumVersion() {
viewModelScope.launch {
runCatching {
versionRepository.isUpdateRequired()
}.onSuccess { isRequired ->
if (isRequired) _uiEffect.emit(ShowDialog)
}
private suspend fun checkMinimumVersion(): Boolean =
runCatching {
versionRepository.isUpdateRequired()
}.getOrElse { false }.also { isRequired ->
if (isRequired) _uiEffect.send(ShowDialog)
}
}

private fun handleAutoLogin() {
viewModelScope.launch {
if (shouldRefresh()) {
_uiEffect.emit(NavigateToLogin)
return@launch
}

runCatching { accountRepository.renewToken() }
.onSuccess {
_uiEffect.emit(NavigateToMain)
}.onFailure {
_uiEffect.emit(NavigateToLogin)
}
private suspend fun handleAutoLogin() {
if (shouldRefresh()) {
_uiEffect.send(NavigateToLogin)
return
}

runCatching { accountRepository.renewToken() }
.onSuccess {
_uiEffect.send(NavigateToMain)
}.onFailure {
_uiEffect.send(NavigateToLogin)
}
}

private suspend fun shouldRefresh(): Boolean =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ internal class DefaultAccountDataSource
}
}

override suspend fun clearTokens() {
accountDataStore.edit { preferences ->
preferences.remove(ACCESS_TOKEN)
preferences.remove(REFRESH_TOKEN)
}
}

companion object {
private val ACCESS_TOKEN = stringPreferencesKey("ACCESS_TOKEN")
private val REFRESH_TOKEN = stringPreferencesKey("REFRESH_TOKEN")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.into.websoso.core.network.datasource.account

import com.into.websoso.core.network.datasource.account.model.KakaoLoginResponseDto
import com.into.websoso.core.network.datasource.account.model.KakaoLogoutRequestDto
import com.into.websoso.core.network.datasource.account.model.TokenReissueRequestDto
import com.into.websoso.core.network.datasource.account.model.TokenReissueResponseDto
import dagger.Module
Expand All @@ -19,6 +20,11 @@ internal interface AccountApi {
@Header("Kakao-Access-Token") accessToken: String,
): KakaoLoginResponseDto

@POST("auth/logout")
suspend fun postLogoutWithKakao(
@Body kakaoLogoutRequestDto: KakaoLogoutRequestDto,
)

@POST("reissue")
suspend fun postReissueToken(
@Body tokenReissueRequestDto: TokenReissueRequestDto,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.into.websoso.core.network.datasource.account

import com.into.websoso.core.auth.AuthPlatform
import com.into.websoso.core.auth.AuthToken
import com.into.websoso.core.network.datasource.account.model.KakaoLogoutRequestDto
import com.into.websoso.core.network.datasource.account.model.TokenReissueRequestDto
import com.into.websoso.data.account.datasource.AccountRemoteDataSource
import com.into.websoso.data.account.model.AccountEntity
Expand Down Expand Up @@ -30,6 +31,18 @@ internal class DefaultAccountDataSource
).toData()
}

override suspend fun postLogout(
refreshToken: String,
deviceIdentifier: String,
) {
accountApi.postLogoutWithKakao(
kakaoLogoutRequestDto = KakaoLogoutRequestDto(
refreshToken = refreshToken,
deviceIdentifier = deviceIdentifier,
),
)
}

override suspend fun postReissue(refreshToken: String): TokenEntity =
accountApi
.postReissueToken(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package com.into.websoso.data.remote.request
package com.into.websoso.core.network.datasource.account.model

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class LogoutRequestDto(
internal data class KakaoLogoutRequestDto(
@SerialName("refreshToken")
val refreshToken: String,
@SerialName("deviceIdentifier")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import com.into.websoso.data.account.datasource.AccountRemoteDataSource
import javax.inject.Inject
import javax.inject.Singleton

// TODO: 인스턴스 싱글톤 참고하기
// TODO: 인스턴스 싱글톤 참고하기, tokens 네이밍수정, result 객체 적용
@Singleton
class AccountRepository
@Inject
Expand All @@ -34,6 +34,14 @@ class AccountRepository
return account.isRegister
}

suspend fun deleteToken(deviceIdentifier: String) {
accountRemoteDataSource
.postLogout(
refreshToken = refreshToken(),
deviceIdentifier = deviceIdentifier,
).also { accountLocalDataSource.clearTokens() }
}

suspend fun renewToken(): String {
val tokens = accountRemoteDataSource.postReissue(refreshToken = refreshToken())

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@ interface AccountLocalDataSource {
suspend fun saveAccessToken(accessToken: String)

suspend fun saveRefreshToken(refreshToken: String)

suspend fun clearTokens()
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,10 @@ interface AccountRemoteDataSource {
authToken: AuthToken,
): AccountEntity

suspend fun postLogout(
refreshToken: String,
deviceIdentifier: String,
)

suspend fun postReissue(refreshToken: String): TokenEntity
}
Loading