From 406b3ca4e2a2ff8fe9576b1677cae4e557bb3e56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 23 Aug 2026 16:27:05 +0900 Subject: [PATCH 1/3] =?UTF-8?q?refactor:=20DataStore=20=EC=88=98=EB=AA=85(?= =?UTF-8?q?scope)=20=EA=B3=84=EC=95=BD=20=EB=8F=84=EC=9E=85,=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=EB=B3=84=20clear=20=EC=A0=9C=EA=B1=B0=20(#91?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로그아웃 정리 대상을 저장소마다 clear 로 각자 노출하고 호출해 주기를 기대하던 구조를 걷어내는 1단계. 수명 계약과 테스트를 들이고, 프로덕션 호출자가 0 이던 저장소별 정리 메서드와 Context 델리게이트를 제거한다. 레지스트리 구현체·DI 배선·logout() 연결은 후속 커밋에서. - core:datastore 에 StoreScope(SESSION/DEVICE)·LocalStoreRegistry 계약 신설 - preferencesDataStore Context 델리게이트 4개 삭제 (파일명·경로 계약은 후속 커밋의 레지스트리 배선이 그대로 승계) - UserProfileRepository.clear()·ReceiverRepository.clearAuthCode()· TokenDataSource.clearTokens()·ReceiverAuthCodeDataSource.clearCode() 와 테스트 fake 오버라이드 제거 - 레지스트리 계약 테스트(프로세스 재기동 시뮬레이션 포함)와 세션 일괄 정리 단언 추가 주의: 이 커밋 단독으로는 빌드되지 않는다 (구현·배선은 후속 커밋). --- .../screen/HomeTabViewModelTest.kt | 2 - .../receiver/ReceiverHomeViewModelTest.kt | 2 - .../repoimpl/UserProfileRepositoryImpl.kt | 4 - .../repoimpl/auth/AuthRepositoryImplTest.kt | 45 ++++- .../core/datastore/LocalStoreRegistry.kt | 38 ++++ .../afternote/core/datastore/StoreScope.kt | 15 ++ .../core/datastore/TokenDataSource.kt | 6 - .../datastore/TokenPreferencesDataStore.kt | 14 -- .../core/datastore/UserProfileDataSource.kt | 6 - .../UserProfilePreferencesDataStore.kt | 19 -- .../datastore/di/LocalStoreRegistryModule.kt | 17 ++ .../datastore/LocalStoreRegistryImplTest.kt | 170 ++++++++++++++++++ .../repository/UserProfileRepository.kt | 2 - ...dentityVerificationPreferencesDataStore.kt | 14 -- .../data/local/ReceiverAuthCodeDataSource.kt | 6 - .../ReceiverAuthCodePreferencesDataStore.kt | 14 -- .../receiver/ReceiverRepositoryImpl.kt | 4 - .../repository/receiver/ReceiverRepository.kt | 5 +- .../MasterKeyViewModelTest.kt | 4 - .../ReceiverMemorialPlaylistViewModelTest.kt | 2 - 20 files changed, 281 insertions(+), 108 deletions(-) create mode 100644 core/datastore/src/main/java/com/afternote/core/datastore/LocalStoreRegistry.kt create mode 100644 core/datastore/src/main/java/com/afternote/core/datastore/StoreScope.kt delete mode 100644 core/datastore/src/main/java/com/afternote/core/datastore/TokenPreferencesDataStore.kt delete mode 100644 core/datastore/src/main/java/com/afternote/core/datastore/UserProfilePreferencesDataStore.kt create mode 100644 core/datastore/src/main/java/com/afternote/core/datastore/di/LocalStoreRegistryModule.kt create mode 100644 core/datastore/src/test/java/com/afternote/core/datastore/LocalStoreRegistryImplTest.kt delete mode 100644 feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/local/IdentityVerificationPreferencesDataStore.kt delete mode 100644 feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/local/ReceiverAuthCodePreferencesDataStore.kt diff --git a/app/src/test/java/com/afternote/afternote_fe/screen/HomeTabViewModelTest.kt b/app/src/test/java/com/afternote/afternote_fe/screen/HomeTabViewModelTest.kt index e9e7c9de71..8d2b064645 100644 --- a/app/src/test/java/com/afternote/afternote_fe/screen/HomeTabViewModelTest.kt +++ b/app/src/test/java/com/afternote/afternote_fe/screen/HomeTabViewModelTest.kt @@ -484,8 +484,6 @@ private class FakeUserProfileRepository : UserProfileRepository { override suspend fun saveUserName(name: String) { savedUserNames += name } - - override suspend fun clear() = unexpected("clear") } private class RecordingErrorReporter : ErrorReporter { diff --git a/app/src/test/java/com/afternote/afternote_fe/screen/receiver/ReceiverHomeViewModelTest.kt b/app/src/test/java/com/afternote/afternote_fe/screen/receiver/ReceiverHomeViewModelTest.kt index fb2d9ad183..fb99b4d6e0 100644 --- a/app/src/test/java/com/afternote/afternote_fe/screen/receiver/ReceiverHomeViewModelTest.kt +++ b/app/src/test/java/com/afternote/afternote_fe/screen/receiver/ReceiverHomeViewModelTest.kt @@ -169,8 +169,6 @@ private class FakeReceiverRepository : ReceiverRepository { override suspend fun saveAuthCode(code: String) = unexpected("saveAuthCode") - override suspend fun clearAuthCode() = unexpected("clearAuthCode") - override fun getPagedReceivedAfternotes() = unexpected("getPagedReceivedAfternotes") override suspend fun getReceivedAfterNotes(): Result = afterNotes diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/UserProfileRepositoryImpl.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/UserProfileRepositoryImpl.kt index c57855c483..71c474a575 100644 --- a/core/data/src/main/java/com/afternote/core/data/repoimpl/UserProfileRepositoryImpl.kt +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/UserProfileRepositoryImpl.kt @@ -21,8 +21,4 @@ class UserProfileRepositoryImpl override suspend fun saveUserName(name: String) { dataSource.saveUserName(name) } - - override suspend fun clear() { - dataSource.clear() - } } diff --git a/core/data/src/test/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImplTest.kt b/core/data/src/test/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImplTest.kt index 249309c418..3c4536013b 100644 --- a/core/data/src/test/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImplTest.kt +++ b/core/data/src/test/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImplTest.kt @@ -3,6 +3,8 @@ package com.afternote.core.data.repoimpl.auth import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.emptyPreferences +import com.afternote.core.datastore.LocalStoreRegistry +import com.afternote.core.datastore.StoreScope import com.afternote.core.datastore.TokenDataSource import com.afternote.core.domain.error.InvalidLoginCredentialsException import com.afternote.core.domain.error.NetworkUnavailableException @@ -38,14 +40,25 @@ import kotlin.coroutines.cancellation.CancellationException * * 계약 — 발급(로그인) 응답의 `expiresIn` 은 기록하고, 생략(null)이면 이전 토큰 기준 stale * deadline 이 새 세션에 적용되지 않게 비우며(`TokenReissuer` 회전 경로와 같은 규칙), - * 세션 종료(`logout`/`clearSession`)는 로컬 토큰과 deadline 을 함께 정리한다. + * 세션 종료(`logout`/`clearSession`)는 SESSION 스코프 저장소 일괄 정리(#912)와 deadline + * 정리를 함께 수행한다. * * [AccessTokenExpiryTracker] 는 실물 사용 — 시계(SystemClock)는 `isReturnDefaultValues` 로 * 0 에 고정되므로, 잔여 30초 기록 = 임박 true / 비움 = false 로 상태를 관찰한다 (임계 60초). */ class AuthRepositoryImplTest { private val tracker = AccessTokenExpiryTracker() - private val tokenDataSource = TokenDataSource(InMemoryPreferencesDataStore()) + private val tokenStore = InMemoryPreferencesDataStore() + private val tokenDataSource = TokenDataSource(tokenStore) + + // 실제 레지스트리처럼 SESSION 정리가 토큰 저장소를 비우게 흉내 낸다 — 그래야 아래 + // 토큰 잔존·서버 호출 순서(정리가 먼저면 refresh 가 없어 서버 호출 불가) 단언이 유효하다. + private val localStoreRegistry = + FakeLocalStoreRegistry( + onClearScope = { scope -> + if (scope == StoreScope.SESSION) tokenStore.updateData { emptyPreferences() } + }, + ) private fun repository( authApiService: AuthApiService = FakeAuthApiService(), @@ -55,6 +68,7 @@ class AuthRepositoryImplTest { authApiService = authApiService, tokenApiService = tokenApiService, expiryTracker = tracker, + localStoreRegistry = localStoreRegistry, ) @Test @@ -105,7 +119,7 @@ class AuthRepositoryImplTest { } @Test - fun `logout - 저장돼 있던 refresh 로 서버 호출 후 로컬 토큰·deadline 정리`() { + fun `logout - 저장돼 있던 refresh 로 서버 호출 후 SESSION 스코프·deadline 정리`() { runBlocking { tokenDataSource.saveTokens(accessToken = "access", refreshToken = "stored-refresh") } tracker.record(expiresInSeconds = 30) val authApiService = FakeAuthApiService() @@ -113,13 +127,15 @@ class AuthRepositoryImplTest { val result = runBlocking { repository(authApiService).logout() } assertTrue(result.isSuccess) + // 정리가 서버 호출보다 먼저였다면 refresh 가 이미 없어 이 요청 자체가 못 나간다 (순서 가드). assertEquals(listOf(LogoutRequestDto("stored-refresh")), authApiService.logoutRequests) + assertEquals(listOf(StoreScope.SESSION), localStoreRegistry.clearedScopes) assertNull(runBlocking { tokenDataSource.getRefreshToken() }) assertFalse(tracker.isExpiringSoon()) } @Test - fun `logout - 서버 호출 실패해도 (best-effort) 토큰·deadline 정리는 진행`() { + fun `logout - 서버 호출 실패해도 (best-effort) SESSION 스코프·deadline 정리는 진행`() { runBlocking { tokenDataSource.saveTokens(accessToken = "access", refreshToken = "stored-refresh") } tracker.record(expiresInSeconds = 30) val authApiService = @@ -130,18 +146,20 @@ class AuthRepositoryImplTest { val result = runBlocking { repository(authApiService).logout() } assertTrue(result.isSuccess) + assertEquals(listOf(StoreScope.SESSION), localStoreRegistry.clearedScopes) assertNull(runBlocking { tokenDataSource.getRefreshToken() }) assertFalse(tracker.isExpiringSoon()) } @Test - fun `clearSession - 로컬 토큰·deadline 함께 정리`() { + fun `clearSession - SESSION 스코프·deadline 함께 정리 (탈퇴 경로도 이 메서드를 쓴다)`() { runBlocking { tokenDataSource.saveTokens(accessToken = "access", refreshToken = "refresh") } tracker.record(expiresInSeconds = 30) val result = runBlocking { repository().clearSession() } assertTrue(result.isSuccess) + assertEquals(listOf(StoreScope.SESSION), localStoreRegistry.clearedScopes) assertNull(runBlocking { tokenDataSource.getAccessToken() }) assertFalse(tracker.isExpiringSoon()) } @@ -331,6 +349,23 @@ private class FakeTokenApiService( override suspend fun reissue(body: ReissueRequestDto): BaseResponse = onReissue(body) } +/** [LocalStoreRegistry] 테스트 대역 — clearScope 호출을 기록하고, 주입된 동작으로 실제 정리를 흉내 낸다. */ +private class FakeLocalStoreRegistry( + private val onClearScope: suspend (StoreScope) -> Unit = {}, +) : LocalStoreRegistry { + val clearedScopes = mutableListOf() + + override fun store( + name: String, + scope: StoreScope, + ): DataStore = error("store 는 이 테스트에서 호출되면 안 됨") + + override suspend fun clearScope(scope: StoreScope) { + clearedScopes += scope + onClearScope(scope) + } +} + /** 단위 테스트용 in-memory `DataStore` — 디스크 없이 [TokenDataSource] 실물을 구동한다. */ private class InMemoryPreferencesDataStore : DataStore { private val state = MutableStateFlow(emptyPreferences()) diff --git a/core/datastore/src/main/java/com/afternote/core/datastore/LocalStoreRegistry.kt b/core/datastore/src/main/java/com/afternote/core/datastore/LocalStoreRegistry.kt new file mode 100644 index 0000000000..ae814ad35f --- /dev/null +++ b/core/datastore/src/main/java/com/afternote/core/datastore/LocalStoreRegistry.kt @@ -0,0 +1,38 @@ +package com.afternote.core.datastore + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences + +/** + * 앱의 모든 Preferences DataStore 를 만들어 나눠주는 단일 창구 (#912). + * + * 각 모듈이 `preferencesDataStore(name = ...)` 델리게이트를 독립 선언하던 구조에서는 + * (1) 로그아웃 정리 대상 등록을 빼먹어도 어디서도 안 걸리고, (2) 파일명 충돌을 아무도 못 막았다. + * 레지스트리 경유로 바꾸면 획득 시점에 수명([StoreScope])이 함께 등록되고, 같은 name 은 + * 항상 같은 인스턴스를 돌려받아(DataStore 는 파일당 인스턴스 1개가 강제 조건) 둘 다 구조적으로 풀린다. + * + * 반환 타입은 [DataStore] 그대로다 — `Flow` 관찰·원자적 다중 키 `edit` 등 DataStore API 를 + * 좁히지 않는다(#912 하지 말 것). 타입드 접근자(스키마)는 각 feature 의 DataSource 가 유지한다. + */ +interface LocalStoreRegistry { + /** + * `files/datastore/.preferences_pb` 를 쓰는 DataStore 를 만들어 돌려주고, + * [scope] 를 수명으로 등록한다. 같은 [name] 재요청은 같은 인스턴스를 돌려주며, + * 같은 [name] 을 다른 [scope] 로 재요청하면 즉시 실패한다. + * + * [name] 은 저장 파일명 계약이다 — 바꾸면 기존 사용자의 데이터가 통째로 끊긴다 (#912 필수 주의). + */ + fun store( + name: String, + scope: StoreScope, + ): DataStore + + /** + * [scope] 로 등록된 모든 저장소의 키를 비운다 (파일은 유지). + * + * 등록 이력은 디스크에도 남으므로, 이번 프로세스에서 아직 획득된 적 없는 저장소도 + * 함께 지워진다 — Hilt provider 는 최초 주입 때에야 실행되므로 in-memory 등록만으로는 + * "이전 프로세스에서 쓰고 이번 프로세스에서 안 연" 저장소가 새는 구멍이 있다. + */ + suspend fun clearScope(scope: StoreScope) +} diff --git a/core/datastore/src/main/java/com/afternote/core/datastore/StoreScope.kt b/core/datastore/src/main/java/com/afternote/core/datastore/StoreScope.kt new file mode 100644 index 0000000000..48325c1e86 --- /dev/null +++ b/core/datastore/src/main/java/com/afternote/core/datastore/StoreScope.kt @@ -0,0 +1,15 @@ +package com.afternote.core.datastore + +/** + * 로컬 저장소(Preferences DataStore)의 수명 계약 (#912). + * + * 저장소는 [LocalStoreRegistry.store] 로 얻는 순간 scope 가 함께 등록되므로, + * 세션 종료 시 정리 대상에서 빠뜨리는 것이 구조적으로 차단된다. + */ +enum class StoreScope { + /** 로그인 세션에 귀속 — 로그아웃·회원 탈퇴 시 [LocalStoreRegistry.clearScope] 가 전부 비운다. */ + SESSION, + + /** 기기에 귀속 — 세션 종료와 무관하게 유지 (앱 삭제 전까지). */ + DEVICE, +} diff --git a/core/datastore/src/main/java/com/afternote/core/datastore/TokenDataSource.kt b/core/datastore/src/main/java/com/afternote/core/datastore/TokenDataSource.kt index f626cef795..916be8cc02 100644 --- a/core/datastore/src/main/java/com/afternote/core/datastore/TokenDataSource.kt +++ b/core/datastore/src/main/java/com/afternote/core/datastore/TokenDataSource.kt @@ -54,12 +54,6 @@ class TokenDataSource } } - suspend fun clearTokens() { - dataStore.edit { prefs -> - prefs.clear() - } - } - suspend fun updateTokens( accessToken: String, refreshToken: String, diff --git a/core/datastore/src/main/java/com/afternote/core/datastore/TokenPreferencesDataStore.kt b/core/datastore/src/main/java/com/afternote/core/datastore/TokenPreferencesDataStore.kt deleted file mode 100644 index d8612da848..0000000000 --- a/core/datastore/src/main/java/com/afternote/core/datastore/TokenPreferencesDataStore.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.afternote.core.datastore - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.preferencesDataStore - -/** - * 액세스·리프레시 토큰 저장용 Preferences DataStore. - * [TokenDataSource]와 [com.afternote.core.datastore.di.TokenDataStoreModule]에서 동일 인스턴스를 참조합니다. - */ -internal val Context.tokenPreferencesDataStore: DataStore by preferencesDataStore( - name = "Token", -) diff --git a/core/datastore/src/main/java/com/afternote/core/datastore/UserProfileDataSource.kt b/core/datastore/src/main/java/com/afternote/core/datastore/UserProfileDataSource.kt index 977304afd2..ed54c70758 100644 --- a/core/datastore/src/main/java/com/afternote/core/datastore/UserProfileDataSource.kt +++ b/core/datastore/src/main/java/com/afternote/core/datastore/UserProfileDataSource.kt @@ -57,10 +57,4 @@ class UserProfileDataSource prefs[Keys.IS_PASSKEY_REGISTERED] = registered } } - - suspend fun clear() { - dataStore.edit { prefs -> - prefs.clear() - } - } } diff --git a/core/datastore/src/main/java/com/afternote/core/datastore/UserProfilePreferencesDataStore.kt b/core/datastore/src/main/java/com/afternote/core/datastore/UserProfilePreferencesDataStore.kt deleted file mode 100644 index 8b3115a2bf..0000000000 --- a/core/datastore/src/main/java/com/afternote/core/datastore/UserProfilePreferencesDataStore.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.afternote.core.datastore - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.preferencesDataStore - -/** - * 사용자 프로필 캐시용 Preferences DataStore. - * - * 프로필 정보(이름 등)는 거의 변하지 않으므로, 콜드스타트 시 GET /users/me RTT(약 1.3초)를 - * 기다리지 않고 즉시 표시할 수 있도록 마지막 응답을 디스크에 보관한다. - * - * [com.afternote.core.datastore.UserProfileDataSource]와 - * [com.afternote.core.datastore.di.UserProfileDataStoreModule]에서 동일 인스턴스를 참조한다. - */ -internal val Context.userProfilePreferencesDataStore: DataStore by preferencesDataStore( - name = "UserProfile", -) diff --git a/core/datastore/src/main/java/com/afternote/core/datastore/di/LocalStoreRegistryModule.kt b/core/datastore/src/main/java/com/afternote/core/datastore/di/LocalStoreRegistryModule.kt new file mode 100644 index 0000000000..c7f3f9ec41 --- /dev/null +++ b/core/datastore/src/main/java/com/afternote/core/datastore/di/LocalStoreRegistryModule.kt @@ -0,0 +1,17 @@ +package com.afternote.core.datastore.di + +import com.afternote.core.datastore.LocalStoreRegistry +import com.afternote.core.datastore.LocalStoreRegistryImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class LocalStoreRegistryModule { + @Binds + @Singleton + abstract fun bindLocalStoreRegistry(impl: LocalStoreRegistryImpl): LocalStoreRegistry +} diff --git a/core/datastore/src/test/java/com/afternote/core/datastore/LocalStoreRegistryImplTest.kt b/core/datastore/src/test/java/com/afternote/core/datastore/LocalStoreRegistryImplTest.kt new file mode 100644 index 0000000000..5d6553a9ee --- /dev/null +++ b/core/datastore/src/test/java/com/afternote/core/datastore/LocalStoreRegistryImplTest.kt @@ -0,0 +1,170 @@ +package com.afternote.core.datastore + +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.job +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * [LocalStoreRegistryImpl] 의 수명(scope) 계약 가드 (#912). + * + * 핵심은 재기동 시나리오다 — Hilt provider 는 최초 주입 때에야 실행되므로, 이전 프로세스에서 + * 기록된 저장소를 이번 프로세스에서 한 번도 안 연 채 로그아웃하면 in-memory 등록만으로는 + * 잔존이 재현된다. 매니페스트 영속이 그 구멍을 막는 것을 레지스트리 재생성(= 프로세스 재시작 + * 시뮬레이션)으로 검증한다. 실물 파일 DataStore 를 쓰며, 프로세스 종료는 registryScope 취소로 + * 흉내 낸다 (DataStore 의 "파일당 활성 인스턴스 1개" 등록이 scope 종료로 풀린다). + */ +class LocalStoreRegistryImplTest { + @get:Rule + val tmp = TemporaryFolder() + + private val key = stringPreferencesKey("value") + + private fun newRegistry(scope: CoroutineScope) = + LocalStoreRegistryImpl( + produceFile = { name -> File(tmp.root, "$name.preferences_pb") }, + registryScope = scope, + ) + + private fun newScope() = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + /** 프로세스 종료 시뮬레이션 — 이 레지스트리의 모든 DataStore 활성 등록을 푼다. */ + private fun shutdown(scope: CoroutineScope) = + runBlocking { + val job = scope.coroutineContext.job + job.cancel() + job.join() + } + + @Test + fun `store - 같은 name 재요청은 같은 인스턴스 (파일당 1개 강제)`() { + val scope = newScope() + try { + val registry = newRegistry(scope) + + val first = registry.store("alpha", StoreScope.SESSION) + val second = registry.store("alpha", StoreScope.SESSION) + + assertSame(first, second) + } finally { + shutdown(scope) + } + } + + @Test + fun `store - 같은 name 을 다른 scope 로 재요청하면 즉시 실패`() { + val scope = newScope() + try { + val registry = newRegistry(scope) + registry.store("alpha", StoreScope.SESSION) + + val thrown = runCatching { registry.store("alpha", StoreScope.DEVICE) }.exceptionOrNull() + + assertTrue(thrown is IllegalStateException) + } finally { + shutdown(scope) + } + } + + @Test + fun `store - 매니페스트 예약 이름은 거부`() { + val scope = newScope() + try { + val registry = newRegistry(scope) + + val thrown = + runCatching { + registry.store(LocalStoreRegistryImpl.MANIFEST_NAME, StoreScope.DEVICE) + }.exceptionOrNull() + + assertTrue(thrown is IllegalArgumentException) + } finally { + shutdown(scope) + } + } + + @Test + fun `clearScope(SESSION) - SESSION 저장소만 비우고 DEVICE 는 유지`() { + val scope = newScope() + try { + val registry = newRegistry(scope) + runBlocking { + registry.store("session_store", StoreScope.SESSION).edit { it[key] = "세션 값" } + registry.store("device_store", StoreScope.DEVICE).edit { it[key] = "기기 값" } + + registry.clearScope(StoreScope.SESSION) + + assertNull(registry.store("session_store", StoreScope.SESSION).data.first()[key]) + assertEquals("기기 값", registry.store("device_store", StoreScope.DEVICE).data.first()[key]) + } + } finally { + shutdown(scope) + } + } + + @Test + fun `clearScope - 이번 프로세스에서 획득된 적 없는 저장소도 매니페스트 기록으로 지운다 (재기동 시뮬레이션)`() { + // 프로세스 1 — 저장소를 획득해 값을 쓰고 종료한다. + val scope1 = newScope() + val registry1 = newRegistry(scope1) + runBlocking { + registry1.store("session_store", StoreScope.SESSION).edit { it[key] = "잔존 후보" } + registry1.store("device_store", StoreScope.DEVICE).edit { it[key] = "기기 값" } + registry1.awaitPendingRegistrations() + } + shutdown(scope1) + + // 프로세스 2 — 어떤 store() 호출도 없이 곧장 로그아웃 (Hilt lazy 주입으로 미획득인 상황). + val scope2 = newScope() + try { + val registry2 = newRegistry(scope2) + runBlocking { + registry2.clearScope(StoreScope.SESSION) + + assertNull(registry2.store("session_store", StoreScope.SESSION).data.first()[key]) + assertEquals("기기 값", registry2.store("device_store", StoreScope.DEVICE).data.first()[key]) + } + } finally { + shutdown(scope2) + } + } + + @Test + fun `clearScope 가 연 저장소도 이후 store() 와 같은 인스턴스를 공유한다`() { + // 재기동 후 clearScope 가 매니페스트 기록만으로 먼저 연 저장소를, 뒤늦게 feature 가 + // 획득해도 두 번째 인스턴스가 만들어지면 안 된다 (DataStore 파일당 1개 강제 조건). + val scope1 = newScope() + val registry1 = newRegistry(scope1) + runBlocking { + registry1.store("session_store", StoreScope.SESSION).edit { it[key] = "값" } + registry1.awaitPendingRegistrations() + } + shutdown(scope1) + + val scope2 = newScope() + try { + val registry2 = newRegistry(scope2) + runBlocking { + registry2.clearScope(StoreScope.SESSION) + + val store = registry2.store("session_store", StoreScope.SESSION) + store.edit { it[key] = "새 세션 값" } + assertEquals("새 세션 값", store.data.first()[key]) + } + } finally { + shutdown(scope2) + } + } +} diff --git a/core/domain/src/main/java/com/afternote/core/domain/repository/UserProfileRepository.kt b/core/domain/src/main/java/com/afternote/core/domain/repository/UserProfileRepository.kt index 0503410c6e..b662275782 100644 --- a/core/domain/src/main/java/com/afternote/core/domain/repository/UserProfileRepository.kt +++ b/core/domain/src/main/java/com/afternote/core/domain/repository/UserProfileRepository.kt @@ -19,6 +19,4 @@ interface UserProfileRepository { suspend fun getCachedUserName(): String? suspend fun saveUserName(name: String) - - suspend fun clear() } diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/local/IdentityVerificationPreferencesDataStore.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/local/IdentityVerificationPreferencesDataStore.kt deleted file mode 100644 index ccc0fe3942..0000000000 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/local/IdentityVerificationPreferencesDataStore.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.afternote.feature.afternote.data.local - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.preferencesDataStore - -/** - * 본인 확인 완료 여부 영구 저장용 DataStore. - * 발신자별로 분리하지 않는 사람 단위 1회 검증 정책 — 단일 boolean 키. - */ -internal val Context.identityVerificationPreferencesDataStore: DataStore by preferencesDataStore( - name = "afternote_identity_verification", -) diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/local/ReceiverAuthCodeDataSource.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/local/ReceiverAuthCodeDataSource.kt index 1837bf7da2..c694606b3e 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/local/ReceiverAuthCodeDataSource.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/local/ReceiverAuthCodeDataSource.kt @@ -49,10 +49,4 @@ class ReceiverAuthCodeDataSource } } } - - suspend fun clearCode() { - dataStore.edit { preferences -> - preferences.remove(ReceiverAuthCodeKeys.AUTH_CODE) - } - } } diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/local/ReceiverAuthCodePreferencesDataStore.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/local/ReceiverAuthCodePreferencesDataStore.kt deleted file mode 100644 index 88497960e3..0000000000 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/local/ReceiverAuthCodePreferencesDataStore.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.afternote.feature.afternote.data.local - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.preferencesDataStore - -/** - * 수신 인증 코드용 Preferences DataStore. - * 기존 SharedPreferences XML(`afternote_receiver_auth`)과는 별도 파일이며 자동 마이그레이션은 하지 않습니다. - */ -internal val Context.receiverAuthCodePreferencesDataStore: DataStore by preferencesDataStore( - name = "afternote_receiver_auth", -) diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/receiver/ReceiverRepositoryImpl.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/receiver/ReceiverRepositoryImpl.kt index 1fa7c2680e..d716fe4fbd 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/receiver/ReceiverRepositoryImpl.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/receiver/ReceiverRepositoryImpl.kt @@ -45,10 +45,6 @@ class ReceiverRepositoryImpl authCodeDataSource.saveCode(code) } - override suspend fun clearAuthCode() { - authCodeDataSource.clearCode() - } - override fun getPagedReceivedAfternotes(): Flow> = Pager( config = PagingConfig(pageSize = PAGE_SIZE), diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/receiver/ReceiverRepository.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/receiver/ReceiverRepository.kt index 06f7385244..50b747cc85 100644 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/receiver/ReceiverRepository.kt +++ b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/receiver/ReceiverRepository.kt @@ -21,12 +21,9 @@ interface ReceiverRepository { /** 단발 조회; UI 스레드에서는 코루틴 안에서 호출하세요. */ suspend fun currentAuthCode(): String? - /** 사용자가 입력·검증한 코드를 저장합니다. */ + /** 사용자가 입력·검증한 코드를 저장합니다. 제거는 로그아웃 일괄 정리(SESSION scope, #912)가 담당합니다. */ suspend fun saveAuthCode(code: String) - /** 로그아웃·계정 전환·초기화 시 저장 코드를 제거합니다. */ - suspend fun clearAuthCode() - /** * 수신 애프터노트 스트림. 서버는 페이지네이션 미지원이므로 단일 페이지로 받지만, * Paging 3 API(LoadState/refresh/cachedIn) 통일과 추후 페이지네이션 도입을 위해 PagingData로 노출한다. diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/MasterKeyViewModelTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/MasterKeyViewModelTest.kt index b3b4ade093..d8ce92f503 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/MasterKeyViewModelTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/MasterKeyViewModelTest.kt @@ -166,10 +166,6 @@ private class RecordingReceiverRepository : ReceiverRepository { storedAuthCode.value = code } - override suspend fun clearAuthCode() { - storedAuthCode.value = null - } - override fun getPagedReceivedAfternotes(): Flow> = flowOf(PagingData.empty()) override suspend fun getReceivedAfterNotes(): Result = error("호출되면 안 됨") diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistViewModelTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistViewModelTest.kt index f7a5734760..0a982de82d 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistViewModelTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistViewModelTest.kt @@ -112,8 +112,6 @@ private class FakeReceiverRepository( override suspend fun saveAuthCode(code: String) = Unit - override suspend fun clearAuthCode() = Unit - override fun getPagedReceivedAfternotes(): Flow> = emptyFlow() override suspend fun getReceivedAfterNotes(): Result { From ec067418357c8c8c66820f6596be481809069f2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 23 Aug 2026 20:20:46 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83?= =?UTF-8?q?=C2=B7=ED=83=88=ED=87=B4=20=EC=8B=9C=20SESSION=20=EC=8A=A4?= =?UTF-8?q?=EC=BD=94=ED=94=84=20=EB=A1=9C=EC=BB=AC=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=20=EC=9D=BC=EA=B4=84=20=EC=A0=95=EB=A6=AC=20(#912)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LocalStoreRegistryImpl — (name→scope) 등록을 매니페스트 파일에 영속해, Hilt lazy 주입으로 이번 프로세스에서 아직 안 열린 저장소도 clearScope 가 열어서 지운다 - DI 모듈 4개를 registry.store(name, scope) 경유로 전환 — 파일명·경로 불변(기존 사용자 데이터 연속), 반환은 DataStore 그대로라 원자적 다중 키 edit 보존 - 4개 전부 SESSION 확정. IdentityVerification 도 SESSION — 인증 기록이 로컬 boolean 뿐이라 계정 전환을 감지 못 하므로, 로그아웃 시 지워 타 계정의 인증 상태 승계를 막는다 (재로그인 시 재인증 감수, #912 확인 필요 항목 해소) - logout()·clearSession() 이 clearScope(SESSION) 호출 — 탈퇴(deleteAccount) 경로도 clearSession 을 쓰므로 함께 정리. 서버 호출 뒤 정리 순서 유지 --- .../data/repoimpl/auth/AuthRepositoryImpl.kt | 25 ++- .../core/datastore/LocalStoreRegistryImpl.kt | 204 ++++++++++++++++++ .../core/datastore/di/TokenDataStoreModule.kt | 11 +- .../di/UserProfileDataStoreModule.kt | 11 +- .../di/IdentityVerificationDataStoreModule.kt | 14 +- .../di/ReceiverAuthCodeDataStoreModule.kt | 12 +- 6 files changed, 240 insertions(+), 37 deletions(-) create mode 100644 core/datastore/src/main/java/com/afternote/core/datastore/LocalStoreRegistryImpl.kt diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImpl.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImpl.kt index a91b32cf10..d34b0d9c37 100644 --- a/core/data/src/main/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImpl.kt +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImpl.kt @@ -2,6 +2,8 @@ package com.afternote.core.data.repoimpl.auth import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.data.mapper.auth.AuthMapper +import com.afternote.core.datastore.LocalStoreRegistry +import com.afternote.core.datastore.StoreScope import com.afternote.core.datastore.TokenDataSource import com.afternote.core.domain.error.InvalidLoginCredentialsException import com.afternote.core.domain.error.NetworkUnavailableException @@ -30,15 +32,14 @@ class AuthRepositoryImpl private val tokenApiService: TokenApiService, // 발급(로그인) 응답의 expiresIn 으로 선제 reissue deadline 을 기록하고, 세션 종료 시 함께 정리한다 (#408/#410). private val expiryTracker: AccessTokenExpiryTracker, + // 로그아웃·탈퇴 시 SESSION 스코프 로컬 저장소 일괄 정리 (#912). + private val localStoreRegistry: LocalStoreRegistry, ) : AuthRepository { override suspend fun clearSession() = runCatchingCancellable { - tokenDataSource.clearTokens() - // deadline 은 세션의 산물 — 세션을 끝내는 책임이 여기 있으므로 토큰과 함께 정리한다. - // 남기면 재로그인 후 이전 토큰 기준 deadline 으로 임박을 오판한다. - // TokenDataSource.clearTokens() 내부로 내리지 않는 이유: datastore 는 저장만 아는 - // 잎 모듈이라 network 의 tracker 를 알면 의존 방향이 뒤집히고, 기록(record)은 이 - // 레이어(로그인 응답·TokenReissuer)가 하므로 폐기만 내리면 수명 관리가 두 레이어로 찢어진다. + localStoreRegistry.clearScope(StoreScope.SESSION) + // tracker 는 network 계층 in-memory 상태라 레지스트리(디스크 저장소 전담) 관할 밖. + // 남기면 재로그인 후 이전 토큰 기준 deadline 으로 만료 임박을 오판한다. expiryTracker.clear() } @@ -128,7 +129,7 @@ class AuthRepositoryImpl /** * 서버 로그아웃은 best-effort (네트워크 실패해도 사용자는 로그아웃 상태로 가야 함). - * 로컬 토큰과 선제 reissue deadline 은 서버 호출 결과와 무관하게 항상 정리한다. + * SESSION 스코프 로컬 저장소와 선제 reissue deadline 은 서버 호출 결과와 무관하게 항상 정리한다. */ override suspend fun logout(): Result = runCatchingCancellable { @@ -136,12 +137,10 @@ class AuthRepositoryImpl if (refreshToken != null) { runCatchingCancellable { authApiService.logout(LogoutRequestDto(refreshToken)) } } - tokenDataSource.clearTokens() - // deadline 은 방금 지운 액세스 토큰의 잔여 수명 기록 — 토큰이 사라지는 순간 의미를 잃으므로 - // 세션을 끝내는 여기서 함께 버린다 (남기면 재로그인 후 죽은 토큰 기준으로 임박 오판). - // 반드시 위 API 호출 뒤여야 한다: 로그아웃 HTTP 요청도 AuthInterceptor 를 지나므로, 토큰이 - // 만료 임박이면 요청 직전에 선제 reissue(401 을 기다리지 않고 미리 새 토큰 쌍으로 교체)가 - // 일어나 tracker 에 새 deadline 이 기록될 수 있다 — clear 를 먼저 하면 그 기록이 되살아난다. + // 두 정리 모두 반드시 위 API 호출 뒤여야 한다. 로그아웃 HTTP 요청도 AuthInterceptor 를 + // 지나므로 그 시점엔 토큰이 살아 있어야 하고, 토큰이 만료 임박이면 요청 직전 선제 reissue 가 + // tracker 에 새 deadline 을 기록할 수 있다 — tracker 를 먼저 비우면 그 기록이 되살아난다. + localStoreRegistry.clearScope(StoreScope.SESSION) expiryTracker.clear() } diff --git a/core/datastore/src/main/java/com/afternote/core/datastore/LocalStoreRegistryImpl.kt b/core/datastore/src/main/java/com/afternote/core/datastore/LocalStoreRegistryImpl.kt new file mode 100644 index 0000000000..9a73ff296e --- /dev/null +++ b/core/datastore/src/main/java/com/afternote/core/datastore/LocalStoreRegistryImpl.kt @@ -0,0 +1,204 @@ +package com.afternote.core.datastore + +import android.content.Context +import android.util.Log +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringSetPreferencesKey +import androidx.datastore.preferences.preferencesDataStoreFile +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.job +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import java.io.File +import java.io.IOException +import javax.inject.Inject + +/** + * [LocalStoreRegistry] 구현. + * + * 파일 경로는 기존 `preferencesDataStore` 델리게이트와 동일한 + * `files/datastore/.preferences_pb` — 델리게이트 → 레지스트리 전환에서 기존 사용자 + * 데이터가 그대로 이어진다. + * + * (name → scope) 등록은 in-memory 캐시와 별도로 자체 매니페스트 파일에도 영속한다. + * Hilt `@Provides` 는 최초 주입 때에야 실행되므로, in-memory 만으로는 "이전 프로세스에서 + * 기록되고 이번 프로세스에서 아직 안 열린" 저장소를 [clearScope] 가 놓친다 — 매니페스트가 + * 그 구멍을 막는다. 매니페스트 기록이 유실된 이름은 다음 획득 때 다시 기록되므로 자가 복구된다. + */ +internal class LocalStoreRegistryImpl( + private val produceFile: (name: String) -> File, + private val registryScope: CoroutineScope, +) : LocalStoreRegistry { + @Inject + constructor( + @ApplicationContext context: Context, + ) : this( + produceFile = { name -> context.preferencesDataStoreFile(name) }, + registryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), + ) + + /** [registeredByCode] false = 매니페스트 기록만으로 열림([clearScope] 경유) — 코드의 scope 선언이 오면 그쪽이 정본. */ + private class Entry( + var scope: StoreScope, + val dataStore: DataStore, + var registeredByCode: Boolean, + ) + + private val lock = Any() + private val entries = mutableMapOf() + private val pendingManifestJobs = mutableListOf() + + private val manifest: DataStore by lazy { + PreferenceDataStoreFactory.create(scope = newStoreScope()) { produceFile(MANIFEST_NAME) } + } + + override fun store( + name: String, + scope: StoreScope, + ): DataStore { + require(name != MANIFEST_NAME) { "'$MANIFEST_NAME' 은 레지스트리 예약 이름입니다." } + val (dataStore, needsRecord) = + synchronized(lock) { + val existing = entries[name] + when { + existing == null -> { + val created = createStore(name) + entries[name] = Entry(scope, created, registeredByCode = true) + created to true + } + + existing.registeredByCode -> { + check(existing.scope == scope) { + "저장소 '$name' 의 scope 충돌: ${existing.scope} 로 이미 등록됐는데 $scope 로 재요청됨" + } + existing.dataStore to false + } + + else -> { + val scopeChanged = existing.scope != scope + existing.scope = scope + existing.registeredByCode = true + existing.dataStore to scopeChanged + } + } + } + if (needsRecord) scheduleManifestRecord(name, scope) + return dataStore + } + + override suspend fun clearScope(scope: StoreScope) { + awaitPendingRegistrations() + val manifestNames = readManifestNames(scope) + val targets = + synchronized(lock) { + val inMemoryNames = entries.filterValues { it.scope == scope }.keys + (manifestNames + inMemoryNames).mapNotNull { name -> + val entry = entries[name] + when { + entry == null -> { + // 이번 프로세스에서 아직 획득되지 않은 저장소 — 매니페스트 기록으로 연다. + // 캐시에 넣어 이후 store() 가 같은 인스턴스를 받게 한다 (파일당 1개 강제). + val created = createStore(name) + entries[name] = Entry(scope, created, registeredByCode = false) + created + } + + entry.scope == scope -> { + entry.dataStore + } + + else -> { + // 코드에서 scope 가 바뀐 저장소 — 매니페스트가 낡았다. 지우지 않는다. + null + } + } + } + } + targets.forEach { store -> store.edit { it.clear() } } + } + + /** 아직 디스크에 안 간 매니페스트 기록을 기다린다 — [clearScope] 선행 단계이자 재기동 테스트의 flush 지점. */ + internal suspend fun awaitPendingRegistrations() { + synchronized(lock) { pendingManifestJobs.toList() }.joinAll() + } + + private fun createStore(name: String): DataStore = + PreferenceDataStoreFactory.create(scope = newStoreScope()) { + produceFile(name) + } + + /** + * 저장소별 독립 [SupervisorJob] 을 [registryScope] 아래에 둔다 — 한 저장소의 실패가 다른 + * 저장소로 번지지 않으면서, 부모 취소(테스트의 프로세스 종료 시뮬레이션) 시 함께 닫혀 + * DataStore 의 "파일당 활성 인스턴스 1개" 등록이 해제된다. + */ + private fun newStoreScope(): CoroutineScope = + CoroutineScope( + registryScope.coroutineContext + SupervisorJob(registryScope.coroutineContext.job), + ) + + private fun scheduleManifestRecord( + name: String, + scope: StoreScope, + ) { + val job = + registryScope.launch { + // 실패해도 앱은 계속 가야 한다 — 이 이름은 다음 획득 때 재기록되고, + // 이번 프로세스의 clearScope 는 in-memory 등록으로 커버된다. + runCatching { recordInManifest(name, scope) } + .onFailure { Log.e(TAG, "매니페스트 기록 실패($name): ${it.javaClass.name}") } + } + synchronized(lock) { pendingManifestJobs += job } + job.invokeOnCompletion { synchronized(lock) { pendingManifestJobs.remove(job) } } + } + + private suspend fun recordInManifest( + name: String, + scope: StoreScope, + ) { + manifest.edit { prefs -> + StoreScope.entries.forEach { candidate -> + val key = manifestKey(candidate) + val current = prefs[key] ?: emptySet() + // 소속 scope 에는 더하고 나머지에서는 빼서, scope 이동 시 낡은 기록이 남지 않게 한다. + prefs[key] = if (candidate == scope) current + name else current - name + } + } + } + + private suspend fun readManifestNames(scope: StoreScope): Set = + runCatching { manifest.data.first()[manifestKey(scope)] } + .getOrElse { exception -> + if (exception is IOException) { + // 매니페스트 손상 — in-memory 등록만으로 진행한다. 이름들은 다음 획득 때 재기록된다. + Log.e(TAG, "매니페스트 읽기 실패: ${exception.javaClass.name}") + null + } else { + throw exception + } + }.orEmpty() + + private fun manifestKey(scope: StoreScope): Preferences.Key> = + when (scope) { + StoreScope.SESSION -> SESSION_NAMES + StoreScope.DEVICE -> DEVICE_NAMES + } + + companion object { + private const val TAG = "LocalStoreRegistry" + + /** 레지스트리 자신의 (name → scope) 영속 기록 파일 — [store] 의 name 으로 쓸 수 없다. */ + internal const val MANIFEST_NAME = "local_store_registry" + + private val SESSION_NAMES = stringSetPreferencesKey("session_store_names") + private val DEVICE_NAMES = stringSetPreferencesKey("device_store_names") + } +} diff --git a/core/datastore/src/main/java/com/afternote/core/datastore/di/TokenDataStoreModule.kt b/core/datastore/src/main/java/com/afternote/core/datastore/di/TokenDataStoreModule.kt index 937f8df1a4..9a63d3e34d 100644 --- a/core/datastore/src/main/java/com/afternote/core/datastore/di/TokenDataStoreModule.kt +++ b/core/datastore/src/main/java/com/afternote/core/datastore/di/TokenDataStoreModule.kt @@ -1,13 +1,12 @@ package com.afternote.core.datastore.di -import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences -import com.afternote.core.datastore.tokenPreferencesDataStore +import com.afternote.core.datastore.LocalStoreRegistry +import com.afternote.core.datastore.StoreScope import dagger.Module import dagger.Provides import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @@ -17,7 +16,7 @@ internal object TokenDataStoreModule { @Provides @Singleton @TokenDataStore - fun provideTokenDataStore( - @ApplicationContext context: Context, - ): DataStore = context.tokenPreferencesDataStore + fun provideTokenDataStore(registry: LocalStoreRegistry): DataStore = + // name 은 저장 파일명 계약 — 바꾸면 기존 사용자 로그인이 풀린다 (#912 필수 주의). + registry.store(name = "Token", scope = StoreScope.SESSION) } diff --git a/core/datastore/src/main/java/com/afternote/core/datastore/di/UserProfileDataStoreModule.kt b/core/datastore/src/main/java/com/afternote/core/datastore/di/UserProfileDataStoreModule.kt index fd605463dc..bcd63817d1 100644 --- a/core/datastore/src/main/java/com/afternote/core/datastore/di/UserProfileDataStoreModule.kt +++ b/core/datastore/src/main/java/com/afternote/core/datastore/di/UserProfileDataStoreModule.kt @@ -1,13 +1,12 @@ package com.afternote.core.datastore.di -import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences -import com.afternote.core.datastore.userProfilePreferencesDataStore +import com.afternote.core.datastore.LocalStoreRegistry +import com.afternote.core.datastore.StoreScope import dagger.Module import dagger.Provides import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @@ -17,7 +16,7 @@ internal object UserProfileDataStoreModule { @Provides @Singleton @UserProfileDataStore - fun provideUserProfileDataStore( - @ApplicationContext context: Context, - ): DataStore = context.userProfilePreferencesDataStore + fun provideUserProfileDataStore(registry: LocalStoreRegistry): DataStore = + // name 은 저장 파일명 계약 — 바꾸면 기존 사용자의 프로필 캐시가 끊긴다 (#912 필수 주의). + registry.store(name = "UserProfile", scope = StoreScope.SESSION) } diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/IdentityVerificationDataStoreModule.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/IdentityVerificationDataStoreModule.kt index 45bf8a1c23..64e3ef8187 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/IdentityVerificationDataStoreModule.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/IdentityVerificationDataStoreModule.kt @@ -1,13 +1,12 @@ package com.afternote.feature.afternote.data.di -import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences -import com.afternote.feature.afternote.data.local.identityVerificationPreferencesDataStore +import com.afternote.core.datastore.LocalStoreRegistry +import com.afternote.core.datastore.StoreScope import dagger.Module import dagger.Provides import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @@ -17,7 +16,10 @@ internal object IdentityVerificationDataStoreModule { @Provides @Singleton @IdentityVerificationDataStore - fun provideIdentityVerificationDataStore( - @ApplicationContext context: Context, - ): DataStore = context.identityVerificationPreferencesDataStore + fun provideIdentityVerificationDataStore(registry: LocalStoreRegistry): DataStore = + // name 은 저장 파일명 계약 — 바꾸면 기존 사용자의 본인인증 상태가 끊긴다 (#912 필수 주의). + // SESSION 인 이유 — 인증 기록이 이 로컬 boolean 뿐이라 계정 전환을 감지하지 못하므로, + // 로그아웃 시 지워서 다음 계정이 이전 사용자의 본인인증 완료 상태를 승계하지 못하게 + // 한다. 같은 사람의 재로그인 시 재인증은 감수한다 (#912). + registry.store(name = "afternote_identity_verification", scope = StoreScope.SESSION) } diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/ReceiverAuthCodeDataStoreModule.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/ReceiverAuthCodeDataStoreModule.kt index 4ebff2b3e6..9ac1bf5f77 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/ReceiverAuthCodeDataStoreModule.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/ReceiverAuthCodeDataStoreModule.kt @@ -1,13 +1,12 @@ package com.afternote.feature.afternote.data.di -import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences -import com.afternote.feature.afternote.data.local.receiverAuthCodePreferencesDataStore +import com.afternote.core.datastore.LocalStoreRegistry +import com.afternote.core.datastore.StoreScope import dagger.Module import dagger.Provides import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @@ -17,7 +16,8 @@ internal object ReceiverAuthCodeDataStoreModule { @Provides @Singleton @ReceiverAuthCodeDataStore - fun provideReceiverAuthCodeDataStore( - @ApplicationContext context: Context, - ): DataStore = context.receiverAuthCodePreferencesDataStore + fun provideReceiverAuthCodeDataStore(registry: LocalStoreRegistry): DataStore = + // name 은 저장 파일명 계약 (#912 필수 주의). 구 SharedPreferences XML 과 같은 이름이지만 + // 자동 마이그레이션하지 않는다는 기존 결정이 있다 — 마이그레이션을 붙이지 말 것. + registry.store(name = "afternote_receiver_auth", scope = StoreScope.SESSION) } From 0c08ccedf649297a461b34631e1367eea19d58e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 23 Aug 2026 20:23:48 +0900 Subject: [PATCH 3/3] =?UTF-8?q?test:=20=EC=A0=9C=EA=B1=B0=EB=90=9C=20clear?= =?UTF-8?q?AuthCode=C2=B7clear=20=EC=98=A4=EB=B2=84=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=EB=A5=BC=20androidTest=20fake=20=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20(#912)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit develop 머지로 들어온 androidTest fake 3곳(ReceiverRepository 2, UserProfileRepository 1)이 #912 에서 제거된 인터페이스 메서드를 override 하고 있어 정리한다. :app:compileDebugAndroidTestKotlin 의 나머지 실패 44건은 순수 origin/develop 에서도 동일 재현되는 기존 결함 — 에러 집합 대조로 본 브랜치 신규 깨짐 0건 확인. --- .../afternote_fe/AppAndReceiverCompletionAndroidTest.kt | 4 ---- .../afternote/afternote_fe/ReceiverAdvancedAndroidTest.kt | 4 ---- .../java/com/afternote/afternote_fe/test/TestFakes.kt | 5 ----- 3 files changed, 13 deletions(-) diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt index 975fb75c55..78a99bebfc 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt @@ -747,10 +747,6 @@ private class CompletionReceiverRepository : ReceiverRepository { authCode.value = code } - override suspend fun clearAuthCode() { - authCode.value = null - } - override fun getPagedReceivedAfternotes(): Flow> = flowOf(PagingData.empty()) override suspend fun getReceivedAfterNotes(): Result { diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverAdvancedAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverAdvancedAndroidTest.kt index 7a90d4544d..6d9c12d450 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverAdvancedAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverAdvancedAndroidTest.kt @@ -295,10 +295,6 @@ private class AdvancedReceiverRepository : ReceiverRepository { authCode.value = code } - override suspend fun clearAuthCode() { - authCode.value = null - } - override fun getPagedReceivedAfternotes(): Flow> = flowOf(PagingData.empty()) override suspend fun getReceivedAfterNotes(): Result = diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt index 43f4753af8..98cc07325c 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt @@ -227,11 +227,6 @@ class FakeUserProfileRepository : UserProfileRepository { override suspend fun saveUserName(name: String) { cachedUserName = name } - - override suspend fun clear() { - cachedUserName = null - passkeyRegistered.value = false - } } class FakeDiaryRepository(