diff --git a/app/src/main/java/com/example/slowclock/MainActivity.kt b/app/src/main/java/com/example/slowclock/MainActivity.kt index 2b92beea..1ee36bec 100644 --- a/app/src/main/java/com/example/slowclock/MainActivity.kt +++ b/app/src/main/java/com/example/slowclock/MainActivity.kt @@ -23,6 +23,7 @@ import com.example.slowclock.data.model.ThemeMode import com.example.slowclock.data.remote.repository.AuthRepository import com.example.slowclock.data.remote.repository.SettingsRepository import com.example.slowclock.data.remote.repository.UserRepository +import com.example.slowclock.domain.profile.SignOutUseCase import com.example.slowclock.navigation.AppNavigation import com.example.slowclock.ui.theme.SlowClockTheme import com.firebase.ui.auth.AuthUI @@ -43,6 +44,9 @@ class MainActivity : ComponentActivity() { @Inject lateinit var settingsRepository: SettingsRepository + @Inject + lateinit var signOutUseCase: SignOutUseCase + private fun handleNewInstallation() { val prefs = getSharedPreferences("app_state", MODE_PRIVATE) val isAppEverLaunched = prefs.getBoolean("app_launched", false) @@ -51,7 +55,9 @@ class MainActivity : ComponentActivity() { // 이 앱이 처음 실행됨 = 새 설치 Log.d("INSTALL", "새 설치 감지 - Firebase 로그아웃") - authRepository.signOut() + // 앱을 지웠다 다시 깐 기기다. 세션뿐 아니라 앞 설치가 남긴 알람 장부와 공유 코드도 + // 함께 비운다. 남기면 목록에 없는 알람이 계속 울린다(#165). + signOutUseCase() AuthUI.getInstance().signOut(this) // 플래그 저장 diff --git a/core/data/src/main/java/com/example/slowclock/data/remote/repository/SettingsRepository.kt b/core/data/src/main/java/com/example/slowclock/data/remote/repository/SettingsRepository.kt index 5bd5d32b..d17a9c2c 100644 --- a/core/data/src/main/java/com/example/slowclock/data/remote/repository/SettingsRepository.kt +++ b/core/data/src/main/java/com/example/slowclock/data/remote/repository/SettingsRepository.kt @@ -29,6 +29,16 @@ class SettingsRepository prefs.edit().putString(KEY_SHARE_CODE, shareCode.trim()).apply() } + /** + * 등록해 둔 가족의 공유 코드를 지운다. 세션이 끝날 때 부른다. + * + * 남겨 두면 같은 기기에 다른 사람이 로그인했을 때 앞 사람이 등록한 가족의 일정이 + * 그대로 보인다. 어르신 폰을 자녀가 잠깐 쓰는 일이 이 앱에서는 드물지 않다(#165). + */ + fun clearShareCode() { + prefs.edit().remove(KEY_SHARE_CODE).apply() + } + fun getThemeMode(): ThemeMode = ThemeMode.fromName(prefs.getString(KEY_THEME_MODE, null)) fun setThemeMode(mode: ThemeMode) { diff --git a/feature/profile/src/main/java/com/example/slowclock/domain/profile/SignOutUseCase.kt b/feature/profile/src/main/java/com/example/slowclock/domain/profile/SignOutUseCase.kt new file mode 100644 index 00000000..121f3f3e --- /dev/null +++ b/feature/profile/src/main/java/com/example/slowclock/domain/profile/SignOutUseCase.kt @@ -0,0 +1,35 @@ +package com.example.slowclock.domain.profile + +import com.example.slowclock.core.alarm.AlarmScheduler +import com.example.slowclock.data.remote.repository.AuthRepository +import com.example.slowclock.data.remote.repository.SettingsRepository +import javax.inject.Inject + +/** + * 로그아웃. 세션을 끊는 것으로 끝나지 않고 이 기기에 남는 그 사람의 흔적을 함께 지운다. + * + * 알람은 기기 안 장부로 살아 있고 로그인 여부를 보지 않는다. 반복 일정은 회차가 무한히 + * 이어지고(#130) 재부팅에도 살아남으므로(#127), 지우지 않으면 로그아웃한 계정의 알람이 그 + * 기기에서 영구히 매일 울린다. 목록에는 없으니 사용자가 끌 방법도 없다(#165). + * + * 등록해 둔 가족의 공유 코드도 지운다. 남겨 두면 같은 기기에 다른 사람이 로그인했을 때 앞 + * 사람이 등록한 가족의 일정이 그대로 보인다. + * + * 여러 Repository 를 순서대로 조합하고 두 화면이 함께 쓰므로 UseCase 로 둔다. 계정 삭제가 + * 이미 같은 짝을 [DeleteAccountUseCase] 에서 맞추고 있어 자리도 같다. + */ +class SignOutUseCase + @Inject + constructor( + private val authRepository: AuthRepository, + private val alarmScheduler: AlarmScheduler, + private val settingsRepository: SettingsRepository, + ) { + operator fun invoke() { + // 세션을 끊기 전에 기기 잔재부터 지운다. 순서가 반대면 중간에 실패했을 때 + // 로그인은 풀렸는데 알람만 남는 상태가 된다. + alarmScheduler.cancelAll() + settingsRepository.clearShareCode() + authRepository.signOut() + } + } diff --git a/feature/profile/src/main/java/com/example/slowclock/ui/profile/ProfileViewModel.kt b/feature/profile/src/main/java/com/example/slowclock/ui/profile/ProfileViewModel.kt index 48a5a246..b91446c3 100644 --- a/feature/profile/src/main/java/com/example/slowclock/ui/profile/ProfileViewModel.kt +++ b/feature/profile/src/main/java/com/example/slowclock/ui/profile/ProfileViewModel.kt @@ -6,6 +6,7 @@ import com.example.slowclock.data.remote.repository.UserRepository import com.example.slowclock.domain.profile.DeleteAccountResult import com.example.slowclock.domain.profile.DeleteAccountStep import com.example.slowclock.domain.profile.DeleteAccountUseCase +import com.example.slowclock.domain.profile.SignOutUseCase import com.example.slowclock.ui.mvi.MviViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch @@ -18,6 +19,7 @@ class ProfileViewModel private val userRepository: UserRepository, private val authRepository: AuthRepository, private val deleteAccount: DeleteAccountUseCase, + private val signOutUseCase: SignOutUseCase, ) : MviViewModel(ProfileUiState()) { init { loadProfile() @@ -145,7 +147,8 @@ class ProfileViewModel } private fun signOut() { - authRepository.signOut() + // 세션만 끊으면 이 기기에 걸린 알람과 등록해 둔 공유 코드가 남는다(#165). + signOutUseCase() dispatch(ProfileReducerEvent.Left(ProfileLeaveReason.SIGNED_OUT)) } diff --git a/feature/profile/src/test/java/com/example/slowclock/domain/profile/SignOutUseCaseTest.kt b/feature/profile/src/test/java/com/example/slowclock/domain/profile/SignOutUseCaseTest.kt new file mode 100644 index 00000000..a4ea4e51 --- /dev/null +++ b/feature/profile/src/test/java/com/example/slowclock/domain/profile/SignOutUseCaseTest.kt @@ -0,0 +1,34 @@ +package com.example.slowclock.domain.profile + +import com.example.slowclock.core.alarm.AlarmScheduler +import com.example.slowclock.data.remote.repository.AuthRepository +import com.example.slowclock.data.remote.repository.SettingsRepository +import io.mockk.mockk +import io.mockk.verifyOrder +import org.junit.Test + +/** + * 로그아웃이 기기에 남는 흔적을 함께 지우는지 본다. + * + * 지우지 않으면 로그아웃한 계정의 반복 알람이 그 기기에서 영구히 매일 울린다. 목록에는 없으니 + * 사용자가 끌 방법도 없다(#165). + */ +class SignOutUseCaseTest { + private val authRepository = mockk(relaxed = true) + private val alarmScheduler = mockk(relaxed = true) + private val settingsRepository = mockk(relaxed = true) + + private val signOut = SignOutUseCase(authRepository, alarmScheduler, settingsRepository) + + @Test + fun `세션을 끊기 전에 기기 잔재를 먼저 지운다`() { + // 순서가 반대면 중간에 실패했을 때 로그인은 풀렸는데 알람만 남는다. + signOut() + + verifyOrder { + alarmScheduler.cancelAll() + settingsRepository.clearShareCode() + authRepository.signOut() + } + } +} diff --git a/feature/profile/src/test/java/com/example/slowclock/ui/profile/ProfileViewModelTest.kt b/feature/profile/src/test/java/com/example/slowclock/ui/profile/ProfileViewModelTest.kt index d9c76ead..4f4a72a2 100644 --- a/feature/profile/src/test/java/com/example/slowclock/ui/profile/ProfileViewModelTest.kt +++ b/feature/profile/src/test/java/com/example/slowclock/ui/profile/ProfileViewModelTest.kt @@ -6,6 +6,7 @@ import com.example.slowclock.data.remote.repository.UserRepository import com.example.slowclock.domain.profile.DeleteAccountResult import com.example.slowclock.domain.profile.DeleteAccountStep import com.example.slowclock.domain.profile.DeleteAccountUseCase +import com.example.slowclock.domain.profile.SignOutUseCase import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -35,6 +36,7 @@ class ProfileViewModelTest { private val userRepository = mockk() private val authRepository = mockk() private val deleteAccount = mockk() + private val signOutUseCase = mockk(relaxed = true) @Before fun setUp() { @@ -51,7 +53,7 @@ class ProfileViewModelTest { Dispatchers.resetMain() } - private fun createViewModel() = ProfileViewModel(userRepository, authRepository, deleteAccount) + private fun createViewModel() = ProfileViewModel(userRepository, authRepository, deleteAccount, signOutUseCase) @Test fun `Firestore 사용자 문서로 이름·이메일·공유 코드를 채운다`() = @@ -94,7 +96,7 @@ class ProfileViewModelTest { viewModel.onIntent(ProfileIntent.SignOut) - verify(exactly = 1) { authRepository.signOut() } + verify(exactly = 1) { signOutUseCase() } assertEquals(ProfileLeaveReason.SIGNED_OUT, viewModel.uiState.value.leave) viewModel.onIntent(ProfileIntent.ConsumeLeave)