Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
Expand Up @@ -33,6 +33,7 @@ import com.afternote.feature.receiver.domain.model.ReceivedExportBundle
import com.afternote.feature.receiver.domain.model.ReceiverIdentity
import com.afternote.feature.receiver.domain.testing.FakeReceiverAuthRepository
import com.afternote.feature.receiver.domain.testing.FakeReceiverRepository
import com.afternote.feature.receiver.domain.testing.FakeSenderRegistryRepository
import com.afternote.feature.receiver.presentation.deliveryverification.MasterKeyScreen
import com.afternote.feature.receiver.presentation.deliveryverification.MasterKeyViewModel
import com.afternote.feature.receiver.presentation.recordsbox.ReceivedRecordsScreen
Expand Down Expand Up @@ -62,7 +63,8 @@ class ReceiverAdvancedAndroidTest {

@Test
fun senderRegistration_recordsEntryAndMasterKey_shareExactReceiverContext() {
val senderRegistry = SenderRegistry()
val senderRegistryRepository = FakeSenderRegistryRepository()
val senderRegistry = SenderRegistry(senderRegistryRepository)
val registrationViewModel = SenderRegistrationViewModel(senderRegistry)
val recordsViewModel = ReceivedRecordsViewModel(senderRegistry)
val masterKeyResults = ArrayDeque<Result<ReceiverIdentity>>()
Expand Down Expand Up @@ -141,7 +143,7 @@ class ReceiverAdvancedAndroidTest {

composeRule.onNodeWithText("받은 기록함").assertIsDisplayed()
composeRule.onNodeWithText("가족 별칭").assertIsDisplayed().performClick()
val sender = senderRegistry.senders.value.single()
val sender = senderRegistryRepository.senderEntries.value.single()
assertEquals(1, registeredTransitions)
assertEquals("가족 별칭", sender.name)
assertEquals(sender.id, selectedSenderId)
Expand All @@ -157,7 +159,7 @@ class ReceiverAdvancedAndroidTest {
composeRule.waitUntil(timeoutMillis = 5_000) { verifiedTransitions == 1 }

val normalizedMasterKey = "3f2504e0-4f89-11d3-9a0c-0305e82c3301"
val attached = checkNotNull(senderRegistry.findById(sender.id))
val attached = checkNotNull(senderRegistryRepository.senderEntries.value.firstOrNull { it.id == sender.id })
assertEquals(listOf(normalizedMasterKey), authRepository.verifiedMasterKeys)
assertEquals(listOf(normalizedMasterKey), receiverRepository.savedMasterKeys)
assertEquals(normalizedMasterKey, receiverRepository.masterKeyState.value)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package com.afternote.feature.receiver.data.di

import com.afternote.feature.receiver.data.repositoryimpl.IdentityVerificationRepositoryImpl
import com.afternote.feature.receiver.data.repositoryimpl.ReceiverRepositoryImpl
import com.afternote.feature.receiver.data.repositoryimpl.SenderRegistryRepositoryImpl
import com.afternote.feature.receiver.domain.repository.IdentityVerificationRepository
import com.afternote.feature.receiver.domain.repository.ReceiverRepository
import com.afternote.feature.receiver.domain.repository.SenderRegistryRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
Expand All @@ -20,4 +22,8 @@ abstract class AfternoteReceiverRepositoryModule {
@Binds
@Singleton
abstract fun bindIdentityVerificationRepository(impl: IdentityVerificationRepositoryImpl): IdentityVerificationRepository

@Binds
@Singleton
abstract fun bindSenderRegistryRepository(impl: SenderRegistryRepositoryImpl): SenderRegistryRepository
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.afternote.feature.receiver.data.di

import javax.inject.Qualifier

/** Hilt 한정자: 발신자별 본인 확인 완료 상태 전용 `DataStore<Preferences>` 바인딩. */
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IdentityVerificationDataStore
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.afternote.feature.receiver.data.di

import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
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.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
internal object IdentityVerificationDataStoreModule {
@Provides
@Singleton
@IdentityVerificationDataStore
fun provideIdentityVerificationDataStore(registry: LocalStoreRegistry): DataStore<Preferences> =
// 파일명·SESSION 수명 계약은 유지한다. 과거의 동적 boolean 키는 도달 불가능한 임시 ID용이라
// 무시하고, 이제 재기동 뒤에도 안정적인 senderId를 아래 저장소의 새 단일 집합에 기록한다.
registry.store(name = "afternote_identity_verification", scope = StoreScope.SESSION)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.afternote.feature.receiver.data.di

import javax.inject.Qualifier

/** Hilt 한정자: 발신자 카드 레지스트리 전용 `DataStore<Preferences>` 바인딩. */
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class SenderRegistryDataStore
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.afternote.feature.receiver.data.di

import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
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.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
internal object SenderRegistryDataStoreModule {
@Provides
@Singleton
@SenderRegistryDataStore
fun provideSenderRegistryDataStore(registry: LocalStoreRegistry): DataStore<Preferences> =
registry.store(name = "afternote_sender_registry", scope = StoreScope.SESSION)
}
Original file line number Diff line number Diff line change
@@ -1,55 +1,64 @@
package com.afternote.feature.receiver.data.repositoryimpl

import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.afternote.feature.receiver.data.di.IdentityVerificationDataStore
import com.afternote.feature.receiver.domain.repository.IdentityVerificationRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import javax.inject.Inject
import javax.inject.Singleton

private object IdentityVerificationKeys {
val VERIFIED_SENDER_IDS = stringSetPreferencesKey("identity_verified_sender_ids")
}

/**
* [IdentityVerificationRepository] 의 프로세스 수명 in-memory 구현.
* 발신자별 본인 확인 완료 상태를 SESSION Preferences DataStore에 보관한다.
*
* 발신자별 1회 검증 정책 (#597) — 발신자 A 인증이 발신자 B 의 관문을 열지 않도록 senderId 단위로
* 격리해 보관한다.
*
* ## 왜 in-memory 인가 (#597 리뷰 반영)
*
* 키인 `senderId` 는 presentation 의 `SenderRegistry` 가 `UUID.randomUUID()` 로 발급하는
* **프로세스 수명 값**이다 (registry 자체가 in-memory stub, #215).
* 앱 재시작 시 같은 발신자라도 재등록으로 새 UUID 를 받으므로,
* - 디스크에 남긴 이전 UUID 키는 **다시는 조회되지 않고** (영속의 실효 없음),
* - 인증 1회마다 죽은 키가 preferences 파일에 **단조 누적**되어 읽기 비용만 키운다.
* ## 왜 다시 영속인가 (#599)
*
* 그래서 저장 수명을 키 발급처와 같은 프로세스 수명으로 맞춘다. #912 가 도입했던 DataStore
* (SESSION scope) 는 전역 boolean 시절의 「process death 후 유지」가 목적이었으나, 발신자별
* UUID 키에서는 그 유지가 도달 불가능해 디스크 비용만 남는다. 프로세스를 넘는 발신자 식별자
* (예: BE 발급 sender id, 또는 마스터 키의 비가역 파생값) 가 생기면 그때 영속을 되살린다.
* 키인 `senderId` 는 [SenderRegistryRepositoryImpl] 이 발급하는 로컬 카드 ID 다. #597 시점에는 그
* 발급처가 in-memory stub 이라 앱을 재시작하면 같은 발신자도 새 UUID 를 받았고, 그래서 디스크에 남긴
* 키는 «다시는 조회되지 않는 죽은 값» 으로 단조 누적되기만 했다 — 그 이유로 저장 수명을 프로세스에
* 맞췄다.
*
* 구 릴리스가 디스크에 남긴 전역 `identity_verified` boolean · `identity_verified_<UUID>` 잔존값은
* 본 구현이 디스크를 아예 읽지 않으므로 구조적으로 어떤 발신자의 관문도 열 수 없다.
* (잔존 파일 `afternote_identity_verification` 은 SESSION scope 매니페스트 등록이 디스크에 남아 있어
* 다음 로그아웃 때 [com.afternote.core.datastore.LocalStoreRegistry.clearScope] 가 마저 비운다.)
* #599 가 그 전제를 없앴다. 카드 ID 가 재기동 뒤에도 그대로 복원되므로 같은 ID 로 저장한 확인 상태도
* 재기동 뒤 유효하다. 그래서 SESSION scope 영속으로 되돌린다 — 로그아웃 시
* [com.afternote.core.datastore.LocalStoreRegistry.clearScope] 가 카드와 함께 지운다.
*
* `@Singleton` — 열람 신청 흐름의 ViewModel 들이 같은 인스턴스를 공유해야 캐시가 의미를 갖는다.
*/
@Singleton
class IdentityVerificationRepositoryImpl
@Inject
constructor() : IdentityVerificationRepository {
private val verifiedSenderIds = MutableStateFlow<Set<String>>(emptySet())

constructor(
@param:IdentityVerificationDataStore private val dataStore: DataStore<Preferences>,
) : IdentityVerificationRepository {
override fun isVerified(senderId: String): Flow<Boolean> =
// 왜 Flow? 값이 시간에 따라 변하고 (verify 완료 시 false → true),
// UI 가 그 변화를 reactive 하게 받아 화면 갱신해야 해서. 단발 조회면 suspend 로 충분했지만 시계열.
verifiedSenderIds
.map { senderId in it }
// 다른 발신자의 markVerified 로 Set 이 바뀌어도 이 발신자의 boolean 이 그대로면 재방출하지 않는다.
.distinctUntilChanged()
dataStore.data
.map { preferences -> senderId in preferences[IdentityVerificationKeys.VERIFIED_SENDER_IDS].orEmpty() }
.catch { failure ->
if (failure is CancellationException) throw failure
if (failure is Exception) {
emit(false)
} else {
throw failure
}
}.distinctUntilChanged()

override suspend fun markVerified(senderId: String) {
verifiedSenderIds.update { it + senderId }
dataStore.edit { preferences ->
val verified = preferences[IdentityVerificationKeys.VERIFIED_SENDER_IDS].orEmpty()
preferences[IdentityVerificationKeys.VERIFIED_SENDER_IDS] = verified + senderId
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package com.afternote.feature.receiver.data.repositoryimpl

import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.afternote.core.common.result.runCatchingCancellable
import com.afternote.feature.receiver.data.di.SenderRegistryDataStore
import com.afternote.feature.receiver.domain.model.DeliveryVerificationStatus
import com.afternote.feature.receiver.domain.model.ReceiverIdentity
import com.afternote.feature.receiver.domain.model.SenderEntry
import com.afternote.feature.receiver.domain.repository.SenderRegistryRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton

private object SenderRegistryKeys {
val SNAPSHOT = stringPreferencesKey("sender_registry_snapshot")
}

private const val CURRENT_SCHEMA_VERSION = 1

@Serializable
private data class SenderRegistrySnapshot(
val schemaVersion: Int = CURRENT_SCHEMA_VERSION,
val senders: List<PersistedSenderEntry> = emptyList(),
)

@Serializable
private data class PersistedSenderEntry(
val id: String,
val name: String,
val masterKey: String? = null,
val realSenderName: String? = null,
val relation: String? = null,
val verificationStatus: String? = null,
)

/**
* Preferences DataStore에 발신자 카드의 순서 있는 단일 JSON 스냅샷을 저장한다.
*
* 이 저장소만 별도 암호화하지 않는다. [com.afternote.feature.receiver.data.local.ReceiverMasterKeyDataSource]와
* 토큰 저장소도 같은 종류의 비밀을 평문 DataStore에 보관하므로, 여기만 암호화하면 수명·마이그레이션
* 계약이 갈라진다. 저장소 전체를 아우르는 암호화 전환은 별도 시스템 마이그레이션으로 다룬다.
*/
@Singleton
class SenderRegistryRepositoryImpl
@Inject
constructor(
@param:SenderRegistryDataStore private val dataStore: DataStore<Preferences>,
) : SenderRegistryRepository {
private val json =
Json {
ignoreUnknownKeys = true
encodeDefaults = true
}

override val senders: Flow<List<SenderEntry>> =
dataStore.data
.map { preferences -> decode(preferences[SenderRegistryKeys.SNAPSHOT]) }
.catch { failure ->
if (failure is CancellationException) throw failure
if (failure is Exception) {
emit(emptyList())
} else {
throw failure
}
}

override suspend fun register(name: String): Result<SenderEntry> {
val entry = SenderEntry(id = UUID.randomUUID().toString(), name = name)
return runCatchingCancellable {
dataStore.edit { preferences ->
val current = decode(preferences[SenderRegistryKeys.SNAPSHOT])
preferences[SenderRegistryKeys.SNAPSHOT] = encode(current + entry)
}
entry
}
}

override suspend fun findById(id: String): Result<SenderEntry?> =
runCatchingCancellable {
val preferences = dataStore.data.first()
decode(preferences[SenderRegistryKeys.SNAPSHOT]).firstOrNull { it.id == id }
}

override suspend fun attachIdentity(
id: String,
masterKey: String,
identity: ReceiverIdentity,
): Result<SenderEntry?> =
updateById(id) { entry ->
entry.copy(
masterKey = masterKey,
realSenderName = identity.senderName,
relation = identity.relation,
)
}

override suspend fun updateVerificationStatus(
id: String,
status: DeliveryVerificationStatus,
): Result<SenderEntry?> = updateById(id) { it.copy(verificationStatus = status) }

private suspend fun updateById(
id: String,
transform: (SenderEntry) -> SenderEntry,
): Result<SenderEntry?> =
runCatchingCancellable {
var updated: SenderEntry? = null
dataStore.edit { preferences ->
val current = decode(preferences[SenderRegistryKeys.SNAPSHOT])
val next =
current.map { entry ->
if (entry.id == id) {
transform(entry).also { updated = it }
} else {
entry
}
}
if (updated != null) {
preferences[SenderRegistryKeys.SNAPSHOT] = encode(next)
}
}
updated
}

private fun decode(raw: String?): List<SenderEntry> {
if (raw == null) return emptyList()
val snapshot = json.decodeFromString<SenderRegistrySnapshot>(raw)
if (snapshot.schemaVersion != CURRENT_SCHEMA_VERSION) {
throw UnsupportedSenderRegistrySchemaException(snapshot.schemaVersion)
}
return snapshot.senders.map(PersistedSenderEntry::toDomain)
}

private fun encode(entries: List<SenderEntry>): String =
json.encodeToString(
SenderRegistrySnapshot(
senders = entries.map(SenderEntry::toPersisted),
),
)
}

private class UnsupportedSenderRegistrySchemaException(
version: Int,
) : IllegalStateException("지원하지 않는 sender registry schemaVersion=$version")

private fun PersistedSenderEntry.toDomain(): SenderEntry =
SenderEntry(
id = id,
name = name,
masterKey = masterKey,
realSenderName = realSenderName,
relation = relation,
// 우리가 [SenderEntry.toPersisted] 로 직접 쓴 `name` 을 되읽는다. 모르는 값이면 «캐시 없음» 으로
// 낮춘다 — 옛 릴리스가 남긴 이름이 사라졌을 때 [DeliveryVerificationStatus.UNKNOWN] 으로 흡수하면
// 「아직 신청 안 함」으로 그려져 버린다 (#1554 가 `fromRaw` 를 걷어낸 것과 같은 이유).
verificationStatus = verificationStatus?.let(DeliveryVerificationStatus::fromWireOrNull),
)

private fun SenderEntry.toPersisted(): PersistedSenderEntry =
PersistedSenderEntry(
id = id,
name = name,
masterKey = masterKey,
realSenderName = realSenderName,
relation = relation,
verificationStatus = verificationStatus?.name,
)
Loading
Loading