diff --git a/core/common/src/main/java/com/example/slowclock/util/Recurrence.kt b/core/common/src/main/java/com/example/slowclock/util/Recurrence.kt index 22fd9cd1..e72a14aa 100644 --- a/core/common/src/main/java/com/example/slowclock/util/Recurrence.kt +++ b/core/common/src/main/java/com/example/slowclock/util/Recurrence.kt @@ -41,8 +41,10 @@ enum class Recurrence( /** * 되풀이하는 일정의 회차를 세는 자리. * - * 기준 시각([baseMillis])은 첫 회차다. 회차의 시·분·초는 첫 회차와 같고 날짜만 규칙을 따른다. - * 계산은 기기의 현지 달력으로 한다 — 사용자가 「아침 8시」 라고 정한 것은 현지시각 8시다. + * 기준 시각은 저장된 첫 회차의 epoch 다. 이를 계산 당시 기기 시간대로 해석한 시·분·초와 + * 요일·일자를 반복한다. 생성 당시 시간대나 벽시계 시각은 따로 저장하지 않는다. 따라서 시간대가 + * 바뀌어도 생성 당시의 「8시」를 유지한다는 뜻은 아니다. 기존 epoch 와 회차 키는 이행하지 않는다. + * DST 중복·공백 시각은 Calendar 의 기본 해석을 따른다. 세부 경계는 docs/recurrence-time-policy.md. */ object RecurrenceRule { /** diff --git a/core/common/src/test/java/com/example/slowclock/util/RecurrenceTimeZoneTest.kt b/core/common/src/test/java/com/example/slowclock/util/RecurrenceTimeZoneTest.kt new file mode 100644 index 00000000..ab8f8b5e --- /dev/null +++ b/core/common/src/test/java/com/example/slowclock/util/RecurrenceTimeZoneTest.kt @@ -0,0 +1,96 @@ +package com.example.slowclock.util + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import java.time.OffsetDateTime +import java.util.TimeZone + +/** 기존 epoch 모델의 경계. 실행기 전체 기본값을 바꾸므로 매 테스트 뒤 원래 시간대로 돌린다. */ +class RecurrenceTimeZoneTest { + private lateinit var originalZone: TimeZone + + @Before + fun saveTimeZone() { + originalZone = TimeZone.getDefault() + } + + @After + fun restoreTimeZone() { + TimeZone.setDefault(originalZone) + } + + @Test + fun `서울에서 만든 epoch는 LA 계산 시 LA 시각과 요일로 해석한다`() { + val base = instant("2026-01-06T08:00:00+09:00") + TimeZone.setDefault(TimeZone.getTimeZone("America/Los_Angeles")) + + assertEquals( + instant("2026-01-08T15:00:00-08:00"), + RecurrenceRule.occurrenceOn(base, Recurrence.DAILY, instant("2026-01-08T00:00:00-08:00")), + ) + assertEquals( + instant("2026-01-12T15:00:00-08:00"), + RecurrenceRule.occurrenceOn(base, Recurrence.WEEKLY, instant("2026-01-12T00:00:00-08:00")), + ) + assertNull(RecurrenceRule.occurrenceOn(base, Recurrence.WEEKLY, instant("2026-01-13T00:00:00-08:00"))) + } + + @Test + fun `단발 일정의 epoch는 시간대 변경 뒤에도 그대로다`() { + val base = instant("2026-01-06T08:00:00+09:00") + TimeZone.setDefault(TimeZone.getTimeZone("America/Los_Angeles")) + + assertEquals(base, RecurrenceRule.occurrenceOn(base, Recurrence.NONE, instant("2026-01-05T00:00:00-08:00"))) + assertEquals(base, RecurrenceRule.nextOccurrenceAfter(base, Recurrence.NONE, base - 1)) + assertNull(RecurrenceRule.nextOccurrenceAfter(base, Recurrence.NONE, base)) + } + + @Test + fun `동일 시간대의 매일 아침 회차는 DST 시작 때 23시간 뒤다`() { + TimeZone.setDefault(TimeZone.getTimeZone("America/Los_Angeles")) + val base = instant("2026-02-01T08:00:00-08:00") + val before = instant("2026-03-07T08:00:00-08:00") + val after = RecurrenceRule.nextOccurrenceAfter(base, Recurrence.DAILY, before) + + assertEquals(instant("2026-03-08T08:00:00-07:00"), after) + assertEquals(23 * 60 * 60 * 1_000L, after!! - before) + } + + @Test + fun `호스트 Calendar의 DST 공백은 존재하는 이후 시각으로 보정한다`() { + TimeZone.setDefault(TimeZone.getTimeZone("America/Los_Angeles")) + val base = instant("2026-02-01T02:30:00-08:00") + + assertEquals( + instant("2026-03-08T03:30:00-07:00"), + RecurrenceRule.occurrenceOn(base, Recurrence.DAILY, instant("2026-03-08T00:00:00-08:00")), + ) + } + + @Test + fun `호스트 Calendar의 DST 중복은 늦은 offset의 회차 하나를 선택한다`() { + TimeZone.setDefault(TimeZone.getTimeZone("America/Los_Angeles")) + val base = instant("2026-10-01T01:30:00-07:00") + val occurrence = RecurrenceRule.occurrenceOn(base, Recurrence.DAILY, instant("2026-11-01T00:00:00-07:00")) + + assertEquals(instant("2026-11-01T01:30:00-08:00"), occurrence) + assertEquals( + instant("2026-11-02T01:30:00-08:00"), + RecurrenceRule.nextOccurrenceAfter(base, Recurrence.DAILY, occurrence!!), + ) + } + + @Test + fun `회차 키는 계산 당시 시간대의 날짜며 기존 키를 이행하지 않는다`() { + val base = instant("2026-01-06T08:00:00+09:00") + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")) + assertEquals("2026-01-06", RecurrenceRule.occurrenceKey(base)) + TimeZone.setDefault(TimeZone.getTimeZone("America/Los_Angeles")) + assertEquals("2026-01-05", RecurrenceRule.occurrenceKey(base)) + } + + private fun instant(value: String): Long = OffsetDateTime.parse(value).toInstant().toEpochMilli() +} diff --git a/core/data/src/main/java/com/example/slowclock/data/remote/repository/FamilyGroupRepository.kt b/core/data/src/main/java/com/example/slowclock/data/remote/repository/FamilyGroupRepository.kt index 7c4b246e..9821e26e 100644 --- a/core/data/src/main/java/com/example/slowclock/data/remote/repository/FamilyGroupRepository.kt +++ b/core/data/src/main/java/com/example/slowclock/data/remote/repository/FamilyGroupRepository.kt @@ -4,6 +4,7 @@ import com.example.slowclock.data.FirestoreCollections import com.google.firebase.Timestamp import com.google.firebase.firestore.FieldValue import com.google.firebase.firestore.FirebaseFirestore +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.tasks.await import javax.inject.Inject @@ -43,6 +44,8 @@ class FamilyGroupRepository ).await() } true + } catch (e: CancellationException) { + throw e } catch (e: Exception) { false } diff --git a/core/data/src/main/java/com/example/slowclock/data/remote/repository/NotificationRepository.kt b/core/data/src/main/java/com/example/slowclock/data/remote/repository/NotificationRepository.kt index 3ffe4d7a..49c43977 100644 --- a/core/data/src/main/java/com/example/slowclock/data/remote/repository/NotificationRepository.kt +++ b/core/data/src/main/java/com/example/slowclock/data/remote/repository/NotificationRepository.kt @@ -2,6 +2,7 @@ package com.example.slowclock.data.remote.repository import com.example.slowclock.data.FirestoreCollections import com.google.firebase.firestore.FirebaseFirestore +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.tasks.await import javax.inject.Inject @@ -29,6 +30,8 @@ class NotificationRepository batch.commit().await() } true + } catch (e: CancellationException) { + throw e } catch (e: Exception) { false } diff --git a/core/data/src/main/java/com/example/slowclock/data/remote/repository/ScheduleRepository.kt b/core/data/src/main/java/com/example/slowclock/data/remote/repository/ScheduleRepository.kt index 0d026736..b0517fd9 100644 --- a/core/data/src/main/java/com/example/slowclock/data/remote/repository/ScheduleRepository.kt +++ b/core/data/src/main/java/com/example/slowclock/data/remote/repository/ScheduleRepository.kt @@ -136,18 +136,6 @@ class ScheduleRepository return ScheduleResult.Error(AppError.AuthError) } - val startOfDay = calendar.clone() as Calendar - startOfDay.set(Calendar.HOUR_OF_DAY, 0) - startOfDay.set(Calendar.MINUTE, 0) - startOfDay.set(Calendar.SECOND, 0) - startOfDay.set(Calendar.MILLISECOND, 0) - - val endOfDay = calendar.clone() as Calendar - endOfDay.set(Calendar.HOUR_OF_DAY, 23) - endOfDay.set(Calendar.MINUTE, 59) - endOfDay.set(Calendar.SECOND, 59) - endOfDay.set(Calendar.MILLISECOND, 999) - return try { val documents = schedulesCollection diff --git a/core/data/src/main/java/com/example/slowclock/data/remote/repository/UserRepository.kt b/core/data/src/main/java/com/example/slowclock/data/remote/repository/UserRepository.kt index 8a774849..00f076a7 100644 --- a/core/data/src/main/java/com/example/slowclock/data/remote/repository/UserRepository.kt +++ b/core/data/src/main/java/com/example/slowclock/data/remote/repository/UserRepository.kt @@ -70,6 +70,8 @@ class UserRepository return try { val document = usersCollection.document(uid).get().await() document.toObject() + } catch (e: CancellationException) { + throw e } catch (e: Exception) { null } @@ -123,6 +125,8 @@ class UserRepository }.commit() .await() true + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.e(TAG, "사용자 문서 삭제 실패", e) false @@ -167,6 +171,8 @@ class UserRepository savePublicProfile(uid, if (name.isNotBlank()) name else existing.name) } true + } catch (e: CancellationException) { + throw e } catch (e: Exception) { // 공유 코드가 없으면 그 뒤에 만든 일정이 sharedCode 없이 저장되고, 보안 규칙의 // 공유 읽기 조건이 sharedCode != "" 라 가족이 어떤 코드로도 읽지 못한다. @@ -188,6 +194,8 @@ class UserRepository .document(uid) .set(PublicProfile(id = uid, name = name)) .await() + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.w(TAG, "공개 프로필 저장 실패", e) } @@ -220,6 +228,8 @@ class UserRepository .filter { (_, name) -> name.isNotBlank() } .toMap() } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.w(TAG, "공유 일정 소유자 이름 조회 실패", e) emptyMap() diff --git a/core/data/src/test/java/com/example/slowclock/data/remote/repository/AccountCleanupCancellationTest.kt b/core/data/src/test/java/com/example/slowclock/data/remote/repository/AccountCleanupCancellationTest.kt new file mode 100644 index 00000000..ba2573de --- /dev/null +++ b/core/data/src/test/java/com/example/slowclock/data/remote/repository/AccountCleanupCancellationTest.kt @@ -0,0 +1,85 @@ +package com.example.slowclock.data.remote.repository + +import com.google.android.gms.tasks.TaskCompletionSource +import com.google.android.gms.tasks.Tasks +import com.google.firebase.firestore.CollectionReference +import com.google.firebase.firestore.DocumentReference +import com.google.firebase.firestore.DocumentSnapshot +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.Query +import com.google.firebase.firestore.QuerySnapshot +import com.google.firebase.firestore.WriteBatch +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class AccountCleanupCancellationTest { + private val firestore = mockk() + private val collection = mockk() + private val owned = mockk() + private val joined = mockk() + private val snapshot = mockk() + private val document = mockk() + private val reference = mockk() + private val batch = mockk() + + init { + every { firestore.collection(any()) } returns collection + every { collection.whereEqualTo(any(), any()) } returns owned + every { collection.whereArrayContains("memberIds", "owner") } returns joined + every { owned.get() } returns Tasks.forResult(snapshot) + every { joined.get() } returns Tasks.forResult(snapshot) + every { snapshot.documents } returns listOf(document) + every { document.reference } returns reference + every { reference.delete() } returns Tasks.forResult(null) + every { firestore.batch() } returns batch + every { batch.delete(reference) } returns batch + } + + @Test + fun `알림 조회 취소는 삭제 batch를 시작하지 않는다`() = + runTest { + every { owned.get() } returns TaskCompletionSource().task + assertCancellationStops { NotificationRepository(firestore).deleteAllNotificationsOf("owner") } + verify(exactly = 0) { firestore.batch() } + } + + @Test + fun `알림 삭제 commit 대기 취소는 실패 반환으로 바꾸지 않는다`() = + runTest { + every { batch.commit() } returns TaskCompletionSource().task + assertCancellationStops { NotificationRepository(firestore).deleteAllNotificationsOf("owner") } + } + + @Test + fun `소유 그룹 조회 취소는 그룹을 삭제하지 않는다`() = + runTest { + every { owned.get() } returns TaskCompletionSource().task + assertCancellationStops { FamilyGroupRepository(firestore).leaveAllGroupsOf("owner") } + verify(exactly = 0) { reference.delete() } + } + + @Test + fun `소유 그룹 삭제 대기 취소는 참여 그룹 조회를 시작하지 않는다`() = + runTest { + every { reference.delete() } returns TaskCompletionSource().task + assertCancellationStops { FamilyGroupRepository(firestore).leaveAllGroupsOf("owner") } + verify(exactly = 0) { joined.get() } + } + + @Test + fun `참여 그룹 조회 취소는 실패 반환으로 바꾸지 않는다`() = + runTest { + every { joined.get() } returns TaskCompletionSource().task + assertCancellationStops { FamilyGroupRepository(firestore).leaveAllGroupsOf("owner") } + } + + @Test + fun `참여 그룹 갱신 대기 취소는 실패 반환으로 바꾸지 않는다`() = + runTest { + every { reference.update(any>()) } returns TaskCompletionSource().task + assertCancellationStops { FamilyGroupRepository(firestore).leaveAllGroupsOf("owner") } + } +} diff --git a/core/data/src/test/java/com/example/slowclock/data/remote/repository/CancellationAssertions.kt b/core/data/src/test/java/com/example/slowclock/data/remote/repository/CancellationAssertions.kt new file mode 100644 index 00000000..743526d9 --- /dev/null +++ b/core/data/src/test/java/com/example/slowclock/data/remote/repository/CancellationAssertions.kt @@ -0,0 +1,22 @@ +package com.example.slowclock.data.remote.repository + +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import org.junit.Assert.assertFalse + +/** Job 의 isCancelled 만 보면 취소를 삼킨 함수도 통과한다. 호출 뒤 동기 코드가 실행됐는지 본다. */ +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +internal suspend fun TestScope.assertCancellationStops(block: suspend () -> Any?) { + var returned = false + val job = + launch { + block() + returned = true + } + runCurrent() + assertFalse("실제 Task 응답을 기다려야 한다", job.isCompleted) + job.cancelAndJoin() + assertFalse("취소를 일반 반환으로 바꾸면 호출 뒤 코드가 실행된다", returned) +} diff --git a/core/data/src/test/java/com/example/slowclock/data/remote/repository/UserRepositoryShareCodeTest.kt b/core/data/src/test/java/com/example/slowclock/data/remote/repository/UserRepositoryShareCodeTest.kt index f2b89fc3..b59f94ef 100644 --- a/core/data/src/test/java/com/example/slowclock/data/remote/repository/UserRepositoryShareCodeTest.kt +++ b/core/data/src/test/java/com/example/slowclock/data/remote/repository/UserRepositoryShareCodeTest.kt @@ -28,6 +28,7 @@ import org.junit.Test class UserRepositoryShareCodeTest { private val uid = "owner" private val code = "ABC123" + private val auth = mockk() private val firestore = mockk() private val users = mockk() private val profiles = mockk() @@ -40,6 +41,7 @@ class UserRepositoryShareCodeTest { private val repository: UserRepository init { + every { auth.currentUser } returns mockk { every { uid } returns this@UserRepositoryShareCodeTest.uid } every { firestore.collection("users") } returns users every { firestore.collection("publicProfiles") } returns profiles every { firestore.collection("shareCodes") } returns registry @@ -56,7 +58,7 @@ class UserRepositoryShareCodeTest { every { firestore.batch() } returns batch every { batch.delete(any()) } returns batch every { batch.commit() } returns Tasks.forResult(null) - repository = UserRepository(mockk(), firestore, mockk()) + repository = UserRepository(auth, firestore, mockk()) } @Test @@ -166,5 +168,50 @@ class UserRepositoryShareCodeTest { verify(exactly = 0) { batch.commit() } } + @Test + fun `현재 사용자 읽기 취소는 문서 없음으로 반환하지 않는다`() = + runTest { + every { userRef.get() } returns TaskCompletionSource().task + assertCancellationStops { repository.getCurrentUser() } + } + + @Test + fun `계정 삭제 읽기 취소는 batch를 시작하지 않는다`() = + runTest { + every { userRef.get() } returns TaskCompletionSource().task + assertCancellationStops { repository.deleteUserDocument(uid) } + verify(exactly = 0) { firestore.batch() } + } + + @Test + fun `계정 삭제 commit 대기 취소는 실패 결과로 바꾸지 않는다`() = + runTest { + every { batch.commit() } returns TaskCompletionSource().task + assertCancellationStops { repository.deleteUserDocument(uid) } + } + + @Test + fun `코드 등록 대기 취소는 사용자 쓰기로 이어지지 않는다`() = + runTest { + every { codeRef.set(any()) } returns TaskCompletionSource().task + assertCancellationStops { repository.ensureShareCode(uid, "이름", "email@example.com") } + verify(exactly = 0) { userRef.update(any>()) } + verify(exactly = 0) { profileRef.set(any()) } + } + + @Test + fun `공개 프로필 저장 대기 취소도 ensureShareCode 성공으로 반환하지 않는다`() = + runTest { + every { profileRef.set(any()) } returns TaskCompletionSource().task + assertCancellationStops { repository.ensureShareCode(uid, "이름", "email@example.com") } + } + + @Test + fun `공유 소유자 이름 읽기 취소는 빈 이름 목록으로 반환하지 않는다`() = + runTest { + every { profileRef.get() } returns TaskCompletionSource().task + assertCancellationStops { repository.getUserNames(listOf(uid)) } + } + private fun denied() = FirebaseFirestoreException("denied", FirebaseFirestoreException.Code.PERMISSION_DENIED) } diff --git a/core/ui/src/test/java/com/example/slowclock/ui/mvi/MviViewModelTest.kt b/core/ui/src/test/java/com/example/slowclock/ui/mvi/MviViewModelTest.kt index c0934448..add0c9c2 100644 --- a/core/ui/src/test/java/com/example/slowclock/ui/mvi/MviViewModelTest.kt +++ b/core/ui/src/test/java/com/example/slowclock/ui/mvi/MviViewModelTest.kt @@ -1,5 +1,13 @@ package com.example.slowclock.ui.mvi +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertSame @@ -10,7 +18,8 @@ import org.junit.Test * * 1. 상태는 `dispatch` → `reduce` 경로로만 바뀐다. Intent 를 받아도 dispatch 하지 않으면 그대로다. * 2. 일회성 신호는 `Intent.ConsumeXxx` 로 reset 된다. - * 3. 같은 신호가 연속 두 번 나도 두 번 소비된다. + * 3. collector 가 관측하고 소비한 뒤 같은 신호가 다시 오면 두 번 관측된다. + * 빠른 연속 쓰기의 모든 중간값 전달이나 제품 화면의 lifecycle 수집까지 보장하는 테스트는 아니다. */ class MviViewModelTest { @Test @@ -48,30 +57,29 @@ class MviViewModelTest { } @Test - fun `같은 신호가 연속 두 번 나도 소비 사이에 reset 되어 두 번 다 관측된다`() { - val viewModel = CounterViewModel() - val observed = mutableListOf() - - repeat(2) { - viewModel.onIntent(CounterIntent.Fail("저장할 수 없습니다")) - viewModel.uiState.value.errorMessage - ?.let(observed::add) - viewModel.onIntent(CounterIntent.ConsumeError) + fun `실제 collector가 소비한 뒤 같은 신호를 다시 관측한다`() = + runBlocking { + val viewModel = CounterViewModel() + val observed = mutableListOf() + val collector = + launch(start = CoroutineStart.UNDISPATCHED) { + viewModel.uiState + .map { it.errorMessage } + .take(5) + .toList(observed) + } + + repeat(2) { + viewModel.onIntent(CounterIntent.Fail("저장할 수 없습니다")) + yield() + viewModel.onIntent(CounterIntent.ConsumeError) + yield() + } + withTimeout(1_000) { collector.join() } + + assertEquals(listOf(null, "저장할 수 없습니다", null, "저장할 수 없습니다", null), observed) + assertNull(viewModel.uiState.value.errorMessage) } - - assertEquals(listOf("저장할 수 없습니다", "저장할 수 없습니다"), observed) - assertNull(viewModel.uiState.value.errorMessage) - } - - @Test - fun `reduce 는 같은 입력에 같은 결과를 낸다`() { - val viewModel = CounterViewModel() - val start = viewModel.uiState.value - - viewModel.onIntent(CounterIntent.Increase) - - assertEquals(start.copy(count = start.count + 1), viewModel.uiState.value) - } } private sealed interface CounterIntent : MviIntent { diff --git a/docs/alarm-data-audit.md b/docs/alarm-data-audit.md new file mode 100644 index 00000000..656c5456 --- /dev/null +++ b/docs/alarm-data-audit.md @@ -0,0 +1,37 @@ +# 알람·데이터 후속 검증 범위 + +2026-09-06, #218. 운영 Firebase 호출·배포 없이 소스와 호스트 JVM에서 검증했다. 기기 성능 실측은 별도 진행한다. + +## 취소 전파 (R13) + +UserRepository의 사용자 읽기·삭제·공유 코드 보장·공개 프로필 쓰기·소유자 이름 조회와 NotificationRepository/FamilyGroupRepository의 계정 정리가 `CancellationException`을 다시 던진다. 일반 오류에 대한 기존 null/false/빈 목록 반환은 유지한다. 특히 공개 프로필 쓰기 대기가 취소됐는데 `ensureShareCode`가 성공으로 반환하는 경계를 닫는다. + +대기 중인 실제 `TaskCompletionSource.task` 12개 경로에서 코루틴을 취소한다. Job의 취소 플래그만 확인하지 않고 호출 뒤의 동기 코드가 실행되지 않는지 검사한다. 삭제 읽기·코드 등록·그룹 삭제 취소 뒤 다음 쓰기가 시작되지 않는지도 확인한다. 원격에 이미 제출한 쓰기 자체를 되돌리는 기능은 아니다. 동기 문서 파싱과 snapshot callback의 오류 처리는 그대로 둔다. + +근거: [Android 코루틴 취소 예외 전파](https://developer.android.com/kotlin/coroutines/coroutines-best-practices#watch-out-for-exceptions), [Task.await의 취소 범위](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-play-services/kotlinx.coroutines.tasks/await.html). + +## 시간대와 MVI 테스트 (R07, R61) + +시간대 동작은 [기존 epoch 호환 정책](recurrence-time-policy.md)에 명시하고 6개 회귀 테스트로 검증한다. 생성 지역 시각 또는 여행지 벽시계 시각으로 데이터를 임의 이행하지 않는다. + +`MviViewModelTest`의 같은 파일 안 fake 산술을 반복하는 단언을 제거하고, 실제 StateFlow collector가 오류→소비→같은 오류를 관측하는지 검증한다. StateFlow의 모든 중간값 전달이나 화면 lifecycle의 전체 동작을 보장한다고 해석하지 않는다. 제품별 검증은 Main/Profile/AddSchedule 등의 ViewModel 회귀 테스트를 근거로 삼는다. + +## 성능 확인과 측정 경계 (R14, R30, R31) + +R14: 날짜 조회에서 사용하지 않던 Calendar 복제 2개를 제거했다. 전체 사용자 문서 조회는 유지한다. 과거에 시작한 반복 문서를 날짜 범위로 제외하면 오늘 회차가 사라진다. 캐시가 있어도 최초 query·서버 동기화·장기 재연결의 읽기가 모두 없어지는 것은 아니다. [Firestore 최초 snapshot](https://firebase.google.com/docs/firestore/query-data/listen), [리스너 재접속 과금](https://firebase.google.com/docs/firestore/pricing#listening_to_query_results). + +실제 `RecurrenceRule.occurrenceOn` bytecode에 합성 anchor를 넣고, 반환 epoch를 모아 정렬하는 호스트 계산을 측정했다. JDK 21.0.11/aarch64/14 logical CPU, 서울 시간대, 각 규모 20회 준비·100회 측정이다. 기준은 2026-01-01 08:00에서 `i % 31`일 뒤, 반복 종류 4개를 균등 분배하고 대상 날짜는 2026-09-06이다. + +| 입력 anchor 수 | 선택된 회차 수 | p50 (ms) | p95 (ms) | 최대 (ms) | +| --- | --- | --- | --- | --- | +| 100 | 29 | 0.144 | 0.222 | 1.217 | +| 1,000 | 290 | 0.686 | 0.874 | 1.061 | +| 10,000 | 2,903 | 3.666 | 4.411 | 5.105 | + +이 값에는 Firestore 문서 파싱·네트워크·디스크·Binder·Compose·기기 프레임이 포함되지 않는다. 실제 사용자의 데이터 분포도 아니다. 조회/파싱 전체 지연, 출시 성능 또는 비용을 보장하는 근거로 사용하지 않는다. 현재 근거로 스키마·인덱스·구독 구조를 바꾸지 않는다. + +R30: MainViewModel 생성 시 설정 읽기와 알람 권한/알림 상태 확인은 여전히 동기 호출이다. 설정 캐시가 준비되지 않은 경우의 대기와 Binder 응답 비용은 호스트 mock으로 판정할 수 없다. 초기 알람 복원은 별도 IO 경로이며, 이 사실을 모든 시작 경로가 비동기라는 뜻으로 쓰지 않는다. + +R31: `AlarmScheduler.cancelAll`은 예약 일정과 미룸 장부를 순회한다. 로그아웃에서는 취소 완료 뒤 공유 상태를 비우고 인증 세션을 끊는 순서를 유지한다(`SignOutUseCaseTest`). 기존 새 설치 경로도 동기 순서를 유지한다. 비동기로 분리하면 계정 전환 뒤 늦은 이전 계정 취소가 새 알람에 영향을 줄 수 있어, 실측 없이 실행 순서를 바꾸지 않는다. + +다음 기기 검증에서는 동일한 합성 규모로 cold/warm 시작의 SharedPreferences·권한 Binder 대기, 로그아웃/새 설치의 전체 취소 소요 시간과 프레임 trace, 계정 전환 중 취소 완료 순서를 함께 기록한다. 실제 데이터 규모의 Firestore 비용은 별도 비용 검토 범위다. 이번 호스트 결과만으로 저사양 기기의 ANR·프레임 지연이 없다고 결론 내리지 않는다. diff --git a/docs/recurrence-time-policy.md b/docs/recurrence-time-policy.md new file mode 100644 index 00000000..39798b2e --- /dev/null +++ b/docs/recurrence-time-policy.md @@ -0,0 +1,22 @@ +# v1 반복 일정 시간대 호환 정책 + +기존 일정의 epoch와 완료 날짜를 보존한다. v1 문서에는 생성 당시 시간대나 별도의 벽시계 시각이 없으므로, 기존 값을 특정 지역의 오전 8시로 다시 해석하거나 이동 뒤 오전 8시로 이행하지 않는다. 이 문서는 현재 동작의 호환 기준이며 여행지의 같은 시각에 울리는 기능을 추가하는 결정이 아니다. + +## 계산과 이미 예약된 알람 + +- `Schedule.startTime`은 첫 회차의 절대 시각이다. 단발 일정과 이미 AlarmManager에 예약한 시각은 시간대만 바뀌어도 같은 epoch를 유지한다. +- 반복 회차를 새로 계산할 때는 `Calendar.getInstance()`가 선택한 시간대·달력으로 첫 회차를 읽고, 그 시·분·초와 요일 또는 일자를 대상 날짜에 적용한다. 일반적인 기기 설정에서는 현재 기기 시간대다. Android 13 이상에서 locale에 `tz` 확장이 있으면 Calendar가 이를 사용할 수 있다. +- 예: 서울 2026-01-06 08:00은 LA에서 2026-01-05 15:00이다. LA에서 계산하는 매일 회차는 15:00, 매주 회차는 월요일이다. 생성 당시 화요일 08:00을 저장한 별도 필드는 없다. +- 시간대 변경만으로 재예약하지 않는다. 기존 예약과 이후 앱 복원·동기화·다음 회차 계산을 구분한다. `TIMEZONE_CHANGED` 수신을 추가하는 것만으로 모든 회차가 여행지의 생성 시각을 유지하지는 않는다. +- `completedDates` 키는 회차를 계산한 달력 날짜다. 기존 키를 이동시키지 않으므로 시간대를 옮긴 날에는 화면의 날짜·완료 회차가 달라질 수 있다. +- 반복 종료 시각은 시작에서 떨어진 **경과 시간**을 보존한다. DST 경계를 넘는 날에는 시작과 종료의 벽시계 간격이 다를 수 있다. + +## DST 경계 + +같은 시간대에서 존재하는 시각은 달력 날짜를 따라 반복한다. 따라서 DST 시작일의 아침 회차는 전날보다 23시간 뒤일 수 있고 종료일은 25시간 뒤일 수 있다. 존재하지 않거나 두 번 있는 시각은 기존 Calendar의 기본 lenient 해석을 보존한다. + +`RecurrenceTimeZoneTest`는 명시적인 offset으로 만든 기대 epoch를 사용해 서울→LA 이동, 단발 epoch, 회차 키, DST 시작, 공백·중복 시각을 검증한다. 공백·중복의 구체 offset은 호스트 JDK Calendar의 회귀 결과다. 모든 Android/OEM·시간대 데이터 버전의 동작을 실측했다는 뜻은 아니다. 실기기 검증은 별도 진행한다. + +향후 여행지의 같은 벽시계 시각 또는 생성 지역의 시각을 제품 기능으로 선택한다면, 시간대·벽시계 필드, DST 공백/중복 정책, 완료 키와 기존 문서 이행을 함께 설계해야 한다. 현재 문서에서 누락된 생성 시간대를 추정해 기존 알람을 이동시키지 않는다. + +근거: [Android Calendar.getInstance와 기본 leniency](https://developer.android.com/reference/java/util/Calendar), [AlarmManager RTC epoch](https://developer.android.com/reference/android/app/AlarmManager#RTC_WAKEUP).