From ea66b6da1e31c2e4467933ed14930efd03f6f37e Mon Sep 17 00:00:00 2001 From: klaus Date: Thu, 9 Jul 2026 00:53:17 +0900 Subject: [PATCH] =?UTF-8?q?fix(common):=20UTC=20=EC=83=81=EB=8C=80?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=ED=8C=8C=EC=8B=B1=EC=9D=84=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/common/RelativeTimeFormatter.kt | 42 ++++++++--- .../common/RelativeTimeFormatterTest.kt | 70 +++++++++++++++++++ 2 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/common/RelativeTimeFormatterTest.kt diff --git a/app/src/main/java/kr/co/vividnext/sodalive/common/RelativeTimeFormatter.kt b/app/src/main/java/kr/co/vividnext/sodalive/common/RelativeTimeFormatter.kt index dd407939..e4cc9e09 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/common/RelativeTimeFormatter.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/common/RelativeTimeFormatter.kt @@ -2,7 +2,7 @@ package kr.co.vividnext.sodalive.common import android.content.Context import kr.co.vividnext.sodalive.R -import java.text.ParseException +import java.text.ParsePosition import java.text.SimpleDateFormat import java.util.Calendar import java.util.Date @@ -10,8 +10,9 @@ import java.util.Locale import java.util.TimeZone fun formatUtcRelativeTimeText(context: Context, utcText: String?): String { - val pastMillis = parseServerUtcToMillis(utcText) + val parsedTime = parseServerUtcToMillis(utcText) ?: return context.getString(R.string.character_comment_time_just_now) + val pastMillis = parsedTime.millis val nowMillis = System.currentTimeMillis() var diff = nowMillis - pastMillis @@ -21,6 +22,10 @@ fun formatUtcRelativeTimeText(context: Context, utcText: String?): String { val hour = 60 * minute val day = 24 * hour + if (parsedTime.isIsoText && diff < RECENT_ISO_TIME_GRACE_MILLIS) { + return context.getString(R.string.character_comment_time_just_now) + } + if (diff < minute) { return context.getString(R.string.character_comment_time_just_now) } @@ -75,19 +80,25 @@ class AndroidUtcRelativeTimeTextFormatter(context: Context) : UtcRelativeTimeTex override fun format(utcText: String?): String = formatUtcRelativeTimeText(applicationContext, utcText) } -private fun parseServerUtcToMillis(utcText: String?): Long? { +private fun parseServerUtcToMillis(utcText: String?): ParsedServerTime? { if (utcText.isNullOrBlank()) return null - val value = utcText.trim() + val value = utcText.trim().normalizeIsoFraction() if (value.all { it.isDigit() }) { return try { - value.toLong() + val number = value.toLong() + ParsedServerTime( + millis = if (value.length <= EPOCH_SECONDS_MAX_LENGTH) number * 1000 else number, + isIsoText = false + ) } catch (_: NumberFormatException) { null } } val patterns = listOf( + "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", + "yyyy-MM-dd'T'HH:mm:ssXXX", "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", "yyyy-MM-dd'T'HH:mm:ss'Z'", "yyyy-MM-dd'T'HH:mm:ss", @@ -99,11 +110,26 @@ private fun parseServerUtcToMillis(utcText: String?): Long? { try { val dateFormat = SimpleDateFormat(pattern, Locale.US) dateFormat.timeZone = TimeZone.getTimeZone("UTC") - val parsed: Date? = dateFormat.parse(value) - if (parsed != null) return parsed.time - } catch (_: ParseException) { + val position = ParsePosition(0) + val parsed: Date? = dateFormat.parse(value, position) + if (parsed != null && position.index == value.length) { + return ParsedServerTime(millis = parsed.time, isIsoText = true) + } + } catch (_: RuntimeException) { } } return null } + +private fun String.normalizeIsoFraction(): String { + return replace(Regex("""\.(\d{3})\d+"""), ".$1") +} + +private data class ParsedServerTime( + val millis: Long, + val isIsoText: Boolean +) + +private const val EPOCH_SECONDS_MAX_LENGTH = 10 +private const val RECENT_ISO_TIME_GRACE_MILLIS = 10 * 60_000L diff --git a/app/src/test/java/kr/co/vividnext/sodalive/common/RelativeTimeFormatterTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/common/RelativeTimeFormatterTest.kt new file mode 100644 index 00000000..9392ddbd --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/common/RelativeTimeFormatterTest.kt @@ -0,0 +1,70 @@ +package kr.co.vividnext.sodalive.common + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import kr.co.vividnext.sodalive.R +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +class RelativeTimeFormatterTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun `epoch millis는 기존처럼 실제 경과 분을 표시한다`() { + val twoMinutesAgoMillis = System.currentTimeMillis() - 2 * MINUTE + + val text = formatUtcRelativeTimeText(context, twoMinutesAgoMillis.toString()) + + assertEquals(context.getString(R.string.character_comment_time_minutes, 2), text) + } + + @Test + fun `epoch seconds는 초 단위로 파싱한다`() { + val twoHoursAgoSeconds = (System.currentTimeMillis() - 2 * HOUR) / 1000 + + val text = formatUtcRelativeTimeText(context, twoHoursAgoSeconds.toString()) + + assertEquals(context.getString(R.string.character_comment_time_hours, 2), text) + } + + @Test + fun `ISO UTC 문자열은 작성 직후 서버 단말 시계 차이를 방금전으로 보정한다`() { + val sixMinutesAgoIso = utcIso(System.currentTimeMillis() - 6 * MINUTE) + + val text = formatUtcRelativeTimeText(context, sixMinutesAgoIso) + + assertEquals(context.getString(R.string.character_comment_time_just_now), text) + } + + @Test + fun `ISO offset 문자열과 6자리 fraction을 UTC 기준으로 파싱한다`() { + val value = utcIso(System.currentTimeMillis()) + .replace(".000Z", ".123456+00:00") + + val text = formatUtcRelativeTimeText(context, value) + + assertEquals(context.getString(R.string.character_comment_time_just_now), text) + } + + private fun utcIso(millis: Long): String { + return SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + }.format(Date(millis)) + } + + private companion object { + const val MINUTE = 60_000L + const val HOUR = 60 * MINUTE + } +}