From 223d73213b38a9416764045a66af454cf3844a20 Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:58:52 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20=EC=9C=84=ED=97=98=20=ED=91=9C?= =?UTF-8?q?=ED=98=84=20=EA=B0=90=EC=A7=80=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20-=20=EC=9B=90?= =?UTF-8?q?=EB=AC=B8=EC=97=90=EC=84=9C=20=EC=9E=90=EC=82=B4=C2=B7=EC=9E=90?= =?UTF-8?q?=ED=95=B4=20=EC=9C=84=ED=97=98=20=ED=91=9C=ED=98=84=EC=9D=84=20?= =?UTF-8?q?=EC=B0=BE=EC=95=84=20=EC=9C=84=ED=97=98=EB=8F=84=EC=99=80=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=ED=95=A0=20=EC=83=81=EB=8B=B4=20=EA=B8=B0?= =?UTF-8?q?=EA=B4=80=EC=9D=84=20=EB=B0=98=ED=99=98=ED=95=98=EB=8A=94=20Det?= =?UTF-8?q?ectRiskInTextUseCase=20=EC=B6=94=EA=B0=80.=20CRITICAL=EC=9D=80?= =?UTF-8?q?=20=EC=A0=84=EC=86=A1=20=EC=B0=A8=EB=8B=A8,=20WARNING=EC=9D=80?= =?UTF-8?q?=20=EC=95=88=EB=82=B4=EB=A7=8C=20=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=93=B1=EA=B8=89=EC=9D=84=20=EB=82=98=EB=88=A0=20=EC=9B=90?= =?UTF-8?q?=EA=B2=A9=20=EC=82=AC=EC=A0=84=EC=97=90=EC=84=9C=20=EC=A1=B0?= =?UTF-8?q?=EC=A0=95=ED=95=A0=20=EC=88=98=20=EC=9E=88=EA=B2=8C=20=ED=96=88?= =?UTF-8?q?=EB=8B=A4=20-=20RiskTermMatcher=EB=8A=94=20=EA=B3=B5=EB=B0=B1?= =?UTF-8?q?=C2=B7=EA=B5=AC=EB=91=90=EC=A0=90=EC=9D=84=20=EC=A7=80=EC=9A=B4?= =?UTF-8?q?=20=EB=92=A4=20=EB=A7=A4=EC=B9=AD=ED=95=98=EB=90=98,=20?= =?UTF-8?q?=EB=91=90=20=EA=B8=80=EC=9E=90=20=EC=9D=B4=ED=95=98=20=ED=91=9C?= =?UTF-8?q?=ED=98=84=EC=9D=80=20=EC=96=B4=EC=A0=88=20=EA=B2=BD=EA=B3=84?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=8B=9C=EC=9E=91=ED=95=A0=20=EB=95=8C?= =?UTF-8?q?=EB=A7=8C=20=EC=9D=B8=EC=A0=95=ED=95=9C=EB=8B=A4.=20=EA=B3=B5?= =?UTF-8?q?=EB=B0=B1=EC=9D=84=20=EC=A7=80=EC=9A=B0=EB=A9=B4=20"=EA=B3=BC?= =?UTF-8?q?=EC=9E=90=20=EC=82=B4=EA=B9=8C",=20"=ED=98=BC=EC=9E=90=20?= =?UTF-8?q?=EC=82=B4=EC=95=84=EB=B3=B4=EB=8B=88"=20=EC=95=88=EC=97=90=20"?= =?UTF-8?q?=EC=9E=90=EC=82=B4"=EC=9D=B4=20=EC=83=9D=EA=B2=A8=20=ED=8F=89?= =?UTF-8?q?=EB=B2=94=ED=95=9C=20=EC=9D=BC=EA=B8=B0=EA=B0=80=20=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8=EB=90=98=EA=B8=B0=20=EB=95=8C=EB=AC=B8=20-=20safePhra?= =?UTF-8?q?ses=EB=A1=9C=20"=EB=B0=B0=EA=B3=A0=ED=8C=8C=20=EC=A3=BD?= =?UTF-8?q?=EA=B2=A0=EB=8B=A4"=20=EB=A5=98=EC=9D=98=20=EA=B4=80=EC=9A=A9?= =?UTF-8?q?=20=ED=91=9C=ED=98=84=EC=9D=84=20=EA=B1=B8=EB=9F=AC=EB=82=B8?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/safety/DetectRiskInTextUseCase.kt | 18 +++ .../safety/RefreshRiskLexiconUseCase.kt | 15 +++ .../android/domain/safety/RiskDetection.kt | 20 ++++ .../gamss/android/domain/safety/RiskLevel.kt | 11 ++ .../android/domain/safety/RiskLexicon.kt | 11 ++ .../domain/safety/RiskLexiconRepository.kt | 15 +++ .../gamss/android/domain/safety/RiskTerm.kt | 6 + .../android/domain/safety/RiskTermMatcher.kt | 98 +++++++++++++++++ .../android/domain/safety/SupportAgency.kt | 10 ++ .../safety/DetectRiskInTextUseCaseTest.kt | 57 ++++++++++ .../domain/safety/RiskTermMatcherTest.kt | 103 ++++++++++++++++++ 11 files changed, 364 insertions(+) create mode 100644 domain/src/main/kotlin/com/gamss/android/domain/safety/DetectRiskInTextUseCase.kt create mode 100644 domain/src/main/kotlin/com/gamss/android/domain/safety/RefreshRiskLexiconUseCase.kt create mode 100644 domain/src/main/kotlin/com/gamss/android/domain/safety/RiskDetection.kt create mode 100644 domain/src/main/kotlin/com/gamss/android/domain/safety/RiskLevel.kt create mode 100644 domain/src/main/kotlin/com/gamss/android/domain/safety/RiskLexicon.kt create mode 100644 domain/src/main/kotlin/com/gamss/android/domain/safety/RiskLexiconRepository.kt create mode 100644 domain/src/main/kotlin/com/gamss/android/domain/safety/RiskTerm.kt create mode 100644 domain/src/main/kotlin/com/gamss/android/domain/safety/RiskTermMatcher.kt create mode 100644 domain/src/main/kotlin/com/gamss/android/domain/safety/SupportAgency.kt create mode 100644 domain/src/test/kotlin/com/gamss/android/domain/safety/DetectRiskInTextUseCaseTest.kt create mode 100644 domain/src/test/kotlin/com/gamss/android/domain/safety/RiskTermMatcherTest.kt diff --git a/domain/src/main/kotlin/com/gamss/android/domain/safety/DetectRiskInTextUseCase.kt b/domain/src/main/kotlin/com/gamss/android/domain/safety/DetectRiskInTextUseCase.kt new file mode 100644 index 0000000..c3823f0 --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/safety/DetectRiskInTextUseCase.kt @@ -0,0 +1,18 @@ +package com.gamss.android.domain.safety + +import com.gamss.android.domain.usecase.UseCase +import javax.inject.Inject + +/** + * 원문 → 위험도와 안내할 기관 목록. + * + * 검사는 전부 기기 안에서 한다. 사전만 원격에서 받고 원문은 기기 밖으로 내보내지 않는다. + */ +class DetectRiskInTextUseCase @Inject constructor( + private val repository: RiskLexiconRepository, + private val matcher: RiskTermMatcher, +) : UseCase { + + override suspend fun invoke(params: String): RiskDetection = + matcher.match(params, repository.getLexicon()) +} diff --git a/domain/src/main/kotlin/com/gamss/android/domain/safety/RefreshRiskLexiconUseCase.kt b/domain/src/main/kotlin/com/gamss/android/domain/safety/RefreshRiskLexiconUseCase.kt new file mode 100644 index 0000000..43d28da --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/safety/RefreshRiskLexiconUseCase.kt @@ -0,0 +1,15 @@ +package com.gamss.android.domain.safety + +import com.gamss.android.domain.usecase.NoParamUseCase +import javax.inject.Inject + +/** + * 원격 사전 갱신은 앱 시작 시 한 번만 시도한다. 전송할 때마다 네트워크를 타면 + * 감지가 느려지고 읽기 비용이 사용량에 비례해 늘어난다. + */ +class RefreshRiskLexiconUseCase @Inject constructor( + private val repository: RiskLexiconRepository, +) : NoParamUseCase { + + override suspend fun invoke() = repository.refresh() +} diff --git a/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskDetection.kt b/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskDetection.kt new file mode 100644 index 0000000..f124f68 --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskDetection.kt @@ -0,0 +1,20 @@ +package com.gamss.android.domain.safety + +/** + * matchedTerms 는 등급 판정과 테스트에만 쓴다. 원문과 매칭된 단어는 로그·분석 이벤트에 남기지 않는다. + */ +data class RiskDetection( + val level: RiskLevel, + val matchedTerms: List, + val agencies: List, +) { + val shouldBlock: Boolean get() = level == RiskLevel.CRITICAL + + companion object { + val None = RiskDetection( + level = RiskLevel.NONE, + matchedTerms = emptyList(), + agencies = emptyList(), + ) + } +} diff --git a/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskLevel.kt b/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskLevel.kt new file mode 100644 index 0000000..af29127 --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskLevel.kt @@ -0,0 +1,11 @@ +package com.gamss.android.domain.safety + +/** + * CRITICAL 은 전송을 차단하고, WARNING 은 안내만 하고 전송을 진행한다. + * 등급은 원격 사전이 정하므로 앱 배포 없이 차단 범위를 조정할 수 있다. + */ +enum class RiskLevel { + NONE, + WARNING, + CRITICAL, +} diff --git a/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskLexicon.kt b/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskLexicon.kt new file mode 100644 index 0000000..2394f8a --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskLexicon.kt @@ -0,0 +1,11 @@ +package com.gamss.android.domain.safety + +/** + * version 은 앱 내장 사전과 원격 사전의 우열을 가리는 기준이다. 원격이 더 클 때만 갱신한다. + */ +data class RiskLexicon( + val version: Int, + val terms: List, + val safePhrases: List, + val agencies: List, +) diff --git a/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskLexiconRepository.kt b/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskLexiconRepository.kt new file mode 100644 index 0000000..8797df5 --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskLexiconRepository.kt @@ -0,0 +1,15 @@ +package com.gamss.android.domain.safety + +interface RiskLexiconRepository { + + /** + * 조회는 실패하지 않는다. 원격과 캐시가 모두 불가하면 앱 내장 사전을 돌려준다. + * 안전 기능이 네트워크 상태에 좌우되면 안 되기 때문이다. + */ + suspend fun getLexicon(): RiskLexicon + + /** + * 원격 사전을 확인해 캐시를 갱신한다. 실패해도 예외를 던지지 않는다. + */ + suspend fun refresh() +} diff --git a/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskTerm.kt b/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskTerm.kt new file mode 100644 index 0000000..515cf0f --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskTerm.kt @@ -0,0 +1,6 @@ +package com.gamss.android.domain.safety + +data class RiskTerm( + val term: String, + val level: RiskLevel, +) diff --git a/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskTermMatcher.kt b/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskTermMatcher.kt new file mode 100644 index 0000000..991bf73 --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskTermMatcher.kt @@ -0,0 +1,98 @@ +package com.gamss.android.domain.safety + +import java.text.Normalizer +import javax.inject.Inject + +/** + * 공백과 구두점을 지운 뒤 부분 문자열로 찾으므로 "죽고 싶다", "죽.고.싶.다" 를 한 표현으로 본다. + * + * 두 글자 이하 표현은 어절 경계에서 시작할 때만 인정한다. 공백을 지우면 "과자 살까" 안에 + * "자살" 이, "혼자 살아보니" 안에도 "자살" 이 생겨 평범한 일기가 차단되기 때문이다. + * safePhrases 구간은 먼저 마스킹해 "배고파 죽겠다" 류의 관용 표현을 걸러낸다. + */ +class RiskTermMatcher @Inject constructor() { + + fun match(text: String, lexicon: RiskLexicon): RiskDetection { + val matched = findMatchedTerms(text, lexicon) + return if (matched.isEmpty()) { + RiskDetection.None + } else { + RiskDetection( + level = if (matched.any { it.level == RiskLevel.CRITICAL }) { + RiskLevel.CRITICAL + } else { + RiskLevel.WARNING + }, + matchedTerms = matched.map { it.term }, + agencies = lexicon.agencies.sortedBy { it.priority }, + ) + } + } + + private fun findMatchedTerms(text: String, lexicon: RiskLexicon): List { + val normalized = normalize(text) + if (normalized.value.isEmpty()) return emptyList() + + val masked = maskSafePhrases(normalized.value, lexicon.safePhrases) + return lexicon.terms.filter { it.matches(masked, normalized.wordStarts) } + } + + private fun RiskTerm.matches(maskedText: String, wordStarts: Set): Boolean { + val normalizedTerm = normalize(term).value + return when { + level == RiskLevel.NONE -> false + normalizedTerm.isEmpty() -> false + normalizedTerm.length > SHORT_TERM_LENGTH -> maskedText.contains(normalizedTerm) + else -> maskedText.occurrencesOf(normalizedTerm).any { it in wordStarts } + } + } + + /** + * 알파벳과 숫자만 남긴 문자열, 그리고 각 어절의 첫 글자가 그 문자열에서 갖는 위치. + */ + private class NormalizedText(val value: String, val wordStarts: Set) + + private fun normalize(text: String): NormalizedText { + val builder = StringBuilder() + val wordStarts = mutableSetOf() + var atWordStart = true + for (character in Normalizer.normalize(text, Normalizer.Form.NFKC)) { + if (character.isLetterOrDigit()) { + if (atWordStart) wordStarts.add(builder.length) + builder.append(character.lowercaseChar()) + atWordStart = false + } else { + atWordStart = true + } + } + return NormalizedText(builder.toString(), wordStarts) + } + + private fun maskSafePhrases(normalized: String, safePhrases: List): String = + safePhrases.fold(normalized) { text, phrase -> + val normalizedPhrase = normalize(phrase).value + if (normalizedPhrase.isEmpty()) { + text + } else { + text.replace(normalizedPhrase, MASK.toString().repeat(normalizedPhrase.length)) + } + } + + private fun String.occurrencesOf(value: String): List { + val positions = mutableListOf() + var index = indexOf(value) + while (index >= 0) { + positions += index + index = indexOf(value, index + 1) + } + return positions + } + + private companion object { + /** 어떤 위험 표현과도 겹치지 않는 대체 문자. 마스킹 후에도 위치가 밀리지 않게 길이를 보존한다. */ + const val MASK = ' ' + + /** 이 길이 이하의 표현은 어절 경계에서 시작할 때만 인정한다. */ + const val SHORT_TERM_LENGTH = 2 + } +} diff --git a/domain/src/main/kotlin/com/gamss/android/domain/safety/SupportAgency.kt b/domain/src/main/kotlin/com/gamss/android/domain/safety/SupportAgency.kt new file mode 100644 index 0000000..cb8ec2c --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/safety/SupportAgency.kt @@ -0,0 +1,10 @@ +package com.gamss.android.domain.safety + +data class SupportAgency( + val id: String, + val name: String, + val description: String, + val phoneNumber: String?, + val url: String?, + val priority: Int, +) diff --git a/domain/src/test/kotlin/com/gamss/android/domain/safety/DetectRiskInTextUseCaseTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/safety/DetectRiskInTextUseCaseTest.kt new file mode 100644 index 0000000..044f49c --- /dev/null +++ b/domain/src/test/kotlin/com/gamss/android/domain/safety/DetectRiskInTextUseCaseTest.kt @@ -0,0 +1,57 @@ +package com.gamss.android.domain.safety + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class DetectRiskInTextUseCaseTest { + + private class FakeRiskLexiconRepository(private val lexicon: RiskLexicon) : RiskLexiconRepository { + override suspend fun getLexicon(): RiskLexicon = lexicon + override suspend fun refresh() = Unit + } + + private val lexicon = RiskLexicon( + version = 1, + terms = listOf(RiskTerm("죽고싶", RiskLevel.CRITICAL)), + safePhrases = emptyList(), + agencies = listOf( + SupportAgency("b", "청소년 상담전화", "청소년 전문 상담", "1388", null, 3), + SupportAgency("a", "자살예방 상담전화", "24시간 무료 전문 상담", "109", null, 1), + ), + ) + + private val useCase = DetectRiskInTextUseCase( + repository = FakeRiskLexiconRepository(lexicon), + matcher = RiskTermMatcher(), + ) + + @Test + fun 사전에_있는_위험_표현을_감지하고_기관을_priority_순으로_안내한다() = runBlocking { + val detection = useCase("요즘 정말 죽고싶다는 생각뿐이다") + + assertEquals(RiskLevel.CRITICAL, detection.level) + assertTrue(detection.shouldBlock) + assertEquals(listOf("109", "1388"), detection.agencies.map { it.phoneNumber }) + } + + @Test + fun 위험_표현이_없으면_기관을_안내하지_않는다() = runBlocking { + val detection = useCase("오늘은 친구와 맛있는 걸 먹었다") + + assertEquals(RiskLevel.NONE, detection.level) + assertEquals(emptyList(), detection.agencies) + } + + @Test + fun 사전이_비어_있으면_감지하지_않는다() = runBlocking { + val emptyLexicon = RiskLexicon(0, emptyList(), emptyList(), emptyList()) + val useCaseWithEmptyLexicon = DetectRiskInTextUseCase( + repository = FakeRiskLexiconRepository(emptyLexicon), + matcher = RiskTermMatcher(), + ) + + assertEquals(RiskLevel.NONE, useCaseWithEmptyLexicon("죽고싶다").level) + } +} diff --git a/domain/src/test/kotlin/com/gamss/android/domain/safety/RiskTermMatcherTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/safety/RiskTermMatcherTest.kt new file mode 100644 index 0000000..52614e6 --- /dev/null +++ b/domain/src/test/kotlin/com/gamss/android/domain/safety/RiskTermMatcherTest.kt @@ -0,0 +1,103 @@ +package com.gamss.android.domain.safety + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class RiskTermMatcherTest { + + private val matcher = RiskTermMatcher() + + private val lexicon = RiskLexicon( + version = 1, + terms = listOf( + RiskTerm("자살", RiskLevel.CRITICAL), + RiskTerm("죽고싶", RiskLevel.CRITICAL), + RiskTerm("살기싫", RiskLevel.WARNING), + RiskTerm("관찰만하는단어", RiskLevel.NONE), + ), + safePhrases = listOf("배고파죽", "죽고싶지않", "자살예방"), + agencies = listOf( + SupportAgency("b", "청소년 상담전화", "청소년 전문 상담", "1388", null, 3), + SupportAgency("a", "자살예방 상담전화", "24시간 무료 전문 상담", "109", null, 1), + ), + ) + + @Test + fun 위험_표현이_없으면_NONE을_반환한다() { + assertEquals(RiskLevel.NONE, matcher.match("오늘은 날씨가 좋아서 오래 산책했다", lexicon).level) + } + + @Test + fun CRITICAL_표현이_있으면_CRITICAL을_반환한다() { + assertEquals(RiskLevel.CRITICAL, matcher.match("자살에 대해 계속 생각한다", lexicon).level) + } + + @Test + fun WARNING_표현만_있으면_WARNING을_반환한다() { + assertEquals(RiskLevel.WARNING, matcher.match("요즘은 그냥 살기싫다", lexicon).level) + } + + @Test + fun CRITICAL과_WARNING이_함께_있으면_CRITICAL을_반환한다() { + assertEquals(RiskLevel.CRITICAL, matcher.match("살기싫고 죽고싶다", lexicon).level) + } + + @Test + fun 관용_표현은_감지하지_않는다() { + assertEquals(RiskLevel.NONE, matcher.match("배고파 죽겠다", lexicon).level) + } + + @Test + fun 부정_표현은_감지하지_않는다() { + assertEquals(RiskLevel.NONE, matcher.match("죽고 싶지 않아", lexicon).level) + } + + @Test + fun 안전한_합성어는_감지하지_않는다() { + assertEquals(RiskLevel.NONE, matcher.match("학교에서 자살예방 교육을 들었다", lexicon).level) + } + + @Test + fun 띄어쓰기나_구두점으로_나눠_써도_감지한다() { + assertEquals(RiskLevel.CRITICAL, matcher.match("자 살", lexicon).level) + assertEquals(RiskLevel.CRITICAL, matcher.match("죽.고.싶.다", lexicon).level) + } + + @Test + fun 짧은_표현이_어절_경계를_넘어_겹치면_감지하지_않는다() { + assertEquals(RiskLevel.NONE, matcher.match("과자 살까 말까 고민했다", lexicon).level) + assertEquals(RiskLevel.NONE, matcher.match("혼자 살아보니 편하다", lexicon).level) + } + + @Test + fun 긴_표현은_어절_경계와_무관하게_감지한다() { + assertEquals(RiskLevel.CRITICAL, matcher.match("요즘죽고싶다", lexicon).level) + } + + @Test + fun 등급이_NONE인_항목은_감지하지_않는다() { + assertEquals(RiskLevel.NONE, matcher.match("관찰만하는단어", lexicon).level) + } + + @Test + fun 빈_입력과_공백만_있는_입력은_NONE을_반환한다() { + assertEquals(RiskLevel.NONE, matcher.match("", lexicon).level) + assertEquals(RiskLevel.NONE, matcher.match(" \n ", lexicon).level) + } + + @Test + fun 감지되면_기관을_priority_순으로_반환한다() { + val detection = matcher.match("자살", lexicon) + + assertEquals(listOf("자살예방 상담전화", "청소년 상담전화"), detection.agencies.map { it.name }) + } + + @Test + fun CRITICAL만_전송을_차단한다() { + assertTrue(matcher.match("죽고싶다", lexicon).shouldBlock) + assertFalse(matcher.match("살기싫다", lexicon).shouldBlock) + assertFalse(matcher.match("평범한 하루였다", lexicon).shouldBlock) + } +} From 486f4034229fefd2f7e3577f2b22d2af37183885 Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:59:03 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat:=20=EC=9C=84=ED=97=98=20=ED=91=9C?= =?UTF-8?q?=ED=98=84=20=EC=82=AC=EC=A0=84=20=EC=9B=90=EA=B2=A9=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20=EA=B5=AC=ED=98=84=20-=20=EC=95=B1=20=EB=82=B4?= =?UTF-8?q?=EC=9E=A5=20assets=20JSON=EC=9D=84=20=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=91=90=EA=B3=A0=20Firestore=20app=5Fcon?= =?UTF-8?q?fig/risk=5Flexicon=20=EC=9D=98=20version=20=EC=9D=B4=20?= =?UTF-8?q?=EB=8D=94=20=ED=81=B4=20=EB=95=8C=EB=A7=8C=20=EC=B1=84=ED=83=9D?= =?UTF-8?q?=ED=95=9C=EB=8B=A4.=20=EC=A1=B0=ED=9A=8C=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=EB=8A=94=20DataStore=20=EC=97=90=20=EC=BA=90=EC=8B=9C=ED=95=98?= =?UTF-8?q?=EA=B3=A0=2024=EC=8B=9C=EA=B0=84=20=EB=8F=99=EC=95=88=20?= =?UTF-8?q?=EC=9E=AC=EC=A1=B0=ED=9A=8C=ED=95=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=EB=8B=A4=20-=20=EC=9B=90=EA=B2=A9=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=C2=B7=EC=BA=90=EC=8B=9C=20=ED=8C=8C=EC=8B=B1=C2=B7ass?= =?UTF-8?q?ets=20=EB=A1=9C=EB=94=A9=EC=9D=B4=20=EB=AA=A8=EB=91=90=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=ED=95=B4=EB=8F=84=20=EC=98=88=EC=99=B8?= =?UTF-8?q?=EB=A5=BC=20=EB=82=B4=EB=B3=B4=EB=82=B4=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=EB=8B=A4.=20=EC=95=88=EC=A0=84=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=EC=9D=B4=20=EB=84=A4=ED=8A=B8=EC=9B=8C=ED=81=AC=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EC=97=90=20=EC=A2=8C=EC=9A=B0=EB=90=98=EB=A9=B4=20?= =?UTF-8?q?=EC=95=88=20=EB=90=9C=EB=8B=A4=20-=20terms=20=EB=82=98=20agenci?= =?UTF-8?q?es=20=EA=B0=80=20=EB=B9=88=20=EC=9B=90=EA=B2=A9=20=EC=82=AC?= =?UTF-8?q?=EC=A0=84=EC=9D=80=20=EC=B1=84=ED=83=9D=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=EB=8B=A4.=20=ED=95=84=EB=93=9C=EB=AA=85=20?= =?UTF-8?q?=EC=98=A4=ED=83=80=EB=A1=9C=20=EA=B0=90=EC=A7=80=EA=B0=80=20?= =?UTF-8?q?=ED=86=B5=EC=A7=B8=EB=A1=9C=20=EA=BA=BC=EC=A7=80=EB=8A=94=20?= =?UTF-8?q?=EC=82=AC=EA=B3=A0=EB=A5=BC=20=EB=A7=89=EB=8A=94=EB=8B=A4=20-?= =?UTF-8?q?=20=EC=9B=90=EB=AC=B8=EC=9D=80=20=EA=B8=B0=EA=B8=B0=20=EB=B0=96?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=82=98=EA=B0=80=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=EB=8B=A4.=20=EC=82=AC=EC=A0=84=EB=A7=8C=20=EB=B0=9B?= =?UTF-8?q?=EC=95=84=EC=99=80=20=EC=98=A8=EB=94=94=EB=B0=94=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=EB=A1=9C=20=EA=B2=80=EC=82=AC=ED=95=9C=EB=8B=A4=20-?= =?UTF-8?q?=20firestore.rules=20=EB=A1=9C=20app=5Fconfig=20=EB=A5=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0=20=EC=A0=84=EC=9A=A9=EC=9C=BC=EB=A1=9C=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/build.gradle.kts | 1 + data/src/main/assets/risk_lexicon.json | 76 ++++++++++ .../gamss/android/data/di/FirebaseModule.kt | 6 + .../com/gamss/android/data/di/SafetyModule.kt | 40 ++++++ .../safety/BundledRiskLexiconDataSource.kt | 29 ++++ .../safety/FirestoreRiskLexiconDataSource.kt | 67 +++++++++ .../data/safety/RiskLexiconDataStore.kt | 7 + .../data/safety/RiskLexiconLocalDataSource.kt | 52 +++++++ .../data/safety/RiskLexiconRepositoryImpl.kt | 101 +++++++++++++ .../data/safety/model/RiskLexiconDto.kt | 62 ++++++++ .../safety/BundledRiskLexiconContentTest.kt | 101 +++++++++++++ .../safety/RiskLexiconRepositoryImplTest.kt | 134 ++++++++++++++++++ firebase.json | 5 + firestore.rules | 17 +++ gradle/libs.versions.toml | 1 + 15 files changed, 699 insertions(+) create mode 100644 data/src/main/assets/risk_lexicon.json create mode 100644 data/src/main/java/com/gamss/android/data/di/SafetyModule.kt create mode 100644 data/src/main/java/com/gamss/android/data/safety/BundledRiskLexiconDataSource.kt create mode 100644 data/src/main/java/com/gamss/android/data/safety/FirestoreRiskLexiconDataSource.kt create mode 100644 data/src/main/java/com/gamss/android/data/safety/RiskLexiconDataStore.kt create mode 100644 data/src/main/java/com/gamss/android/data/safety/RiskLexiconLocalDataSource.kt create mode 100644 data/src/main/java/com/gamss/android/data/safety/RiskLexiconRepositoryImpl.kt create mode 100644 data/src/main/java/com/gamss/android/data/safety/model/RiskLexiconDto.kt create mode 100644 data/src/test/java/com/gamss/android/data/safety/BundledRiskLexiconContentTest.kt create mode 100644 data/src/test/java/com/gamss/android/data/safety/RiskLexiconRepositoryImplTest.kt create mode 100644 firebase.json create mode 100644 firestore.rules diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 64735ab..3059515 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -61,6 +61,7 @@ dependencies { implementation(platform(libs.firebase.bom)) implementation(libs.firebase.auth) + implementation(libs.firebase.firestore) implementation(libs.kotlinx.coroutines.play.services) testImplementation(libs.junit) diff --git a/data/src/main/assets/risk_lexicon.json b/data/src/main/assets/risk_lexicon.json new file mode 100644 index 0000000..211f8d3 --- /dev/null +++ b/data/src/main/assets/risk_lexicon.json @@ -0,0 +1,76 @@ +{ + "version": 1, + "terms": [ + { "term": "자살", "level": "CRITICAL" }, + { "term": "자살방법", "level": "CRITICAL" }, + { "term": "죽는방법", "level": "CRITICAL" }, + { "term": "죽고싶", "level": "CRITICAL" }, + { "term": "죽어버리", "level": "CRITICAL" }, + { "term": "죽어야겠", "level": "CRITICAL" }, + { "term": "자해", "level": "CRITICAL" }, + { "term": "손목그", "level": "CRITICAL" }, + { "term": "목매달", "level": "CRITICAL" }, + { "term": "목을매", "level": "CRITICAL" }, + { "term": "극단적선택", "level": "CRITICAL" }, + { "term": "살고싶지않", "level": "CRITICAL" }, + { "term": "유서를", "level": "CRITICAL" }, + { "term": "유서써", "level": "CRITICAL" }, + { "term": "유서남", "level": "CRITICAL" }, + { "term": "번개탄", "level": "WARNING" }, + { "term": "사라지고싶", "level": "WARNING" }, + { "term": "없어지고싶", "level": "WARNING" }, + { "term": "살기싫", "level": "WARNING" }, + { "term": "살아갈이유", "level": "WARNING" }, + { "term": "다포기하고싶", "level": "WARNING" }, + { "term": "아무도날필요", "level": "WARNING" }, + { "term": "혼자남겨진것같", "level": "WARNING" } + ], + "safePhrases": [ + "죽고싶지않", + "자살예방", + "자살률", + "자살골", + "자살패스", + "유서깊", + "배고파죽", + "웃겨죽", + "좋아죽", + "귀여워죽", + "더워죽", + "추워죽", + "졸려죽", + "힘들어죽", + "심심해죽", + "죽을맛", + "죽을만큼", + "죽도록", + "죽여주는", + "자해공갈" + ], + "agencies": [ + { + "id": "kr-109", + "name": "자살예방 상담전화", + "description": "24시간 무료 전문 상담", + "phoneNumber": "109", + "url": null, + "priority": 1 + }, + { + "id": "kr-mental-health", + "name": "정신건강 상담전화", + "description": "정신건강 위기 상담", + "phoneNumber": "1577-0199", + "url": null, + "priority": 2 + }, + { + "id": "kr-youth", + "name": "청소년 상담전화", + "description": "청소년 전문 상담", + "phoneNumber": "1388", + "url": null, + "priority": 3 + } + ] +} diff --git a/data/src/main/java/com/gamss/android/data/di/FirebaseModule.kt b/data/src/main/java/com/gamss/android/data/di/FirebaseModule.kt index aefdd37..b0b08d8 100644 --- a/data/src/main/java/com/gamss/android/data/di/FirebaseModule.kt +++ b/data/src/main/java/com/gamss/android/data/di/FirebaseModule.kt @@ -3,6 +3,8 @@ package com.gamss.android.data.di import com.google.firebase.Firebase import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.auth +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.firestore import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -15,4 +17,8 @@ internal object FirebaseModule { @Provides @Singleton fun provideFirebaseAuth(): FirebaseAuth = Firebase.auth + + @Provides + @Singleton + fun provideFirebaseFirestore(): FirebaseFirestore = Firebase.firestore } diff --git a/data/src/main/java/com/gamss/android/data/di/SafetyModule.kt b/data/src/main/java/com/gamss/android/data/di/SafetyModule.kt new file mode 100644 index 0000000..682f762 --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/di/SafetyModule.kt @@ -0,0 +1,40 @@ +package com.gamss.android.data.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile +import com.gamss.android.data.safety.RiskLexiconDataStore +import com.gamss.android.data.safety.RiskLexiconRepositoryImpl +import com.gamss.android.domain.safety.RiskLexiconRepository +import dagger.Binds +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 + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class SafetyModule { + + @Binds + abstract fun bindRiskLexiconRepository( + riskLexiconRepositoryImpl: RiskLexiconRepositoryImpl, + ): RiskLexiconRepository + + companion object { + @Provides + @Singleton + @RiskLexiconDataStore + fun provideRiskLexiconDataStore( + @ApplicationContext context: Context, + ): DataStore = PreferenceDataStoreFactory.create( + produceFile = { context.preferencesDataStoreFile(RISK_LEXICON_DATA_STORE_NAME) }, + ) + + private const val RISK_LEXICON_DATA_STORE_NAME = "risk_lexicon" + } +} diff --git a/data/src/main/java/com/gamss/android/data/safety/BundledRiskLexiconDataSource.kt b/data/src/main/java/com/gamss/android/data/safety/BundledRiskLexiconDataSource.kt new file mode 100644 index 0000000..1d57e02 --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/safety/BundledRiskLexiconDataSource.kt @@ -0,0 +1,29 @@ +package com.gamss.android.data.safety + +import android.content.Context +import com.gamss.android.data.safety.model.RiskLexiconDto +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 앱에 내장된 기본 사전. 네트워크와 캐시가 모두 없을 때의 최종 폴백이라 실패하면 그대로 던진다. + */ +@Singleton +internal class BundledRiskLexiconDataSource @Inject constructor( + @param:ApplicationContext private val context: Context, + private val json: Json, +) { + suspend fun load(): RiskLexiconDto = withContext(Dispatchers.IO) { + context.assets.open(ASSET_NAME).bufferedReader().use { reader -> + json.decodeFromString(reader.readText()) + } + } + + private companion object { + const val ASSET_NAME = "risk_lexicon.json" + } +} diff --git a/data/src/main/java/com/gamss/android/data/safety/FirestoreRiskLexiconDataSource.kt b/data/src/main/java/com/gamss/android/data/safety/FirestoreRiskLexiconDataSource.kt new file mode 100644 index 0000000..b241c23 --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/safety/FirestoreRiskLexiconDataSource.kt @@ -0,0 +1,67 @@ +package com.gamss.android.data.safety + +import com.gamss.android.data.safety.model.RiskLexiconDto +import com.gamss.android.data.safety.model.RiskTermDto +import com.gamss.android.data.safety.model.SupportAgencyDto +import com.google.firebase.firestore.DocumentSnapshot +import com.google.firebase.firestore.FirebaseFirestore +import kotlinx.coroutines.tasks.await +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 사전 전체를 문서 하나에 담아 읽기 1회로 끝낸다. 쓰기는 콘솔에서만 하고 클라이언트는 읽기만 한다. + */ +@Singleton +internal class FirestoreRiskLexiconDataSource @Inject constructor( + private val firestore: FirebaseFirestore, +) { + suspend fun fetch(): RiskLexiconDto? = + firestore.collection(COLLECTION) + .document(DOCUMENT) + .get() + .await() + .takeIf { it.exists() } + ?.toDto() + + private fun DocumentSnapshot.toDto(): RiskLexiconDto = RiskLexiconDto( + version = getLong(FIELD_VERSION)?.toInt() ?: 0, + terms = objectList(FIELD_TERMS).map { + RiskTermDto(term = it.string(FIELD_TERM), level = it.string(FIELD_LEVEL)) + }, + safePhrases = (get(FIELD_SAFE_PHRASES) as? List<*>).orEmpty().filterIsInstance(), + agencies = objectList(FIELD_AGENCIES).map { + SupportAgencyDto( + id = it.string(FIELD_ID), + name = it.string(FIELD_NAME), + description = it.string(FIELD_DESCRIPTION), + phoneNumber = it[FIELD_PHONE_NUMBER] as? String, + url = it[FIELD_URL] as? String, + priority = (it[FIELD_PRIORITY] as? Number)?.toInt() ?: Int.MAX_VALUE, + ) + }, + ) + + private fun DocumentSnapshot.objectList(field: String): List> = + (get(field) as? List<*>).orEmpty().filterIsInstance>() + + private fun Map<*, *>.string(key: String): String = (this[key] as? String).orEmpty() + + private companion object { + const val COLLECTION = "app_config" + const val DOCUMENT = "risk_lexicon" + + const val FIELD_VERSION = "version" + const val FIELD_TERMS = "terms" + const val FIELD_SAFE_PHRASES = "safePhrases" + const val FIELD_AGENCIES = "agencies" + const val FIELD_TERM = "term" + const val FIELD_LEVEL = "level" + const val FIELD_ID = "id" + const val FIELD_NAME = "name" + const val FIELD_DESCRIPTION = "description" + const val FIELD_PHONE_NUMBER = "phoneNumber" + const val FIELD_URL = "url" + const val FIELD_PRIORITY = "priority" + } +} diff --git a/data/src/main/java/com/gamss/android/data/safety/RiskLexiconDataStore.kt b/data/src/main/java/com/gamss/android/data/safety/RiskLexiconDataStore.kt new file mode 100644 index 0000000..ab55516 --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/safety/RiskLexiconDataStore.kt @@ -0,0 +1,7 @@ +package com.gamss.android.data.safety + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +internal annotation class RiskLexiconDataStore diff --git a/data/src/main/java/com/gamss/android/data/safety/RiskLexiconLocalDataSource.kt b/data/src/main/java/com/gamss/android/data/safety/RiskLexiconLocalDataSource.kt new file mode 100644 index 0000000..43f67bd --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/safety/RiskLexiconLocalDataSource.kt @@ -0,0 +1,52 @@ +package com.gamss.android.data.safety + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +internal data class CachedRiskLexicon( + val json: String, + val fetchedAtMillis: Long, +) + +@Singleton +internal class RiskLexiconLocalDataSource @Inject constructor( + @param:RiskLexiconDataStore private val dataStore: DataStore, +) { + suspend fun read(): CachedRiskLexicon? = withContext(Dispatchers.IO) { + val preferences = dataStore.data.first() + val json = preferences[LEXICON_JSON_KEY] ?: return@withContext null + CachedRiskLexicon( + json = json, + fetchedAtMillis = preferences[FETCHED_AT_KEY] ?: 0L, + ) + } + + suspend fun write(json: String, fetchedAtMillis: Long): Unit = withContext(Dispatchers.IO) { + dataStore.edit { preferences -> + preferences[LEXICON_JSON_KEY] = json + preferences[FETCHED_AT_KEY] = fetchedAtMillis + } + } + + /** + * 원격 사전을 채택하지 않았더라도 조회에 성공했다면 시각만 갱신해 TTL 이 흐르게 한다. + */ + suspend fun markFetched(fetchedAtMillis: Long): Unit = withContext(Dispatchers.IO) { + dataStore.edit { preferences -> + preferences[FETCHED_AT_KEY] = fetchedAtMillis + } + } + + private companion object { + val LEXICON_JSON_KEY = stringPreferencesKey("risk_lexicon_json") + val FETCHED_AT_KEY = longPreferencesKey("risk_lexicon_fetched_at") + } +} diff --git a/data/src/main/java/com/gamss/android/data/safety/RiskLexiconRepositoryImpl.kt b/data/src/main/java/com/gamss/android/data/safety/RiskLexiconRepositoryImpl.kt new file mode 100644 index 0000000..1d59576 --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/safety/RiskLexiconRepositoryImpl.kt @@ -0,0 +1,101 @@ +package com.gamss.android.data.safety + +import com.gamss.android.data.safety.model.RiskLexiconDto +import com.gamss.android.data.safety.model.toDomain +import com.gamss.android.domain.safety.RiskLexicon +import com.gamss.android.domain.safety.RiskLexiconRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.Json +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.hours + +/** + * 내장 사전과 원격 사전 중 version 이 큰 쪽을 쓴다. 원격 조회나 캐시 파싱이 실패해도 + * 내장 사전으로 계속 동작해야 하므로 이 클래스 밖으로 예외를 내보내지 않는다. + */ +@Singleton +internal class RiskLexiconRepositoryImpl @Inject constructor( + private val bundled: BundledRiskLexiconDataSource, + private val remote: FirestoreRiskLexiconDataSource, + private val local: RiskLexiconLocalDataSource, + private val json: Json, +) : RiskLexiconRepository { + + private val mutex = Mutex() + private var cached: RiskLexicon? = null + + override suspend fun getLexicon(): RiskLexicon = mutex.withLock { loadOnce() } + + override suspend fun refresh() { + if (isCacheStale()) { + fetchRemoteOrNull()?.let { adopt(it) } + } + } + + private suspend fun adopt(fetched: RiskLexiconDto) { + // 채택 여부와 무관하게 조회 시각을 남긴다. 남기지 않으면 TTL 이 갱신되지 않아 앱 실행마다 다시 조회한다. + runSafely { local.markFetched(System.currentTimeMillis()) } + + // 필드명이 바뀌거나 파싱이 어긋나면 빈 사전이 온다. 그대로 채택하면 감지가 통째로 꺼진다. + if (fetched.terms.isEmpty() || fetched.agencies.isEmpty()) return + + mutex.withLock { + if (fetched.version > loadOnce().version) { + writeCache(fetched) + cached = fetched.toDomain() + } + } + } + + private suspend fun loadOnce(): RiskLexicon = cached ?: load().also { cached = it } + + private suspend fun load(): RiskLexicon { + val candidates = listOfNotNull(loadBundledOrNull(), loadStoredOrNull()) + return candidates.maxByOrNull { it.version }?.toDomain() ?: EMPTY + } + + private suspend fun isCacheStale(): Boolean { + val fetchedAt = readCacheOrNull()?.fetchedAtMillis ?: return true + return System.currentTimeMillis() - fetchedAt >= CACHE_TTL_MILLIS + } + + private suspend fun writeCache(dto: RiskLexiconDto) { + runSafely { local.write(json.encodeToString(dto), System.currentTimeMillis()) } + } + + private suspend fun readCacheOrNull(): CachedRiskLexicon? = runSafely { local.read() } + + private suspend fun loadStoredOrNull(): RiskLexiconDto? = runSafely { + readCacheOrNull()?.json?.let { json.decodeFromString(it) } + } + + private suspend fun loadBundledOrNull(): RiskLexiconDto? = runSafely { bundled.load() } + + private suspend fun fetchRemoteOrNull(): RiskLexiconDto? = runSafely { remote.fetch() } + + /** + * 사전 로딩 실패가 감지 실패로 이어지지 않도록 삼킨다. 취소만 그대로 전파한다. + */ + @Suppress("TooGenericExceptionCaught", "SwallowedException") + private suspend fun runSafely(block: suspend () -> T): T? = + try { + block() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } + + private companion object { + val EMPTY = RiskLexicon( + version = 0, + terms = emptyList(), + safePhrases = emptyList(), + agencies = emptyList(), + ) + val CACHE_TTL_MILLIS = 24.hours.inWholeMilliseconds + } +} diff --git a/data/src/main/java/com/gamss/android/data/safety/model/RiskLexiconDto.kt b/data/src/main/java/com/gamss/android/data/safety/model/RiskLexiconDto.kt new file mode 100644 index 0000000..ed1be6a --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/safety/model/RiskLexiconDto.kt @@ -0,0 +1,62 @@ +package com.gamss.android.data.safety.model + +import com.gamss.android.domain.safety.RiskLevel +import com.gamss.android.domain.safety.RiskLexicon +import com.gamss.android.domain.safety.RiskTerm +import com.gamss.android.domain.safety.SupportAgency +import kotlinx.serialization.Serializable + +@Serializable +internal data class RiskLexiconDto( + val version: Int = 0, + val terms: List = emptyList(), + val safePhrases: List = emptyList(), + val agencies: List = emptyList(), +) + +@Serializable +internal data class RiskTermDto( + val term: String = "", + val level: String = "", +) + +@Serializable +internal data class SupportAgencyDto( + val id: String = "", + val name: String = "", + val description: String = "", + val phoneNumber: String? = null, + val url: String? = null, + val priority: Int = DEFAULT_PRIORITY, +) + +internal fun RiskLexiconDto.toDomain(): RiskLexicon = RiskLexicon( + version = version, + terms = terms.filter { it.term.isNotBlank() }.map { it.toDomain() }, + safePhrases = safePhrases.filter { it.isNotBlank() }, + agencies = agencies.filter { it.name.isNotBlank() }.map { it.toDomain() }, +) + +/** + * 알 수 없는 등급 문자열은 차단하지 않는 WARNING 으로 강등한다. + * 원격 사전의 오타가 곧바로 과차단이 되면 안 된다. + */ +private fun RiskTermDto.toDomain(): RiskTerm = RiskTerm( + term = term, + level = when (level.uppercase()) { + RiskLevel.CRITICAL.name -> RiskLevel.CRITICAL + RiskLevel.NONE.name -> RiskLevel.NONE + else -> RiskLevel.WARNING + }, +) + +private fun SupportAgencyDto.toDomain(): SupportAgency = SupportAgency( + id = id, + name = name, + description = description, + phoneNumber = phoneNumber?.takeIf { it.isNotBlank() }, + url = url?.takeIf { it.isNotBlank() }, + priority = priority, +) + +private const val DEFAULT_PRIORITY = Int.MAX_VALUE diff --git a/data/src/test/java/com/gamss/android/data/safety/BundledRiskLexiconContentTest.kt b/data/src/test/java/com/gamss/android/data/safety/BundledRiskLexiconContentTest.kt new file mode 100644 index 0000000..50e5a0f --- /dev/null +++ b/data/src/test/java/com/gamss/android/data/safety/BundledRiskLexiconContentTest.kt @@ -0,0 +1,101 @@ +package com.gamss.android.data.safety + +import com.gamss.android.data.safety.model.RiskLexiconDto +import com.gamss.android.data.safety.model.toDomain +import com.gamss.android.domain.safety.RiskLevel +import com.gamss.android.domain.safety.RiskTermMatcher +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test +import java.io.File + +/** + * 실제로 앱에 실리는 사전을 검증한다. CRITICAL 은 사용자의 글을 차단하므로 오탐 하나의 비용이 크다. + * 사전을 고칠 때마다 이 문장 목록으로 회귀를 잡는다. + */ +class BundledRiskLexiconContentTest { + + private val lexicon = JSON + .decodeFromString(File(ASSET_PATH).readText()) + .toDomain() + + private val matcher = RiskTermMatcher() + + @Test + fun `차단해야 하는 문장은 CRITICAL로 판정한다`() { + CRITICAL_SENTENCES.forEach { sentence -> + assertEquals(sentence, RiskLevel.CRITICAL, matcher.match(sentence, lexicon).level) + } + } + + @Test + fun `안내가 필요한 문장은 감지한다`() { + WARNING_SENTENCES.forEach { sentence -> + assertNotEquals(sentence, RiskLevel.NONE, matcher.match(sentence, lexicon).level) + } + } + + @Test + fun `일상적인 문장은 감지하지 않는다`() { + BENIGN_SENTENCES.forEach { sentence -> + assertEquals(sentence, RiskLevel.NONE, matcher.match(sentence, lexicon).level) + } + } + + @Test + fun `감지되면 상담 기관을 priority 순으로 안내한다`() { + val detection = matcher.match(CRITICAL_SENTENCES.first(), lexicon) + + assertEquals("109", detection.agencies.first().phoneNumber) + assertEquals(detection.agencies.map { it.priority }.sorted(), detection.agencies.map { it.priority }) + } + + private companion object { + const val ASSET_PATH = "src/main/assets/risk_lexicon.json" + + val JSON = Json { ignoreUnknownKeys = true } + + val CRITICAL_SENTENCES = listOf( + "요즘 계속 죽고 싶다는 생각만 든다", + "자살에 대해 계속 검색하게 된다", + "자 살", + "죽.고.싶.다", + "어제 또 자해를 했다", + "이제 정말 살고 싶지 않아", + "유서를 미리 써두었다", + "목을 매는 상상을 한다", + ) + + val WARNING_SENTENCES = listOf( + "그냥 조용히 사라지고 싶어", + "요즘은 그냥 살기 싫다", + "다 포기하고 싶은 하루였다", + ) + + val BENIGN_SENTENCES = listOf( + "오늘 점심이 정말 맛있었다", + "배고파 죽겠다", + "웃겨 죽는 줄 알았어", + "너무 귀여워 죽겠어", + "더워 죽겠네", + "죽을 만큼 노력했다", + "죽도록 공부했다", + "친구랑 죽을 맛인 하루를 보냈다", + "학교에서 자살예방 교육을 들었다", + "OECD 자살률 기사를 읽었다", + "경기에서 자살골을 넣었다", + "수비가 자살 패스를 했다", + "유서 깊은 사찰에 다녀왔다", + "유서 있는 마을을 걸었다", + "걔한테 너무 목매지 마", + "그 회사에 목매고 있다", + "손목 시계를 새로 샀다", + "이 동네는 살기 좋다", + // 공백을 지우면 어절을 넘어 "자살"이 생기는 문장들 + "편의점에서 과자 살까 말까 고민했다", + "요즘은 혼자 살아보니 편하다", + "감자 살 때 크기를 봤다", + ) + } +} diff --git a/data/src/test/java/com/gamss/android/data/safety/RiskLexiconRepositoryImplTest.kt b/data/src/test/java/com/gamss/android/data/safety/RiskLexiconRepositoryImplTest.kt new file mode 100644 index 0000000..ad500d7 --- /dev/null +++ b/data/src/test/java/com/gamss/android/data/safety/RiskLexiconRepositoryImplTest.kt @@ -0,0 +1,134 @@ +package com.gamss.android.data.safety + +import com.gamss.android.data.safety.model.RiskLexiconDto +import com.gamss.android.data.safety.model.RiskTermDto +import com.gamss.android.data.safety.model.SupportAgencyDto +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.io.IOException + +class RiskLexiconRepositoryImplTest { + + private lateinit var bundled: BundledRiskLexiconDataSource + private lateinit var remote: FirestoreRiskLexiconDataSource + private lateinit var local: RiskLexiconLocalDataSource + + private val json = Json { ignoreUnknownKeys = true } + + @Before + fun setUp() { + bundled = mockk() + remote = mockk() + local = mockk(relaxed = true) + } + + private fun repository() = RiskLexiconRepositoryImpl(bundled, remote, local, json) + + @Test + fun `Firestore 조회가 실패해도 내장 사전을 반환한다`() = runTest { + coEvery { bundled.load() } returns dto(version = 1) + coEvery { local.read() } returns null + coEvery { remote.fetch() } throws IOException("network down") + + val repository = repository() + repository.refresh() + + assertEquals(1, repository.getLexicon().version) + coVerify(exactly = 0) { local.write(any(), any()) } + } + + @Test + fun `내장 사전 로딩이 실패해도 예외를 던지지 않는다`() = runTest { + coEvery { bundled.load() } throws IOException("asset missing") + coEvery { local.read() } returns null + + assertEquals(0, repository().getLexicon().version) + } + + @Test + fun `원격 version이 내장본보다 낮으면 갱신하지 않는다`() = runTest { + coEvery { bundled.load() } returns dto(version = 5) + coEvery { local.read() } returns null + coEvery { remote.fetch() } returns dto(version = 2, term = "원격단어") + + val repository = repository() + repository.refresh() + + coVerify(exactly = 0) { local.write(any(), any()) } + assertEquals(5, repository.getLexicon().version) + } + + @Test + fun `원격 version이 더 크면 캐시를 갱신하고 원격 사전을 쓴다`() = runTest { + coEvery { bundled.load() } returns dto(version = 1) + coEvery { local.read() } returns null + coEvery { remote.fetch() } returns dto(version = 9, term = "원격단어") + + val repository = repository() + repository.refresh() + + coVerify(exactly = 1) { local.write(any(), any()) } + val lexicon = repository.getLexicon() + assertEquals(9, lexicon.version) + assertEquals(listOf("원격단어"), lexicon.terms.map { it.term }) + } + + @Test + fun `캐시된 사전이 내장본보다 최신이면 캐시를 쓴다`() = runTest { + coEvery { bundled.load() } returns dto(version = 1) + coEvery { local.read() } returns CachedRiskLexicon( + json = json.encodeToString(dto(version = 4, term = "캐시단어")), + fetchedAtMillis = System.currentTimeMillis(), + ) + + val lexicon = repository().getLexicon() + + assertEquals(4, lexicon.version) + assertEquals(listOf("캐시단어"), lexicon.terms.map { it.term }) + } + + @Test + fun `캐시가 손상되면 내장 사전으로 폴백한다`() = runTest { + coEvery { bundled.load() } returns dto(version = 1) + coEvery { local.read() } returns CachedRiskLexicon( + json = "{ not valid json", + fetchedAtMillis = System.currentTimeMillis(), + ) + + assertEquals(1, repository().getLexicon().version) + } + + @Test + fun `캐시가 아직 신선하면 원격을 조회하지 않는다`() = runTest { + coEvery { bundled.load() } returns dto(version = 1) + coEvery { local.read() } returns CachedRiskLexicon( + json = json.encodeToString(dto(version = 4)), + fetchedAtMillis = System.currentTimeMillis(), + ) + + repository().refresh() + + coVerify(exactly = 0) { remote.fetch() } + } + + private fun dto(version: Int, term: String = "죽고싶") = RiskLexiconDto( + version = version, + terms = listOf(RiskTermDto(term = term, level = "CRITICAL")), + safePhrases = emptyList(), + agencies = listOf( + SupportAgencyDto( + id = "kr-109", + name = "자살예방 상담전화", + description = "24시간 무료 전문 상담", + phoneNumber = "109", + priority = 1, + ), + ), + ) +} diff --git a/firebase.json b/firebase.json new file mode 100644 index 0000000..0207c3f --- /dev/null +++ b/firebase.json @@ -0,0 +1,5 @@ +{ + "firestore": { + "rules": "firestore.rules" + } +} diff --git a/firestore.rules b/firestore.rules new file mode 100644 index 0000000..4d368ca --- /dev/null +++ b/firestore.rules @@ -0,0 +1,17 @@ +rules_version = '2'; + +service cloud.firestore { + match /databases/{database}/documents { + + // 위험 표현 사전. 클라이언트는 읽기만 한다. 사전 수정은 콘솔이나 서버에서만 이뤄져야 한다. + match /app_config/{document} { + allow read: if request.auth != null; + allow write: if false; + } + + // 명시적으로 허용한 경로 외에는 모두 차단한다. + match /{document=**} { + allow read, write: if false; + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1bb142c..a29dbcc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -105,6 +105,7 @@ firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "fir firebase-analytics = { module = "com.google.firebase:firebase-analytics" } firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" } firebase-auth = { module = "com.google.firebase:firebase-auth" } +firebase-firestore = { module = "com.google.firebase:firebase-firestore" } androidx-credentials = { module = "androidx.credentials:credentials", version.ref = "androidx-credentials" } androidx-credentials-play-services-auth = { module = "androidx.credentials:credentials-play-services-auth", version.ref = "androidx-credentials" } From 9376eb7e81ab819d0dc84c5e8e7d21ad9c51a97e Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:59:14 +0900 Subject: [PATCH 03/11] =?UTF-8?q?feat:=20=EC=83=81=EB=8B=B4=20=EA=B8=B0?= =?UTF-8?q?=EA=B4=80=20=EC=95=88=EB=82=B4=20=EB=8B=A4=EC=9D=B4=EC=96=BC?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EC=B6=94=EA=B0=80=20-=20=EC=9C=84?= =?UTF-8?q?=ED=97=98=20=ED=91=9C=ED=98=84=EC=9D=B4=20=EA=B0=90=EC=A7=80?= =?UTF-8?q?=EB=90=98=EB=A9=B4=20=EC=83=81=EB=8B=B4=20=EC=B0=BD=EA=B5=AC?= =?UTF-8?q?=EB=A5=BC=20priority=20=EC=88=9C=EC=9C=BC=EB=A1=9C=20=EB=85=B8?= =?UTF-8?q?=EC=B6=9C=ED=95=98=EA=B3=A0=20=EC=A0=84=ED=99=94=20=EA=B1=B8?= =?UTF-8?q?=EA=B8=B0=EB=A5=BC=20=EC=A0=9C=EA=B3=B5=ED=95=98=EB=8A=94=20Gam?= =?UTF-8?q?ssSupportAgencyDialog=20=EC=B6=94=EA=B0=80.=20=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8=EB=90=9C=20=EA=B2=BD=EC=9A=B0=EC=99=80=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=EB=A7=8C=20=ED=95=98=EB=8A=94=20=EA=B2=BD=EC=9A=B0?= =?UTF-8?q?=EC=9D=98=20=EB=AC=B8=EA=B5=AC=EB=A5=BC=20=EA=B5=AC=EB=B6=84?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20-=20dial()=20=EC=9D=80=20ACTION=5FDIAL=20?= =?UTF-8?q?=EB=A1=9C=20=EB=B2=88=ED=98=B8=EB=A7=8C=20=EC=B1=84=EC=9A=B0?= =?UTF-8?q?=EA=B3=A0=20=EB=B0=9C=EC=8B=A0=EC=9D=80=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=EA=B0=80=20=EB=88=84=EB=A5=B8=EB=8B=A4.=20CALL=5FPHON?= =?UTF-8?q?E=20=EA=B6=8C=ED=95=9C=EC=9D=84=20=EC=9A=94=EA=B5=AC=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B8=B0=20=EC=9C=84=ED=95=B4=EC=84=9C?= =?UTF-8?q?=EB=8B=A4=20-=20=EB=8B=A4=EC=9D=B4=EC=96=BC=EB=9F=AC=EA=B0=80?= =?UTF-8?q?=20=EC=97=86=EB=8A=94=20=EA=B8=B0=EA=B8=B0=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=EC=9D=B4=20=EB=AC=B4=EB=B0=98=EC=9D=91?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=B4=EC=9D=B4=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20dial()=20=EC=9D=B4=20=EC=84=B1=EA=B3=B5=20?= =?UTF-8?q?=EC=97=AC=EB=B6=80=EB=A5=BC=20=EB=B0=98=ED=99=98=ED=95=9C?= =?UTF-8?q?=EB=8B=A4=20-=20Android=2011+=20=ED=8C=A8=ED=82=A4=EC=A7=80=20?= =?UTF-8?q?=EA=B0=80=EC=8B=9C=EC=84=B1=20=EC=A0=95=EC=B1=85=EC=97=90=20?= =?UTF-8?q?=EB=A7=89=ED=9E=88=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20And?= =?UTF-8?q?roidManifest=20=EC=97=90=20DIAL=20queries=20=EC=84=A0=EC=96=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 8 ++ core/ui/build.gradle.kts | 2 + .../com/gamss/android/core/ui/ExternalLink.kt | 22 +++++ .../core/ui/GamssSupportAgencyDialog.kt | 92 +++++++++++++++++++ core/ui/src/main/res/values/strings.xml | 11 +++ 5 files changed, 135 insertions(+) create mode 100644 core/ui/src/main/kotlin/com/gamss/android/core/ui/ExternalLink.kt create mode 100644 core/ui/src/main/kotlin/com/gamss/android/core/ui/GamssSupportAgencyDialog.kt create mode 100644 core/ui/src/main/res/values/strings.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 553a344..1d9aa95 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,14 @@ + + + + + + + + , + isBlocking: Boolean, + onCallClick: (SupportAgency) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.safety_dialog_title)) }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource( + if (isBlocking) { + R.string.safety_dialog_body_blocked + } else { + R.string.safety_dialog_body_notice + }, + ), + style = MaterialTheme.typography.bodyMedium, + ) + agencies.forEach { agency -> + SupportAgencyRow(agency = agency, onCallClick = onCallClick) + } + Text( + text = stringResource(R.string.safety_dialog_emergency), + style = MaterialTheme.typography.bodySmall, + ) + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.safety_dialog_confirm)) + } + }, + ) +} + +@Composable +private fun SupportAgencyRow( + agency: SupportAgency, + onCallClick: (SupportAgency) -> Unit, +) { + val callDescription = stringResource(R.string.safety_agency_call_description, agency.name) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = agency.name, style = MaterialTheme.typography.titleSmall) + Text(text = agency.description, style = MaterialTheme.typography.bodySmall) + } + agency.phoneNumber?.let { phoneNumber -> + TextButton( + onClick = { onCallClick(agency) }, + modifier = Modifier.semantics { contentDescription = callDescription }, + ) { + Text("$phoneNumber ${stringResource(R.string.safety_agency_call)}") + } + } + } +} diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml new file mode 100644 index 0000000..ef67071 --- /dev/null +++ b/core/ui/src/main/res/values/strings.xml @@ -0,0 +1,11 @@ + + + 혼자 감당하지 않아도 괜찮아요 + 작성하신 내용에서 많이 힘든 신호가 보여요. 잠시 멈추고, 전문가와 먼저 이야기해 보시면 좋겠어요. + 작성하신 내용에서 힘든 신호가 보여요. 필요하다면 전문가와 이야기할 수 있어요. + 전화 앱을 열 수 없어요. %1$s 로 직접 걸어주세요. + 지금 당장 위험한 상황이라면 119로 전화해 주세요. + 닫기 + 전화하기 + %1$s에 전화하기 + From 5ea16e39f324a6522a696ae398cf3d6f9c99cb11 Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:59:25 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat:=20=EA=B0=90=EC=A0=95=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=ED=99=94=EB=A9=B4=EC=97=90=20=EC=9C=84=ED=97=98=20?= =?UTF-8?q?=ED=91=9C=ED=98=84=20=EA=B0=90=EC=A7=80=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?-=20onAnalyze()=20=EC=97=90=EC=84=9C=20=EA=B0=90=EC=A0=95=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20=EC=A0=84=EC=97=90=20=EC=9B=90=EB=AC=B8?= =?UTF-8?q?=EC=9D=84=20=EA=B2=80=EC=82=AC=ED=95=98=EA=B3=A0,=20CRITICAL=20?= =?UTF-8?q?=EC=9D=B4=EB=A9=B4=20=EB=B6=84=EC=84=9D=EC=9D=84=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=EB=8B=A4.=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8=ED=95=B4=EB=8F=84=20=EC=9E=85=EB=A0=A5=20sta?= =?UTF-8?q?te=20=EB=8A=94=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=EB=91=AC=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=EB=A5=BC=20=EB=8B=AB=EC=95=98=EC=9D=84=20?= =?UTF-8?q?=EB=95=8C=20=EC=82=AC=EC=9A=A9=EC=9E=90=EA=B0=80=20=EC=93=B4=20?= =?UTF-8?q?=EA=B8=80=EC=9D=B4=20=EB=82=A8=EC=95=84=20=EC=9E=88=EA=B2=8C=20?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20-=20=EC=82=AC=EC=A0=84=20=EB=A1=9C?= =?UTF-8?q?=EB=94=A9=EC=9D=B4=20suspend=20=EC=A7=80=EC=A0=90=EC=9D=B4?= =?UTF-8?q?=EB=9D=BC=20=EC=97=B0=ED=83=80=EB=A1=9C=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=EB=90=A0=20=EC=88=98=20=EC=9E=88=EC=96=B4=20?= =?UTF-8?q?isRunning=20=EA=B0=B1=EC=8B=A0=EC=9D=84=20=EA=B0=90=EC=A7=80=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=20=EC=95=9E=EC=9C=BC=EB=A1=9C=20=EC=98=AE?= =?UTF-8?q?=EA=B2=BC=EB=8B=A4=20-=20=EC=9B=90=EB=AC=B8=EC=9D=80=20?= =?UTF-8?q?=EB=85=B8=EC=B6=9C=C2=B7=EA=B8=B0=EB=A1=9D=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=EB=8B=A4=EB=8A=94=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=EC=9D=84=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20?= =?UTF-8?q?=EB=94=B0=EB=A5=B8=EB=8B=A4=20-=20=EC=95=B1=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91=20=EC=8B=9C=201=ED=9A=8C=20=EC=82=AC=EC=A0=84?= =?UTF-8?q?=EC=9D=84=20=EA=B0=B1=EC=8B=A0=ED=95=9C=EB=8B=A4.=20=EC=A0=84?= =?UTF-8?q?=EC=86=A1=EB=A7=88=EB=8B=A4=20=EB=84=A4=ED=8A=B8=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=EB=A5=BC=20=ED=83=80=EB=A9=B4=20=EA=B0=90=EC=A7=80?= =?UTF-8?q?=EA=B0=80=20=EB=8A=90=EB=A0=A4=EC=A7=80=EA=B3=A0=20=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0=20=EB=B9=84=EC=9A=A9=EC=9D=B4=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EB=9F=89=EC=97=90=20=EB=B9=84=EB=A1=80=ED=95=B4=20=EB=8A=98?= =?UTF-8?q?=EC=96=B4=EB=82=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 2 ++ .../com/gamss/android/app/GamssApplication.kt | 32 ++++++++++++++++++- feature/emotion/build.gradle.kts | 1 + .../android/feature/emotion/EmotionScreen.kt | 30 +++++++++++++++++ .../android/feature/emotion/EmotionState.kt | 2 ++ .../feature/emotion/EmotionViewModel.kt | 21 +++++++++++- 6 files changed, 86 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aa5294b..8472a39 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -71,6 +71,8 @@ dependencies { implementation(libs.orbit.viewmodel) implementation(libs.orbit.compose) + implementation(libs.kotlinx.coroutines.android) + implementation(platform(libs.firebase.bom)) implementation(libs.firebase.crashlytics) implementation(libs.firebase.analytics) diff --git a/app/src/main/kotlin/com/gamss/android/app/GamssApplication.kt b/app/src/main/kotlin/com/gamss/android/app/GamssApplication.kt index 3c54356..15572d0 100644 --- a/app/src/main/kotlin/com/gamss/android/app/GamssApplication.kt +++ b/app/src/main/kotlin/com/gamss/android/app/GamssApplication.kt @@ -1,7 +1,37 @@ package com.gamss.android.app import android.app.Application +import android.util.Log +import com.gamss.android.domain.safety.RefreshRiskLexiconUseCase import dagger.hilt.android.HiltAndroidApp +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val TAG = "GamssApplication" @HiltAndroidApp -class GamssApplication : Application() +class GamssApplication : Application() { + + @Inject + lateinit var refreshRiskLexicon: RefreshRiskLexiconUseCase + + /** + * 백그라운드 초기화 작업의 실패가 앱을 죽이지 않게 한다. + */ + private val applicationScope = CoroutineScope( + SupervisorJob() + Dispatchers.IO + + CoroutineExceptionHandler { _, throwable -> + Log.w(TAG, "application scope task failed", throwable) + }, + ) + + override fun onCreate() { + super.onCreate() + // 위험 표현 사전은 화면 진입 전에 최신화해 둔다. 실패해도 내장 사전으로 동작한다. + applicationScope.launch { refreshRiskLexicon() } + } +} diff --git a/feature/emotion/build.gradle.kts b/feature/emotion/build.gradle.kts index 1a92c59..082b158 100644 --- a/feature/emotion/build.gradle.kts +++ b/feature/emotion/build.gradle.kts @@ -8,4 +8,5 @@ android { dependencies { implementation(projects.domain) + implementation(projects.core.ui) } diff --git a/feature/emotion/src/main/java/com/gamss/android/feature/emotion/EmotionScreen.kt b/feature/emotion/src/main/java/com/gamss/android/feature/emotion/EmotionScreen.kt index 5b41b88..3f1a00b 100644 --- a/feature/emotion/src/main/java/com/gamss/android/feature/emotion/EmotionScreen.kt +++ b/feature/emotion/src/main/java/com/gamss/android/feature/emotion/EmotionScreen.kt @@ -1,5 +1,7 @@ package com.gamss.android.feature.emotion +import android.content.Context +import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -20,10 +22,14 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import com.gamss.android.core.ui.GamssSupportAgencyDialog +import com.gamss.android.core.ui.dial import com.gamss.android.domain.emotion.EmotionResult import org.orbitmvi.orbit.compose.collectAsState +import com.gamss.android.core.ui.R as CoreUiR private const val MAX_DIARY_LEN = 140 private const val PERCENT = 100 @@ -34,6 +40,16 @@ fun EmotionScreen( viewModel: EmotionViewModel = hiltViewModel(), ) { val state by viewModel.collectAsState() + val context = LocalContext.current + + state.riskDetection?.let { detection -> + GamssSupportAgencyDialog( + agencies = detection.agencies, + isBlocking = detection.shouldBlock, + onCallClick = { agency -> context.dialOrNotify(agency.phoneNumber) }, + onDismiss = viewModel::onRiskGuidanceDismiss, + ) + } Scaffold( topBar = { TopAppBar(title = { Text("오늘의 일기 감정") }) }, @@ -79,6 +95,20 @@ fun EmotionScreen( } } +/** + * 다이얼러를 열 수 없는 기기에서 버튼이 무반응으로 보이지 않도록 번호를 안내한다. + */ +private fun Context.dialOrNotify(phoneNumber: String?) { + if (phoneNumber == null) return + if (!dial(phoneNumber)) { + Toast.makeText( + this, + getString(CoreUiR.string.safety_call_unavailable, phoneNumber), + Toast.LENGTH_LONG, + ).show() + } +} + @Composable private fun ResultCard(result: EmotionResult) { val distribution = result.distribution diff --git a/feature/emotion/src/main/java/com/gamss/android/feature/emotion/EmotionState.kt b/feature/emotion/src/main/java/com/gamss/android/feature/emotion/EmotionState.kt index c08e5ae..5fa298d 100644 --- a/feature/emotion/src/main/java/com/gamss/android/feature/emotion/EmotionState.kt +++ b/feature/emotion/src/main/java/com/gamss/android/feature/emotion/EmotionState.kt @@ -1,6 +1,7 @@ package com.gamss.android.feature.emotion import com.gamss.android.domain.emotion.EmotionResult +import com.gamss.android.domain.safety.RiskDetection data class EmotionState( val input: String = "", @@ -8,4 +9,5 @@ data class EmotionState( val result: EmotionResult? = null, val notRecognized: Boolean = false, val error: String? = null, + val riskDetection: RiskDetection? = null, ) diff --git a/feature/emotion/src/main/java/com/gamss/android/feature/emotion/EmotionViewModel.kt b/feature/emotion/src/main/java/com/gamss/android/feature/emotion/EmotionViewModel.kt index 200b690..832e781 100644 --- a/feature/emotion/src/main/java/com/gamss/android/feature/emotion/EmotionViewModel.kt +++ b/feature/emotion/src/main/java/com/gamss/android/feature/emotion/EmotionViewModel.kt @@ -3,6 +3,8 @@ package com.gamss.android.feature.emotion import androidx.lifecycle.ViewModel import com.gamss.android.core.common.AppResult import com.gamss.android.domain.emotion.ClassifyUserEmotionUseCase +import com.gamss.android.domain.safety.DetectRiskInTextUseCase +import com.gamss.android.domain.safety.RiskLevel import dagger.hilt.android.lifecycle.HiltViewModel import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.viewmodel.container @@ -11,6 +13,7 @@ import javax.inject.Inject @HiltViewModel class EmotionViewModel @Inject constructor( private val classifyUserEmotion: ClassifyUserEmotionUseCase, + private val detectRiskInText: DetectRiskInTextUseCase, ) : ViewModel(), ContainerHost { @@ -22,8 +25,20 @@ class EmotionViewModel @Inject constructor( fun onAnalyze() = intent { if (state.isRunning) return@intent - val utterances = listOf(state.input.trim()) + // 위험 감지가 사전 로딩으로 멈출 수 있으므로 그 앞에서 실행 중으로 바꿔 연타를 막는다. reduce { state.copy(isRunning = true, result = null, notRecognized = false, error = null) } + + val detection = detectRiskInText(state.input) + if (detection.level != RiskLevel.NONE) { + reduce { state.copy(riskDetection = detection) } + if (detection.shouldBlock) { + // 차단해도 입력은 그대로 둔다. 안내를 닫았을 때 사용자가 쓴 글이 남아 있어야 한다. + reduce { state.copy(isRunning = false) } + return@intent + } + } + + val utterances = listOf(state.input.trim()) when (val result = classifyUserEmotion(utterances)) { is AppResult.Success -> reduce { state.copy(isRunning = false, result = result.data, notRecognized = result.data == null) @@ -32,4 +47,8 @@ class EmotionViewModel @Inject constructor( is AppResult.Failure -> reduce { state.copy(isRunning = false, error = "감정 분석에 실패했어요") } } } + + fun onRiskGuidanceDismiss() = intent { + reduce { state.copy(riskDetection = null) } + } } From 1fa2e4df7cf39de750e88df79f944e050e9ed36c Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:15:16 +0900 Subject: [PATCH 05/11] =?UTF-8?q?refactor:=20Firestore=20=EC=9D=B8?= =?UTF-8?q?=EC=8A=A4=ED=84=B4=EC=8A=A4=EB=A5=BC=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=EC=A0=90=EA=B9=8C=EC=A7=80=20?= =?UTF-8?q?=EC=A7=80=EC=97=B0=20=EC=83=9D=EC=84=B1=20-=20FirebaseFirestore?= =?UTF-8?q?=20=EB=A5=BC=20Provider=20=EB=A1=9C=20=EB=B0=9B=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B3=80=EA=B2=BD.=20=EC=9D=B8=EC=8A=A4=ED=84=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=83=9D=EC=84=B1=20=EC=8B=9C=EC=A0=90=EC=97=90=20?= =?UTF-8?q?gRPC=20=EC=B1=84=EB=84=90=EA=B3=BC=20ConnectivityManager=20?= =?UTF-8?q?=EC=BD=9C=EB=B0=B1=EC=9D=B4=20=ED=95=A8=EA=BB=98=20=EC=98=AC?= =?UTF-8?q?=EB=9D=BC=EC=98=A4=EB=8A=94=EB=8D=B0,=20=EC=95=B1=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91=20=EC=8B=9C=20=EC=82=AC=EC=A0=84=20=EA=B0=B1=EC=8B=A0?= =?UTF-8?q?=EC=9D=80=20=EC=BA=90=EC=8B=9C=EA=B0=80=20=EC=8B=A0=EC=84=A0?= =?UTF-8?q?=ED=95=98=EB=A9=B4=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=EA=B1=B4?= =?UTF-8?q?=EB=84=88=EB=9B=B0=EB=AF=80=EB=A1=9C=20=EA=B7=B8=20=EA=B2=BD?= =?UTF-8?q?=EC=9A=B0=20=EC=B4=88=EA=B8=B0=ED=99=94=20=EB=B9=84=EC=9A=A9?= =?UTF-8?q?=EC=9D=84=20=EC=B9=98=EB=A5=BC=20=EC=9D=B4=EC=9C=A0=EA=B0=80=20?= =?UTF-8?q?=EC=97=86=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/safety/FirestoreRiskLexiconDataSource.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/data/src/main/java/com/gamss/android/data/safety/FirestoreRiskLexiconDataSource.kt b/data/src/main/java/com/gamss/android/data/safety/FirestoreRiskLexiconDataSource.kt index b241c23..17e8612 100644 --- a/data/src/main/java/com/gamss/android/data/safety/FirestoreRiskLexiconDataSource.kt +++ b/data/src/main/java/com/gamss/android/data/safety/FirestoreRiskLexiconDataSource.kt @@ -7,17 +7,22 @@ import com.google.firebase.firestore.DocumentSnapshot import com.google.firebase.firestore.FirebaseFirestore import kotlinx.coroutines.tasks.await import javax.inject.Inject +import javax.inject.Provider import javax.inject.Singleton /** * 사전 전체를 문서 하나에 담아 읽기 1회로 끝낸다. 쓰기는 콘솔에서만 하고 클라이언트는 읽기만 한다. + * + * Firestore 인스턴스는 Provider 로 받는다. 생성 시점에 gRPC 채널과 네트워크 콜백이 함께 올라오므로, + * 캐시가 신선해 조회를 건너뛰는 실행에서는 그 비용을 치르지 않게 한다. */ @Singleton internal class FirestoreRiskLexiconDataSource @Inject constructor( - private val firestore: FirebaseFirestore, + private val firestoreProvider: Provider, ) { suspend fun fetch(): RiskLexiconDto? = - firestore.collection(COLLECTION) + firestoreProvider.get() + .collection(COLLECTION) .document(DOCUMENT) .get() .await() From c46ba450c9485911b47b4f9b1b2e6964f1c9444b Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:02:32 +0900 Subject: [PATCH 06/11] =?UTF-8?q?chore:=20=EC=9C=84=ED=97=98=20=ED=91=9C?= =?UTF-8?q?=ED=98=84=20=EA=B0=90=EC=A7=80=20=EA=B4=80=EB=A0=A8=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=A0=95=EB=A6=AC=20-=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=9C=A0=EC=B6=94=20=EA=B0=80=EB=8A=A5?= =?UTF-8?q?=ED=95=9C=20=EC=84=A4=EB=AA=85=20=EC=A3=BC=EC=84=9D=EC=9D=84=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=ED=95=98=EA=B3=A0,=20RiskTermMatcher=20?= =?UTF-8?q?=EC=9D=98=20=EC=96=B4=EC=A0=88=20=EA=B2=BD=EA=B3=84=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=EB=A7=8C=20=EB=82=A8=EA=B2=BC=EB=8B=A4.=20=EB=91=90?= =?UTF-8?q?=20=EA=B8=80=EC=9E=90=20=EC=9D=B4=ED=95=98=20=ED=91=9C=ED=98=84?= =?UTF-8?q?=EC=9D=84=20=EC=96=B4=EC=A0=88=20=EA=B2=BD=EA=B3=84=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=ED=95=98=EB=8A=94=20=EC=9D=B4=EC=9C=A0?= =?UTF-8?q?=EB=8A=94=20=EC=BD=94=EB=93=9C=EB=A7=8C=20=EB=B4=90=EC=84=9C?= =?UTF-8?q?=EB=8A=94=20=EC=95=8C=20=EC=88=98=20=EC=97=86=EA=B3=A0,=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=ED=95=98=EB=A9=B4=20"=EA=B3=BC=EC=9E=90=20?= =?UTF-8?q?=EC=82=B4=EA=B9=8C"=20=EC=98=A4=ED=83=90=EC=9D=B4=20=EB=90=98?= =?UTF-8?q?=EC=82=B4=EC=95=84=EB=82=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 1 - .../com/gamss/android/app/GamssApplication.kt | 4 ---- .../com/gamss/android/core/ui/ExternalLink.kt | 6 ------ .../android/core/ui/GamssSupportAgencyDialog.kt | 4 ---- .../data/safety/BundledRiskLexiconDataSource.kt | 3 --- .../data/safety/FirestoreRiskLexiconDataSource.kt | 6 ------ .../data/safety/RiskLexiconLocalDataSource.kt | 3 --- .../data/safety/RiskLexiconRepositoryImpl.kt | 9 --------- .../android/data/safety/model/RiskLexiconDto.kt | 4 ---- .../data/safety/BundledRiskLexiconContentTest.kt | 5 ----- .../domain/safety/DetectRiskInTextUseCase.kt | 5 ----- .../domain/safety/RefreshRiskLexiconUseCase.kt | 4 ---- .../gamss/android/domain/safety/RiskDetection.kt | 3 --- .../com/gamss/android/domain/safety/RiskLevel.kt | 4 ---- .../com/gamss/android/domain/safety/RiskLexicon.kt | 3 --- .../android/domain/safety/RiskLexiconRepository.kt | 7 ------- .../gamss/android/domain/safety/RiskTermMatcher.kt | 14 +++----------- .../gamss/android/feature/emotion/EmotionScreen.kt | 3 --- .../android/feature/emotion/EmotionViewModel.kt | 2 -- firestore.rules | 2 -- 20 files changed, 3 insertions(+), 89 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1d9aa95..11251c6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,7 +3,6 @@ - diff --git a/app/src/main/kotlin/com/gamss/android/app/GamssApplication.kt b/app/src/main/kotlin/com/gamss/android/app/GamssApplication.kt index 15572d0..68a4a1a 100644 --- a/app/src/main/kotlin/com/gamss/android/app/GamssApplication.kt +++ b/app/src/main/kotlin/com/gamss/android/app/GamssApplication.kt @@ -19,9 +19,6 @@ class GamssApplication : Application() { @Inject lateinit var refreshRiskLexicon: RefreshRiskLexiconUseCase - /** - * 백그라운드 초기화 작업의 실패가 앱을 죽이지 않게 한다. - */ private val applicationScope = CoroutineScope( SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, throwable -> @@ -31,7 +28,6 @@ class GamssApplication : Application() { override fun onCreate() { super.onCreate() - // 위험 표현 사전은 화면 진입 전에 최신화해 둔다. 실패해도 내장 사전으로 동작한다. applicationScope.launch { refreshRiskLexicon() } } } diff --git a/core/ui/src/main/kotlin/com/gamss/android/core/ui/ExternalLink.kt b/core/ui/src/main/kotlin/com/gamss/android/core/ui/ExternalLink.kt index 1542d5f..e10b7d6 100644 --- a/core/ui/src/main/kotlin/com/gamss/android/core/ui/ExternalLink.kt +++ b/core/ui/src/main/kotlin/com/gamss/android/core/ui/ExternalLink.kt @@ -6,12 +6,6 @@ import android.content.Intent import android.net.Uri import androidx.core.net.toUri -/** - * 다이얼러에 번호만 채우고 실제 발신은 사용자가 누른다. CALL_PHONE 권한을 요구하지 않기 위해서다. - * - * 다이얼러가 없는 기기에서도 크래시 없이 false 를 돌려준다. 위기 안내에서 버튼이 아무 반응 없이 - * 무시되면 안 되므로 호출부가 대체 안내를 띄울 수 있어야 한다. - */ @Suppress("SwallowedException") fun Context.dial(phoneNumber: String): Boolean = try { diff --git a/core/ui/src/main/kotlin/com/gamss/android/core/ui/GamssSupportAgencyDialog.kt b/core/ui/src/main/kotlin/com/gamss/android/core/ui/GamssSupportAgencyDialog.kt index c3a77f2..b92246b 100644 --- a/core/ui/src/main/kotlin/com/gamss/android/core/ui/GamssSupportAgencyDialog.kt +++ b/core/ui/src/main/kotlin/com/gamss/android/core/ui/GamssSupportAgencyDialog.kt @@ -19,10 +19,6 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.gamss.android.domain.safety.SupportAgency -/** - * 위험 신호가 감지됐을 때 상담 창구를 안내한다. - * isBlocking 이면 사용자의 글이 저장되지 않았다는 사실을 함께 알린다. - */ @Composable fun GamssSupportAgencyDialog( agencies: List, diff --git a/data/src/main/java/com/gamss/android/data/safety/BundledRiskLexiconDataSource.kt b/data/src/main/java/com/gamss/android/data/safety/BundledRiskLexiconDataSource.kt index 1d57e02..b318090 100644 --- a/data/src/main/java/com/gamss/android/data/safety/BundledRiskLexiconDataSource.kt +++ b/data/src/main/java/com/gamss/android/data/safety/BundledRiskLexiconDataSource.kt @@ -9,9 +9,6 @@ import kotlinx.serialization.json.Json import javax.inject.Inject import javax.inject.Singleton -/** - * 앱에 내장된 기본 사전. 네트워크와 캐시가 모두 없을 때의 최종 폴백이라 실패하면 그대로 던진다. - */ @Singleton internal class BundledRiskLexiconDataSource @Inject constructor( @param:ApplicationContext private val context: Context, diff --git a/data/src/main/java/com/gamss/android/data/safety/FirestoreRiskLexiconDataSource.kt b/data/src/main/java/com/gamss/android/data/safety/FirestoreRiskLexiconDataSource.kt index 17e8612..126299d 100644 --- a/data/src/main/java/com/gamss/android/data/safety/FirestoreRiskLexiconDataSource.kt +++ b/data/src/main/java/com/gamss/android/data/safety/FirestoreRiskLexiconDataSource.kt @@ -10,12 +10,6 @@ import javax.inject.Inject import javax.inject.Provider import javax.inject.Singleton -/** - * 사전 전체를 문서 하나에 담아 읽기 1회로 끝낸다. 쓰기는 콘솔에서만 하고 클라이언트는 읽기만 한다. - * - * Firestore 인스턴스는 Provider 로 받는다. 생성 시점에 gRPC 채널과 네트워크 콜백이 함께 올라오므로, - * 캐시가 신선해 조회를 건너뛰는 실행에서는 그 비용을 치르지 않게 한다. - */ @Singleton internal class FirestoreRiskLexiconDataSource @Inject constructor( private val firestoreProvider: Provider, diff --git a/data/src/main/java/com/gamss/android/data/safety/RiskLexiconLocalDataSource.kt b/data/src/main/java/com/gamss/android/data/safety/RiskLexiconLocalDataSource.kt index 43f67bd..38172a5 100644 --- a/data/src/main/java/com/gamss/android/data/safety/RiskLexiconLocalDataSource.kt +++ b/data/src/main/java/com/gamss/android/data/safety/RiskLexiconLocalDataSource.kt @@ -36,9 +36,6 @@ internal class RiskLexiconLocalDataSource @Inject constructor( } } - /** - * 원격 사전을 채택하지 않았더라도 조회에 성공했다면 시각만 갱신해 TTL 이 흐르게 한다. - */ suspend fun markFetched(fetchedAtMillis: Long): Unit = withContext(Dispatchers.IO) { dataStore.edit { preferences -> preferences[FETCHED_AT_KEY] = fetchedAtMillis diff --git a/data/src/main/java/com/gamss/android/data/safety/RiskLexiconRepositoryImpl.kt b/data/src/main/java/com/gamss/android/data/safety/RiskLexiconRepositoryImpl.kt index 1d59576..a7d5c7e 100644 --- a/data/src/main/java/com/gamss/android/data/safety/RiskLexiconRepositoryImpl.kt +++ b/data/src/main/java/com/gamss/android/data/safety/RiskLexiconRepositoryImpl.kt @@ -12,10 +12,6 @@ import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration.Companion.hours -/** - * 내장 사전과 원격 사전 중 version 이 큰 쪽을 쓴다. 원격 조회나 캐시 파싱이 실패해도 - * 내장 사전으로 계속 동작해야 하므로 이 클래스 밖으로 예외를 내보내지 않는다. - */ @Singleton internal class RiskLexiconRepositoryImpl @Inject constructor( private val bundled: BundledRiskLexiconDataSource, @@ -36,10 +32,8 @@ internal class RiskLexiconRepositoryImpl @Inject constructor( } private suspend fun adopt(fetched: RiskLexiconDto) { - // 채택 여부와 무관하게 조회 시각을 남긴다. 남기지 않으면 TTL 이 갱신되지 않아 앱 실행마다 다시 조회한다. runSafely { local.markFetched(System.currentTimeMillis()) } - // 필드명이 바뀌거나 파싱이 어긋나면 빈 사전이 온다. 그대로 채택하면 감지가 통째로 꺼진다. if (fetched.terms.isEmpty() || fetched.agencies.isEmpty()) return mutex.withLock { @@ -76,9 +70,6 @@ internal class RiskLexiconRepositoryImpl @Inject constructor( private suspend fun fetchRemoteOrNull(): RiskLexiconDto? = runSafely { remote.fetch() } - /** - * 사전 로딩 실패가 감지 실패로 이어지지 않도록 삼킨다. 취소만 그대로 전파한다. - */ @Suppress("TooGenericExceptionCaught", "SwallowedException") private suspend fun runSafely(block: suspend () -> T): T? = try { diff --git a/data/src/main/java/com/gamss/android/data/safety/model/RiskLexiconDto.kt b/data/src/main/java/com/gamss/android/data/safety/model/RiskLexiconDto.kt index ed1be6a..33e6a70 100644 --- a/data/src/main/java/com/gamss/android/data/safety/model/RiskLexiconDto.kt +++ b/data/src/main/java/com/gamss/android/data/safety/model/RiskLexiconDto.kt @@ -37,10 +37,6 @@ internal fun RiskLexiconDto.toDomain(): RiskLexicon = RiskLexicon( agencies = agencies.filter { it.name.isNotBlank() }.map { it.toDomain() }, ) -/** - * 알 수 없는 등급 문자열은 차단하지 않는 WARNING 으로 강등한다. - * 원격 사전의 오타가 곧바로 과차단이 되면 안 된다. - */ private fun RiskTermDto.toDomain(): RiskTerm = RiskTerm( term = term, level = when (level.uppercase()) { diff --git a/data/src/test/java/com/gamss/android/data/safety/BundledRiskLexiconContentTest.kt b/data/src/test/java/com/gamss/android/data/safety/BundledRiskLexiconContentTest.kt index 50e5a0f..85e3169 100644 --- a/data/src/test/java/com/gamss/android/data/safety/BundledRiskLexiconContentTest.kt +++ b/data/src/test/java/com/gamss/android/data/safety/BundledRiskLexiconContentTest.kt @@ -10,10 +10,6 @@ import org.junit.Assert.assertNotEquals import org.junit.Test import java.io.File -/** - * 실제로 앱에 실리는 사전을 검증한다. CRITICAL 은 사용자의 글을 차단하므로 오탐 하나의 비용이 크다. - * 사전을 고칠 때마다 이 문장 목록으로 회귀를 잡는다. - */ class BundledRiskLexiconContentTest { private val lexicon = JSON @@ -92,7 +88,6 @@ class BundledRiskLexiconContentTest { "그 회사에 목매고 있다", "손목 시계를 새로 샀다", "이 동네는 살기 좋다", - // 공백을 지우면 어절을 넘어 "자살"이 생기는 문장들 "편의점에서 과자 살까 말까 고민했다", "요즘은 혼자 살아보니 편하다", "감자 살 때 크기를 봤다", diff --git a/domain/src/main/kotlin/com/gamss/android/domain/safety/DetectRiskInTextUseCase.kt b/domain/src/main/kotlin/com/gamss/android/domain/safety/DetectRiskInTextUseCase.kt index c3823f0..4246a5d 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/safety/DetectRiskInTextUseCase.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/safety/DetectRiskInTextUseCase.kt @@ -3,11 +3,6 @@ package com.gamss.android.domain.safety import com.gamss.android.domain.usecase.UseCase import javax.inject.Inject -/** - * 원문 → 위험도와 안내할 기관 목록. - * - * 검사는 전부 기기 안에서 한다. 사전만 원격에서 받고 원문은 기기 밖으로 내보내지 않는다. - */ class DetectRiskInTextUseCase @Inject constructor( private val repository: RiskLexiconRepository, private val matcher: RiskTermMatcher, diff --git a/domain/src/main/kotlin/com/gamss/android/domain/safety/RefreshRiskLexiconUseCase.kt b/domain/src/main/kotlin/com/gamss/android/domain/safety/RefreshRiskLexiconUseCase.kt index 43d28da..16671cc 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/safety/RefreshRiskLexiconUseCase.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/safety/RefreshRiskLexiconUseCase.kt @@ -3,10 +3,6 @@ package com.gamss.android.domain.safety import com.gamss.android.domain.usecase.NoParamUseCase import javax.inject.Inject -/** - * 원격 사전 갱신은 앱 시작 시 한 번만 시도한다. 전송할 때마다 네트워크를 타면 - * 감지가 느려지고 읽기 비용이 사용량에 비례해 늘어난다. - */ class RefreshRiskLexiconUseCase @Inject constructor( private val repository: RiskLexiconRepository, ) : NoParamUseCase { diff --git a/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskDetection.kt b/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskDetection.kt index f124f68..4ee40c9 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskDetection.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskDetection.kt @@ -1,8 +1,5 @@ package com.gamss.android.domain.safety -/** - * matchedTerms 는 등급 판정과 테스트에만 쓴다. 원문과 매칭된 단어는 로그·분석 이벤트에 남기지 않는다. - */ data class RiskDetection( val level: RiskLevel, val matchedTerms: List, diff --git a/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskLevel.kt b/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskLevel.kt index af29127..cda7777 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskLevel.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskLevel.kt @@ -1,9 +1,5 @@ package com.gamss.android.domain.safety -/** - * CRITICAL 은 전송을 차단하고, WARNING 은 안내만 하고 전송을 진행한다. - * 등급은 원격 사전이 정하므로 앱 배포 없이 차단 범위를 조정할 수 있다. - */ enum class RiskLevel { NONE, WARNING, diff --git a/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskLexicon.kt b/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskLexicon.kt index 2394f8a..65d92f8 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskLexicon.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskLexicon.kt @@ -1,8 +1,5 @@ package com.gamss.android.domain.safety -/** - * version 은 앱 내장 사전과 원격 사전의 우열을 가리는 기준이다. 원격이 더 클 때만 갱신한다. - */ data class RiskLexicon( val version: Int, val terms: List, diff --git a/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskLexiconRepository.kt b/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskLexiconRepository.kt index 8797df5..2e794e4 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskLexiconRepository.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskLexiconRepository.kt @@ -2,14 +2,7 @@ package com.gamss.android.domain.safety interface RiskLexiconRepository { - /** - * 조회는 실패하지 않는다. 원격과 캐시가 모두 불가하면 앱 내장 사전을 돌려준다. - * 안전 기능이 네트워크 상태에 좌우되면 안 되기 때문이다. - */ suspend fun getLexicon(): RiskLexicon - /** - * 원격 사전을 확인해 캐시를 갱신한다. 실패해도 예외를 던지지 않는다. - */ suspend fun refresh() } diff --git a/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskTermMatcher.kt b/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskTermMatcher.kt index 991bf73..62486ef 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskTermMatcher.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskTermMatcher.kt @@ -4,11 +4,9 @@ import java.text.Normalizer import javax.inject.Inject /** - * 공백과 구두점을 지운 뒤 부분 문자열로 찾으므로 "죽고 싶다", "죽.고.싶.다" 를 한 표현으로 본다. - * - * 두 글자 이하 표현은 어절 경계에서 시작할 때만 인정한다. 공백을 지우면 "과자 살까" 안에 - * "자살" 이, "혼자 살아보니" 안에도 "자살" 이 생겨 평범한 일기가 차단되기 때문이다. - * safePhrases 구간은 먼저 마스킹해 "배고파 죽겠다" 류의 관용 표현을 걸러낸다. + * 공백과 구두점을 지운 뒤 부분 문자열로 찾되, 두 글자 이하 표현은 어절 경계에서 시작할 때만 + * 인정한다. 공백을 지우면 "과자 살까" 와 "혼자 살아보니" 안에 "자살" 이 생겨 평범한 일기가 + * 차단되기 때문이다. 세 글자 이상은 "죽고 싶다" 처럼 어절이 갈려도 잡아야 하므로 위치를 보지 않는다. */ class RiskTermMatcher @Inject constructor() { @@ -47,9 +45,6 @@ class RiskTermMatcher @Inject constructor() { } } - /** - * 알파벳과 숫자만 남긴 문자열, 그리고 각 어절의 첫 글자가 그 문자열에서 갖는 위치. - */ private class NormalizedText(val value: String, val wordStarts: Set) private fun normalize(text: String): NormalizedText { @@ -89,10 +84,7 @@ class RiskTermMatcher @Inject constructor() { } private companion object { - /** 어떤 위험 표현과도 겹치지 않는 대체 문자. 마스킹 후에도 위치가 밀리지 않게 길이를 보존한다. */ const val MASK = ' ' - - /** 이 길이 이하의 표현은 어절 경계에서 시작할 때만 인정한다. */ const val SHORT_TERM_LENGTH = 2 } } diff --git a/feature/emotion/src/main/java/com/gamss/android/feature/emotion/EmotionScreen.kt b/feature/emotion/src/main/java/com/gamss/android/feature/emotion/EmotionScreen.kt index 3f1a00b..b5ce47d 100644 --- a/feature/emotion/src/main/java/com/gamss/android/feature/emotion/EmotionScreen.kt +++ b/feature/emotion/src/main/java/com/gamss/android/feature/emotion/EmotionScreen.kt @@ -95,9 +95,6 @@ fun EmotionScreen( } } -/** - * 다이얼러를 열 수 없는 기기에서 버튼이 무반응으로 보이지 않도록 번호를 안내한다. - */ private fun Context.dialOrNotify(phoneNumber: String?) { if (phoneNumber == null) return if (!dial(phoneNumber)) { diff --git a/feature/emotion/src/main/java/com/gamss/android/feature/emotion/EmotionViewModel.kt b/feature/emotion/src/main/java/com/gamss/android/feature/emotion/EmotionViewModel.kt index 832e781..4316cb3 100644 --- a/feature/emotion/src/main/java/com/gamss/android/feature/emotion/EmotionViewModel.kt +++ b/feature/emotion/src/main/java/com/gamss/android/feature/emotion/EmotionViewModel.kt @@ -25,14 +25,12 @@ class EmotionViewModel @Inject constructor( fun onAnalyze() = intent { if (state.isRunning) return@intent - // 위험 감지가 사전 로딩으로 멈출 수 있으므로 그 앞에서 실행 중으로 바꿔 연타를 막는다. reduce { state.copy(isRunning = true, result = null, notRecognized = false, error = null) } val detection = detectRiskInText(state.input) if (detection.level != RiskLevel.NONE) { reduce { state.copy(riskDetection = detection) } if (detection.shouldBlock) { - // 차단해도 입력은 그대로 둔다. 안내를 닫았을 때 사용자가 쓴 글이 남아 있어야 한다. reduce { state.copy(isRunning = false) } return@intent } diff --git a/firestore.rules b/firestore.rules index 4d368ca..9e40234 100644 --- a/firestore.rules +++ b/firestore.rules @@ -3,13 +3,11 @@ rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { - // 위험 표현 사전. 클라이언트는 읽기만 한다. 사전 수정은 콘솔이나 서버에서만 이뤄져야 한다. match /app_config/{document} { allow read: if request.auth != null; allow write: if false; } - // 명시적으로 허용한 경로 외에는 모두 차단한다. match /{document=**} { allow read, write: if false; } From e908c6bbc25aa2ba71d943a8af418113d4dcab70 Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:54:35 +0900 Subject: [PATCH 07/11] =?UTF-8?q?feat:=20=EC=9C=84=ED=97=98=20=ED=91=9C?= =?UTF-8?q?=ED=98=84=20=EC=82=AC=EC=A0=84=EC=97=90=20=EC=A3=BD=EC=9D=84?= =?UTF-8?q?=EA=B1=B0=EC=95=BC/=EC=A3=BD=EC=9D=84=EB=9E=98=20=EA=B3=84?= =?UTF-8?q?=EC=97=B4=20=EC=B6=94=EA=B0=80=20-=20=EC=A3=BD=EC=9D=84?= =?UTF-8?q?=EA=B1=B0=EC=95=BC,=20=EC=A3=BD=EC=9D=84=EA=B2=8C,=20=EC=A3=BD?= =?UTF-8?q?=EC=9D=84=EB=9E=98,=20=EC=A3=BD=EC=96=B4=EB=B2=84=EB=A6=B4,=20?= =?UTF-8?q?=EC=A3=BD=EC=96=B4=EC=95=BC=EC=A7=80,=20=EC=A3=BD=EA=B3=A0?= =?UTF-8?q?=EB=A7=90,=20=EC=A3=BD=EB=8A=94=EA=B2=8C=EB=82=AB,=20=EC=A3=BD?= =?UTF-8?q?=EC=9C=BC=EB=A9=B4=EC=A2=8B=EA=B2=A0,=20=EC=A3=BD=EB=8A=94?= =?UTF-8?q?=EC=88=98=EB=B0=96=EC=97=90=209=EA=B0=9C=EB=A5=BC=20CRITICAL=20?= =?UTF-8?q?=EB=A1=9C=20=EC=B6=94=EA=B0=80.=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=EC=A3=BD=EC=96=B4=EB=B2=84=EB=A6=AC=20=EB=8A=94=20=EC=A3=BD?= =?UTF-8?q?=EC=96=B4=EB=B2=84=EB=A6=B4=EA=B1=B0=EC=95=BC=20=EB=A5=BC=20?= =?UTF-8?q?=EC=9E=A1=EC=A7=80=20=EB=AA=BB=ED=95=B4=20=EC=A3=BD=EC=96=B4?= =?UTF-8?q?=EB=B2=84=EB=A6=B4=20=EC=9D=84=20=EB=94=B0=EB=A1=9C=20=EB=84=A3?= =?UTF-8?q?=EC=97=88=EB=8B=A4=20-=20=EC=9D=B4=20=EA=B3=84=EC=97=B4?= =?UTF-8?q?=EC=9D=80=20=EA=B3=BC=EC=9E=A5=20=ED=91=9C=ED=98=84=EA=B3=BC=20?= =?UTF-8?q?=EA=B2=B9=EC=B9=98=EB=8A=94=20=EB=B2=94=EC=9C=84=EA=B0=80=20?= =?UTF-8?q?=EB=84=93=EC=96=B4=20safePhrase=209=EA=B0=9C=EB=A5=BC=20?= =?UTF-8?q?=ED=95=A8=EA=BB=98=20=EC=B6=94=EA=B0=80=ED=96=88=EB=8B=A4.=20?= =?UTF-8?q?=EC=A3=BD=EC=9D=84=EA=B2=83=EA=B0=99,=20=EC=A3=BD=EC=9D=84?= =?UTF-8?q?=EA=B1=B0=EA=B0=99,=20=EC=A3=BD=EC=9D=84=EB=BB=94,=20=EC=A3=BD?= =?UTF-8?q?=EC=9D=84=EA=B0=81=EC=98=A4,=20=EC=A3=BD=EC=9D=84=ED=9E=98,=20?= =?UTF-8?q?=EC=A3=BD=EC=9D=84=EC=A3=84,=20=EB=84=88=EC=A3=BD=EC=9D=84?= =?UTF-8?q?=EB=9E=98,=20=EC=A3=BD=EC=9D=84=EB=95=8C=EA=B9=8C=EC=A7=80,=20?= =?UTF-8?q?=EC=A3=BD=EC=9D=84=EA=B3=A0=EC=83=9D=20-=20=ED=9A=8C=EA=B7=80?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=97=90=20=EC=B0=A8=EB=8B=A8=20?= =?UTF-8?q?=EB=8C=80=EC=83=81=208=EA=B0=9C,=20=ED=86=B5=EA=B3=BC=20?= =?UTF-8?q?=EB=8C=80=EC=83=81=209=EA=B0=9C=20=EB=AC=B8=EC=9E=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/src/main/assets/risk_lexicon.json | 20 ++++++++++++++++++- .../safety/BundledRiskLexiconContentTest.kt | 17 ++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/data/src/main/assets/risk_lexicon.json b/data/src/main/assets/risk_lexicon.json index 211f8d3..03cda9f 100644 --- a/data/src/main/assets/risk_lexicon.json +++ b/data/src/main/assets/risk_lexicon.json @@ -1,6 +1,15 @@ { - "version": 1, + "version": 2, "terms": [ + { "term": "죽을거야", "level": "CRITICAL" }, + { "term": "죽을게", "level": "CRITICAL" }, + { "term": "죽을래", "level": "CRITICAL" }, + { "term": "죽어버릴", "level": "CRITICAL" }, + { "term": "죽어야지", "level": "CRITICAL" }, + { "term": "죽고말", "level": "CRITICAL" }, + { "term": "죽는게낫", "level": "CRITICAL" }, + { "term": "죽으면좋겠", "level": "CRITICAL" }, + { "term": "죽는수밖에", "level": "CRITICAL" }, { "term": "자살", "level": "CRITICAL" }, { "term": "자살방법", "level": "CRITICAL" }, { "term": "죽는방법", "level": "CRITICAL" }, @@ -26,6 +35,15 @@ { "term": "혼자남겨진것같", "level": "WARNING" } ], "safePhrases": [ + "죽을것같", + "죽을거같", + "죽을뻔", + "죽을각오", + "죽을힘", + "죽을죄", + "너죽을래", + "죽을때까지", + "죽을고생", "죽고싶지않", "자살예방", "자살률", diff --git a/data/src/test/java/com/gamss/android/data/safety/BundledRiskLexiconContentTest.kt b/data/src/test/java/com/gamss/android/data/safety/BundledRiskLexiconContentTest.kt index 85e3169..39f3c8b 100644 --- a/data/src/test/java/com/gamss/android/data/safety/BundledRiskLexiconContentTest.kt +++ b/data/src/test/java/com/gamss/android/data/safety/BundledRiskLexiconContentTest.kt @@ -61,6 +61,14 @@ class BundledRiskLexiconContentTest { "이제 정말 살고 싶지 않아", "유서를 미리 써두었다", "목을 매는 상상을 한다", + "나 진짜 죽을거야", + "나 죽을래", + "이제 죽어버릴거야", + "이제 그냥 죽어야지", + "죽고 말거야", + "죽는 게 낫겠다", + "죽으면 좋겠다", + "죽는 수밖에 없어", ) val WARNING_SENTENCES = listOf( @@ -91,6 +99,15 @@ class BundledRiskLexiconContentTest { "편의점에서 과자 살까 말까 고민했다", "요즘은 혼자 살아보니 편하다", "감자 살 때 크기를 봤다", + "힘들어 죽을 것 같아", + "배고파 죽을 거 같아", + "놀라서 죽을 뻔했어", + "죽을 각오로 공부했다", + "죽을 힘을 다해 뛰었다", + "너 죽을래? 하고 장난쳤다", + "죽을 죄를 지었네", + "죽을 때까지 함께하자고 했다", + "죽을 고생을 했다", ) } } From 2fc993e646a24ab2b676e06128e501c62841afb8 Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:28:59 +0900 Subject: [PATCH 08/11] =?UTF-8?q?feat:=20=EA=B3=B5=EC=8B=9D=20=EC=9E=90?= =?UTF-8?q?=EC=82=B4=20=EA=B2=BD=EA=B3=A0=EC=8B=A0=ED=98=B8=20=ED=91=9C?= =?UTF-8?q?=ED=98=84=EC=9D=84=20=EC=9C=84=ED=97=98=20=ED=91=9C=ED=98=84=20?= =?UTF-8?q?=EC=82=AC=EC=A0=84=EC=97=90=20=EB=B0=98=EC=98=81=20-=20?= =?UTF-8?q?=EB=B3=B4=EA=B1=B4=EB=B3=B5=EC=A7=80=EB=B6=80=C2=B7=EC=9E=90?= =?UTF-8?q?=EC=82=B4=EC=98=88=EB=B0=A9=EC=84=BC=ED=84=B0=EA=B0=80=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=ED=95=9C=20=EC=96=B8=EC=96=B4=EC=A0=81=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=A0=EC=8B=A0=ED=98=B8=EC=97=90=EC=84=9C=209?= =?UTF-8?q?=EA=B0=9C=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=96=88=EB=8B=A4.=20CR?= =?UTF-8?q?ITICAL=204=EA=B0=9C(=EC=B0=A8=EB=9D=BC=EB=A6=AC=EC=A3=BD,=20?= =?UTF-8?q?=EC=A3=BD=EC=96=B4=EC=95=BC=ED=8E=B8,=20=EC=A3=BD=EB=8A=94?= =?UTF-8?q?=EA=B2=8C=EB=8B=B5,=20=EB=8B=A4=EB=81=9D=EB=82=B4=EB=B2=84?= =?UTF-8?q?=EB=A6=AC=EA=B3=A0=EC=8B=B6)=EC=99=80=20WARNING=205=EA=B0=9C(?= =?UTF-8?q?=EC=A3=BD=EC=97=88=EC=9C=BC=EB=A9=B4=EC=A2=8B=EA=B2=A0,=20?= =?UTF-8?q?=EB=82=98=EB=A7=8C=EC=97=86=EC=9C=BC=EB=A9=B4,=20=ED=83=9C?= =?UTF-8?q?=EC=96=B4=EB=82=98=EC=A7=80=EB=A7=90=EC=95=98,=20=EC=82=B4?= =?UTF-8?q?=EC=95=84=EC=9E=88=EC=9D=84=EC=9D=B4=EC=9C=A0,=20=EC=97=86?= =?UTF-8?q?=EC=96=B4=EC=A7=80=EB=8A=94=EA=B2=8C=EB=82=AB)=20-=20=EC=A3=BD?= =?UTF-8?q?=EC=97=88=EC=9C=BC=EB=A9=B4=EC=A2=8B=EA=B2=A0=20=EB=8A=94=20?= =?UTF-8?q?=ED=83=80=EC=9D=B8=EC=9D=84=20=ED=96=A5=ED=95=9C=20=EB=B0=9C?= =?UTF-8?q?=ED=99=94=EC=9D=BC=20=EC=88=98=20=EC=9E=88=EC=96=B4=20=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8=ED=95=98=EC=A7=80=20=EC=95=8A=EA=B3=A0=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=EB=A7=8C=20=ED=95=98=EB=8A=94=20WARNING=20=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=91=90=EC=97=88=EB=8B=A4=20-=20=ED=9D=AC?= =?UTF-8?q?=EB=A7=9D=EC=9D=B4=EC=97=86=20=EA=B3=BC=20=EC=A7=90=EB=A7=8C?= =?UTF-8?q?=EB=90=98=EB=8A=94=20=EC=9D=80=20=EA=B2=80=EC=A6=9D=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=9D=BC=EC=83=81=20=EB=AC=B8=EC=9E=A5=EC=97=90=20?= =?UTF-8?q?=EA=B1=B8=EB=A0=A4=20=EC=A0=9C=EC=99=B8=ED=96=88=EB=8B=A4.=20?= =?UTF-8?q?=ED=9A=8C=EA=B7=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=97=90=20?= =?UTF-8?q?=ED=95=B4=EB=8B=B9=20=EB=AC=B8=EC=9E=A5=EC=9D=84=20=ED=86=B5?= =?UTF-8?q?=EA=B3=BC=20=EB=8C=80=EC=83=81=EC=9C=BC=EB=A1=9C=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=ED=96=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/src/main/assets/risk_lexicon.json | 11 ++++++++++- .../data/safety/BundledRiskLexiconContentTest.kt | 13 +++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/data/src/main/assets/risk_lexicon.json b/data/src/main/assets/risk_lexicon.json index 03cda9f..35dd47e 100644 --- a/data/src/main/assets/risk_lexicon.json +++ b/data/src/main/assets/risk_lexicon.json @@ -1,6 +1,15 @@ { - "version": 2, + "version": 3, "terms": [ + { "term": "차라리죽", "level": "CRITICAL" }, + { "term": "죽어야편", "level": "CRITICAL" }, + { "term": "죽는게답", "level": "CRITICAL" }, + { "term": "다끝내버리고싶", "level": "CRITICAL" }, + { "term": "죽었으면좋겠", "level": "WARNING" }, + { "term": "나만없으면", "level": "WARNING" }, + { "term": "태어나지말았", "level": "WARNING" }, + { "term": "살아있을이유", "level": "WARNING" }, + { "term": "없어지는게낫", "level": "WARNING" }, { "term": "죽을거야", "level": "CRITICAL" }, { "term": "죽을게", "level": "CRITICAL" }, { "term": "죽을래", "level": "CRITICAL" }, diff --git a/data/src/test/java/com/gamss/android/data/safety/BundledRiskLexiconContentTest.kt b/data/src/test/java/com/gamss/android/data/safety/BundledRiskLexiconContentTest.kt index 39f3c8b..38764fd 100644 --- a/data/src/test/java/com/gamss/android/data/safety/BundledRiskLexiconContentTest.kt +++ b/data/src/test/java/com/gamss/android/data/safety/BundledRiskLexiconContentTest.kt @@ -69,12 +69,21 @@ class BundledRiskLexiconContentTest { "죽는 게 낫겠다", "죽으면 좋겠다", "죽는 수밖에 없어", + "차라리 죽는 게 낫겠어", + "죽어야 편해질 것 같아", + "죽는 게 답인 것 같아", + "다 끝내버리고 싶다", ) val WARNING_SENTENCES = listOf( "그냥 조용히 사라지고 싶어", "요즘은 그냥 살기 싫다", "다 포기하고 싶은 하루였다", + "차라리 죽었으면 좋겠어", + "나만 없으면 다 잘될 텐데", + "태어나지 말았어야 했어", + "살아있을 이유가 없다", + "내가 없어지는 게 낫다", ) val BENIGN_SENTENCES = listOf( @@ -108,6 +117,10 @@ class BundledRiskLexiconContentTest { "죽을 죄를 지었네", "죽을 때까지 함께하자고 했다", "죽을 고생을 했다", + "이 프로젝트는 희망이 없다", + "걔 진짜 짐만 되는 스타일이야", + "숙제 다 끝내고 싶다", + "일 끝내고 쉬고 싶다", ) } } From 34c420894039d7ecc1d2de2b11604fd42887f08b Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:39:21 +0900 Subject: [PATCH 09/11] =?UTF-8?q?refactor:=20=EC=9C=84=ED=97=98=20?= =?UTF-8?q?=EA=B0=90=EC=A7=80=20=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20designsystem=20=ED=86=A0=ED=81=B0=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20-=20RiskDetection.matchedTerms=20=EC=99=80=20Suppor?= =?UTF-8?q?tAgency.url=20=EC=A0=9C=EA=B1=B0.=20=EC=86=8C=EB=B9=84=EC=B2=98?= =?UTF-8?q?=EA=B0=80=20=EC=97=86=EB=8A=94=EB=8D=B0=20matchedTerms=20?= =?UTF-8?q?=EB=8A=94=20UI=20state=20=EA=B9=8C=EC=A7=80=20=EC=98=AC?= =?UTF-8?q?=EB=9D=BC=EA=B0=80=20=EC=82=AC=EC=9A=A9=EC=9E=90=EA=B0=80=20?= =?UTF-8?q?=EC=93=B4=20=ED=91=9C=ED=98=84=EC=9D=B4=20=EC=83=81=EC=A3=BC?= =?UTF-8?q?=ED=96=88=EB=8B=A4=20-=20=EC=83=81=EB=8B=B4=20=EA=B8=B0?= =?UTF-8?q?=EA=B4=80=20=EC=95=88=EB=82=B4=20=EB=8B=A4=EC=9D=B4=EC=96=BC?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=99=80=20=EC=A0=84=ED=99=94=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=EC=9D=84=20core:ui=20=EC=97=90=EC=84=9C=20feature:emo?= =?UTF-8?q?tion=20=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EB=8F=99.=20core:ui=20?= =?UTF-8?q?=EB=8A=94=20domain=20=EC=9D=84=20=EB=AA=A8=EB=A5=B4=EB=8A=94=20?= =?UTF-8?q?=EC=88=9C=EC=88=98=20=EC=BB=B4=ED=8F=AC=EC=A0=80=EB=B8=94=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=EB=A1=9C=20=EB=90=98=EB=8F=8C=EB=A0=B8?= =?UTF-8?q?=EB=8B=A4=20-=20MaterialTheme=20=EB=8C=80=EC=8B=A0=20GamssTheme?= =?UTF-8?q?=20=ED=86=A0=ED=81=B0=20=EC=82=AC=EC=9A=A9(spacing200,=20body4R?= =?UTF-8?q?egular,=20body5Medium,=20subtitle4)=20-=20=EB=8B=A4=EC=9D=B4?= =?UTF-8?q?=EC=96=BC=EB=A1=9C=EA=B7=B8=EC=9D=98=20isBlocking=20Boolean=20?= =?UTF-8?q?=ED=94=8C=EB=9E=98=EA=B7=B8=EB=A5=BC=20RiskLevel=20=EB=A1=9C=20?= =?UTF-8?q?=EA=B5=90=EC=B2=B4.=20=EB=93=B1=EA=B8=89=EC=9D=B4=20=EB=8A=98?= =?UTF-8?q?=EB=A9=B4=20when=20=EC=9D=B4=20=EC=BB=B4=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=8B=9C=EC=A0=90=EC=97=90=20=EB=88=84=EB=9D=BD=EC=9D=84=20?= =?UTF-8?q?=EC=9E=A1=EB=8A=94=EB=8B=A4=20-=20=EA=B8=B0=EA=B4=80=20priority?= =?UTF-8?q?=20=EC=A0=95=EB=A0=AC=EC=9D=84=20matcher=20=EC=97=90=EC=84=9C?= =?UTF-8?q?=20DTO=20=EB=A7=A4=ED=95=91=EC=9C=BC=EB=A1=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=ED=95=B4=20RiskLexicon.agencies=20=EA=B0=80=20?= =?UTF-8?q?=ED=95=AD=EC=83=81=20=EC=A0=95=EB=A0=AC=EB=8F=BC=20=EC=9E=88?= =?UTF-8?q?=EB=8B=A4=EB=8A=94=20=EB=B6=88=EB=B3=80=EC=8B=9D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=A7=8C=EB=93=A4=EC=97=88=EB=8B=A4=20-=20RiskLexi?= =?UTF-8?q?conRepository=20=EC=97=90=20=EB=AC=B4=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=EC=9D=84=20KDoc=20=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=AA=85=EC=8B=9C.=20=EB=B3=B4=EC=9E=A5=EC=9D=B4=20runSafely?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20=EB=94=94=ED=85=8C=EC=9D=BC=EC=97=90?= =?UTF-8?q?=EB=A7=8C=20=EC=9E=88=EC=97=88=EB=8B=A4=20-=20MASK=20=EA=B8=B8?= =?UTF-8?q?=EC=9D=B4=20=EB=B3=B4=EC=A1=B4=20=EC=A0=9C=EC=95=BD=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=B6=94=EA=B0=80.=20=EA=B8=B8=EC=9D=B4=EA=B0=80?= =?UTF-8?q?=20=EB=B0=94=EB=80=8C=EB=A9=B4=20wordStarts=20=EC=9D=B8?= =?UTF-8?q?=EB=8D=B1=EC=8A=A4=EA=B0=80=20=EB=B0=80=EB=A0=A4=20=EC=A7=A7?= =?UTF-8?q?=EC=9D=80=20=ED=91=9C=ED=98=84=20=EB=A7=A4=EC=B9=AD=EC=9D=B4=20?= =?UTF-8?q?=EC=A1=B0=EC=9A=A9=ED=9E=88=20=EA=B9=A8=EC=A7=84=EB=8B=A4=20-?= =?UTF-8?q?=20=EC=82=AC=EC=A0=84=20=ED=9A=8C=EA=B7=80=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EA=B0=80=20=EC=B2=AB=20=EC=8B=A4=ED=8C=A8=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=A9=88=EC=B6=94=EC=A7=80=20=EC=95=8A=EA=B3=A0=20?= =?UTF-8?q?=EC=96=B4=EA=B8=8B=EB=82=9C=20=EB=AC=B8=EC=9E=A5=EC=9D=84=20?= =?UTF-8?q?=EB=AA=A8=EC=95=84=20=EB=B3=B4=EA=B3=A0=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/ui/build.gradle.kts | 2 - .../data/safety/model/RiskLexiconDto.kt | 4 +- .../safety/BundledRiskLexiconContentTest.kt | 30 ++++++++------- .../android/domain/safety/RiskDetection.kt | 7 +--- .../domain/safety/RiskLexiconRepository.kt | 5 +++ .../android/domain/safety/RiskTermMatcher.kt | 14 +++---- .../android/domain/safety/SupportAgency.kt | 1 - .../safety/DetectRiskInTextUseCaseTest.kt | 6 +-- .../domain/safety/RiskTermMatcherTest.kt | 6 +-- feature/emotion/build.gradle.kts | 1 - .../android/feature/emotion/EmotionScreen.kt | 10 ++--- .../android/feature/emotion}/ExternalLink.kt | 2 +- .../emotion/component/SupportAgencyDialog.kt | 37 ++++++++++--------- .../emotion}/src/main/res/values/strings.xml | 0 14 files changed, 60 insertions(+), 65 deletions(-) rename {core/ui/src/main/kotlin/com/gamss/android/core/ui => feature/emotion/src/main/java/com/gamss/android/feature/emotion}/ExternalLink.kt (91%) rename core/ui/src/main/kotlin/com/gamss/android/core/ui/GamssSupportAgencyDialog.kt => feature/emotion/src/main/java/com/gamss/android/feature/emotion/component/SupportAgencyDialog.kt (71%) rename {core/ui => feature/emotion}/src/main/res/values/strings.xml (100%) diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 1929e5c..4448d9f 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -8,8 +8,6 @@ android { } dependencies { - implementation(projects.domain) - implementation(libs.androidx.core.ktx) implementation(libs.compose.material.icons.core) diff --git a/data/src/main/java/com/gamss/android/data/safety/model/RiskLexiconDto.kt b/data/src/main/java/com/gamss/android/data/safety/model/RiskLexiconDto.kt index 33e6a70..77d0121 100644 --- a/data/src/main/java/com/gamss/android/data/safety/model/RiskLexiconDto.kt +++ b/data/src/main/java/com/gamss/android/data/safety/model/RiskLexiconDto.kt @@ -30,11 +30,12 @@ internal data class SupportAgencyDto( val priority: Int = DEFAULT_PRIORITY, ) +/** agencies 는 여기서 한 번 정렬해 [RiskLexicon.agencies] 가 항상 priority 순이라는 불변식을 만든다. */ internal fun RiskLexiconDto.toDomain(): RiskLexicon = RiskLexicon( version = version, terms = terms.filter { it.term.isNotBlank() }.map { it.toDomain() }, safePhrases = safePhrases.filter { it.isNotBlank() }, - agencies = agencies.filter { it.name.isNotBlank() }.map { it.toDomain() }, + agencies = agencies.filter { it.name.isNotBlank() }.map { it.toDomain() }.sortedBy { it.priority }, ) private fun RiskTermDto.toDomain(): RiskTerm = RiskTerm( @@ -51,7 +52,6 @@ private fun SupportAgencyDto.toDomain(): SupportAgency = SupportAgency( name = name, description = description, phoneNumber = phoneNumber?.takeIf { it.isNotBlank() }, - url = url?.takeIf { it.isNotBlank() }, priority = priority, ) diff --git a/data/src/test/java/com/gamss/android/data/safety/BundledRiskLexiconContentTest.kt b/data/src/test/java/com/gamss/android/data/safety/BundledRiskLexiconContentTest.kt index 38764fd..e893a23 100644 --- a/data/src/test/java/com/gamss/android/data/safety/BundledRiskLexiconContentTest.kt +++ b/data/src/test/java/com/gamss/android/data/safety/BundledRiskLexiconContentTest.kt @@ -6,7 +6,6 @@ import com.gamss.android.domain.safety.RiskLevel import com.gamss.android.domain.safety.RiskTermMatcher import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotEquals import org.junit.Test import java.io.File @@ -20,31 +19,34 @@ class BundledRiskLexiconContentTest { @Test fun `차단해야 하는 문장은 CRITICAL로 판정한다`() { - CRITICAL_SENTENCES.forEach { sentence -> - assertEquals(sentence, RiskLevel.CRITICAL, matcher.match(sentence, lexicon).level) - } + assertNoMismatch(CRITICAL_SENTENCES) { it == RiskLevel.CRITICAL } } @Test fun `안내가 필요한 문장은 감지한다`() { - WARNING_SENTENCES.forEach { sentence -> - assertNotEquals(sentence, RiskLevel.NONE, matcher.match(sentence, lexicon).level) - } + assertNoMismatch(WARNING_SENTENCES) { it != RiskLevel.NONE } } @Test fun `일상적인 문장은 감지하지 않는다`() { - BENIGN_SENTENCES.forEach { sentence -> - assertEquals(sentence, RiskLevel.NONE, matcher.match(sentence, lexicon).level) - } + assertNoMismatch(BENIGN_SENTENCES) { it == RiskLevel.NONE } } @Test - fun `감지되면 상담 기관을 priority 순으로 안내한다`() { - val detection = matcher.match(CRITICAL_SENTENCES.first(), lexicon) + fun `사전의 최우선 상담 기관은 109다`() { + assertEquals("109", lexicon.agencies.first().phoneNumber) + } + + /** + * 사전을 넓힐 때 어긋난 문장을 한 번에 다 보려면 첫 실패에서 멈추지 않아야 한다. + */ + private fun assertNoMismatch(sentences: List, expected: (RiskLevel) -> Boolean) { + val mismatched = sentences + .map { it to matcher.match(it, lexicon).level } + .filterNot { (_, level) -> expected(level) } + .map { (sentence, level) -> "$sentence -> $level" } - assertEquals("109", detection.agencies.first().phoneNumber) - assertEquals(detection.agencies.map { it.priority }.sorted(), detection.agencies.map { it.priority }) + assertEquals(emptyList(), mismatched) } private companion object { diff --git a/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskDetection.kt b/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskDetection.kt index 4ee40c9..bdf735c 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskDetection.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskDetection.kt @@ -2,16 +2,11 @@ package com.gamss.android.domain.safety data class RiskDetection( val level: RiskLevel, - val matchedTerms: List, val agencies: List, ) { val shouldBlock: Boolean get() = level == RiskLevel.CRITICAL companion object { - val None = RiskDetection( - level = RiskLevel.NONE, - matchedTerms = emptyList(), - agencies = emptyList(), - ) + val None = RiskDetection(level = RiskLevel.NONE, agencies = emptyList()) } } diff --git a/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskLexiconRepository.kt b/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskLexiconRepository.kt index 2e794e4..23d4c02 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskLexiconRepository.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskLexiconRepository.kt @@ -2,7 +2,12 @@ package com.gamss.android.domain.safety interface RiskLexiconRepository { + /** + * 예외를 던지지 않는다. 원격과 캐시가 모두 불가하면 앱 내장 사전을, 그마저 실패하면 빈 사전을 돌려준다. + * 감지가 네트워크 상태에 좌우되면 안 되므로 구현체는 이 계약을 반드시 지켜야 한다. + */ suspend fun getLexicon(): RiskLexicon + /** 원격 사전을 확인해 캐시를 갱신한다. 실패해도 예외를 던지지 않는다. */ suspend fun refresh() } diff --git a/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskTermMatcher.kt b/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskTermMatcher.kt index 62486ef..0eb995c 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskTermMatcher.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskTermMatcher.kt @@ -15,18 +15,13 @@ class RiskTermMatcher @Inject constructor() { return if (matched.isEmpty()) { RiskDetection.None } else { - RiskDetection( - level = if (matched.any { it.level == RiskLevel.CRITICAL }) { - RiskLevel.CRITICAL - } else { - RiskLevel.WARNING - }, - matchedTerms = matched.map { it.term }, - agencies = lexicon.agencies.sortedBy { it.priority }, - ) + RiskDetection(level = levelOf(matched), agencies = lexicon.agencies) } } + private fun levelOf(matched: List): RiskLevel = + if (matched.any { it.level == RiskLevel.CRITICAL }) RiskLevel.CRITICAL else RiskLevel.WARNING + private fun findMatchedTerms(text: String, lexicon: RiskLexicon): List { val normalized = normalize(text) if (normalized.value.isEmpty()) return emptyList() @@ -84,6 +79,7 @@ class RiskTermMatcher @Inject constructor() { } private companion object { + /** 치환은 길이를 보존해야 한다. 길이가 바뀌면 wordStarts 인덱스가 밀려 짧은 표현 매칭이 깨진다. */ const val MASK = ' ' const val SHORT_TERM_LENGTH = 2 } diff --git a/domain/src/main/kotlin/com/gamss/android/domain/safety/SupportAgency.kt b/domain/src/main/kotlin/com/gamss/android/domain/safety/SupportAgency.kt index cb8ec2c..5ee19ba 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/safety/SupportAgency.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/safety/SupportAgency.kt @@ -5,6 +5,5 @@ data class SupportAgency( val name: String, val description: String, val phoneNumber: String?, - val url: String?, val priority: Int, ) diff --git a/domain/src/test/kotlin/com/gamss/android/domain/safety/DetectRiskInTextUseCaseTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/safety/DetectRiskInTextUseCaseTest.kt index 044f49c..606047e 100644 --- a/domain/src/test/kotlin/com/gamss/android/domain/safety/DetectRiskInTextUseCaseTest.kt +++ b/domain/src/test/kotlin/com/gamss/android/domain/safety/DetectRiskInTextUseCaseTest.kt @@ -17,8 +17,8 @@ class DetectRiskInTextUseCaseTest { terms = listOf(RiskTerm("죽고싶", RiskLevel.CRITICAL)), safePhrases = emptyList(), agencies = listOf( - SupportAgency("b", "청소년 상담전화", "청소년 전문 상담", "1388", null, 3), - SupportAgency("a", "자살예방 상담전화", "24시간 무료 전문 상담", "109", null, 1), + SupportAgency("a", "자살예방 상담전화", "24시간 무료 전문 상담", "109", 1), + SupportAgency("b", "청소년 상담전화", "청소년 전문 상담", "1388", 3), ), ) @@ -28,7 +28,7 @@ class DetectRiskInTextUseCaseTest { ) @Test - fun 사전에_있는_위험_표현을_감지하고_기관을_priority_순으로_안내한다() = runBlocking { + fun 사전에_있는_위험_표현을_감지하고_기관을_안내한다() = runBlocking { val detection = useCase("요즘 정말 죽고싶다는 생각뿐이다") assertEquals(RiskLevel.CRITICAL, detection.level) diff --git a/domain/src/test/kotlin/com/gamss/android/domain/safety/RiskTermMatcherTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/safety/RiskTermMatcherTest.kt index 52614e6..ca6f73c 100644 --- a/domain/src/test/kotlin/com/gamss/android/domain/safety/RiskTermMatcherTest.kt +++ b/domain/src/test/kotlin/com/gamss/android/domain/safety/RiskTermMatcherTest.kt @@ -19,8 +19,8 @@ class RiskTermMatcherTest { ), safePhrases = listOf("배고파죽", "죽고싶지않", "자살예방"), agencies = listOf( - SupportAgency("b", "청소년 상담전화", "청소년 전문 상담", "1388", null, 3), - SupportAgency("a", "자살예방 상담전화", "24시간 무료 전문 상담", "109", null, 1), + SupportAgency("a", "자살예방 상담전화", "24시간 무료 전문 상담", "109", 1), + SupportAgency("b", "청소년 상담전화", "청소년 전문 상담", "1388", 3), ), ) @@ -88,7 +88,7 @@ class RiskTermMatcherTest { } @Test - fun 감지되면_기관을_priority_순으로_반환한다() { + fun 감지되면_사전의_기관_목록을_그대로_반환한다() { val detection = matcher.match("자살", lexicon) assertEquals(listOf("자살예방 상담전화", "청소년 상담전화"), detection.agencies.map { it.name }) diff --git a/feature/emotion/build.gradle.kts b/feature/emotion/build.gradle.kts index 082b158..1a92c59 100644 --- a/feature/emotion/build.gradle.kts +++ b/feature/emotion/build.gradle.kts @@ -8,5 +8,4 @@ android { dependencies { implementation(projects.domain) - implementation(projects.core.ui) } diff --git a/feature/emotion/src/main/java/com/gamss/android/feature/emotion/EmotionScreen.kt b/feature/emotion/src/main/java/com/gamss/android/feature/emotion/EmotionScreen.kt index b5ce47d..f05be1f 100644 --- a/feature/emotion/src/main/java/com/gamss/android/feature/emotion/EmotionScreen.kt +++ b/feature/emotion/src/main/java/com/gamss/android/feature/emotion/EmotionScreen.kt @@ -25,11 +25,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import com.gamss.android.core.ui.GamssSupportAgencyDialog -import com.gamss.android.core.ui.dial import com.gamss.android.domain.emotion.EmotionResult +import com.gamss.android.feature.emotion.component.SupportAgencyDialog import org.orbitmvi.orbit.compose.collectAsState -import com.gamss.android.core.ui.R as CoreUiR private const val MAX_DIARY_LEN = 140 private const val PERCENT = 100 @@ -43,9 +41,9 @@ fun EmotionScreen( val context = LocalContext.current state.riskDetection?.let { detection -> - GamssSupportAgencyDialog( + SupportAgencyDialog( agencies = detection.agencies, - isBlocking = detection.shouldBlock, + riskLevel = detection.level, onCallClick = { agency -> context.dialOrNotify(agency.phoneNumber) }, onDismiss = viewModel::onRiskGuidanceDismiss, ) @@ -100,7 +98,7 @@ private fun Context.dialOrNotify(phoneNumber: String?) { if (!dial(phoneNumber)) { Toast.makeText( this, - getString(CoreUiR.string.safety_call_unavailable, phoneNumber), + getString(R.string.safety_call_unavailable, phoneNumber), Toast.LENGTH_LONG, ).show() } diff --git a/core/ui/src/main/kotlin/com/gamss/android/core/ui/ExternalLink.kt b/feature/emotion/src/main/java/com/gamss/android/feature/emotion/ExternalLink.kt similarity index 91% rename from core/ui/src/main/kotlin/com/gamss/android/core/ui/ExternalLink.kt rename to feature/emotion/src/main/java/com/gamss/android/feature/emotion/ExternalLink.kt index e10b7d6..aba22a2 100644 --- a/core/ui/src/main/kotlin/com/gamss/android/core/ui/ExternalLink.kt +++ b/feature/emotion/src/main/java/com/gamss/android/feature/emotion/ExternalLink.kt @@ -1,4 +1,4 @@ -package com.gamss.android.core.ui +package com.gamss.android.feature.emotion import android.content.ActivityNotFoundException import android.content.Context diff --git a/core/ui/src/main/kotlin/com/gamss/android/core/ui/GamssSupportAgencyDialog.kt b/feature/emotion/src/main/java/com/gamss/android/feature/emotion/component/SupportAgencyDialog.kt similarity index 71% rename from core/ui/src/main/kotlin/com/gamss/android/core/ui/GamssSupportAgencyDialog.kt rename to feature/emotion/src/main/java/com/gamss/android/feature/emotion/component/SupportAgencyDialog.kt index b92246b..a0c90a9 100644 --- a/core/ui/src/main/kotlin/com/gamss/android/core/ui/GamssSupportAgencyDialog.kt +++ b/feature/emotion/src/main/java/com/gamss/android/feature/emotion/component/SupportAgencyDialog.kt @@ -1,4 +1,4 @@ -package com.gamss.android.core.ui +package com.gamss.android.feature.emotion.component import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -16,13 +15,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp +import com.gamss.android.core.designsystem.theme.GamssTheme +import com.gamss.android.domain.safety.RiskLevel import com.gamss.android.domain.safety.SupportAgency +import com.gamss.android.feature.emotion.R @Composable -fun GamssSupportAgencyDialog( +internal fun SupportAgencyDialog( agencies: List, - isBlocking: Boolean, + riskLevel: RiskLevel, onCallClick: (SupportAgency) -> Unit, onDismiss: () -> Unit, ) { @@ -32,24 +33,18 @@ fun GamssSupportAgencyDialog( text = { Column( modifier = Modifier.verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing200), ) { Text( - text = stringResource( - if (isBlocking) { - R.string.safety_dialog_body_blocked - } else { - R.string.safety_dialog_body_notice - }, - ), - style = MaterialTheme.typography.bodyMedium, + text = bodyTextFor(riskLevel), + style = GamssTheme.typography.body4Regular, ) agencies.forEach { agency -> SupportAgencyRow(agency = agency, onCallClick = onCallClick) } Text( text = stringResource(R.string.safety_dialog_emergency), - style = MaterialTheme.typography.bodySmall, + style = GamssTheme.typography.body5Medium, ) } }, @@ -61,6 +56,14 @@ fun GamssSupportAgencyDialog( ) } +@Composable +private fun bodyTextFor(riskLevel: RiskLevel): String = stringResource( + when (riskLevel) { + RiskLevel.CRITICAL -> R.string.safety_dialog_body_blocked + RiskLevel.WARNING, RiskLevel.NONE -> R.string.safety_dialog_body_notice + }, +) + @Composable private fun SupportAgencyRow( agency: SupportAgency, @@ -73,8 +76,8 @@ private fun SupportAgencyRow( verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { - Text(text = agency.name, style = MaterialTheme.typography.titleSmall) - Text(text = agency.description, style = MaterialTheme.typography.bodySmall) + Text(text = agency.name, style = GamssTheme.typography.subtitle4) + Text(text = agency.description, style = GamssTheme.typography.body5Medium) } agency.phoneNumber?.let { phoneNumber -> TextButton( diff --git a/core/ui/src/main/res/values/strings.xml b/feature/emotion/src/main/res/values/strings.xml similarity index 100% rename from core/ui/src/main/res/values/strings.xml rename to feature/emotion/src/main/res/values/strings.xml From e64a0041d681f1f2863817c13af4c94efaf73ff4 Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:43:50 +0900 Subject: [PATCH 10/11] =?UTF-8?q?fix=20:=20=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=86=8C=EC=8A=A4=EC=85=8B=EB=A7=8C=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../safety/FirestoreRiskLexiconDataSource.kt | 5 +- .../FirestoreRiskLexiconDataSourceTest.kt | 52 +++++++++++++++++++ .../safety/RiskLexiconRepositoryImplTest.kt | 22 ++++++++ 3 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 data/src/test/java/com/gamss/android/data/safety/FirestoreRiskLexiconDataSourceTest.kt diff --git a/data/src/main/java/com/gamss/android/data/safety/FirestoreRiskLexiconDataSource.kt b/data/src/main/java/com/gamss/android/data/safety/FirestoreRiskLexiconDataSource.kt index 126299d..8d92e64 100644 --- a/data/src/main/java/com/gamss/android/data/safety/FirestoreRiskLexiconDataSource.kt +++ b/data/src/main/java/com/gamss/android/data/safety/FirestoreRiskLexiconDataSource.kt @@ -5,6 +5,7 @@ import com.gamss.android.data.safety.model.RiskTermDto import com.gamss.android.data.safety.model.SupportAgencyDto import com.google.firebase.firestore.DocumentSnapshot import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.Source import kotlinx.coroutines.tasks.await import javax.inject.Inject import javax.inject.Provider @@ -18,7 +19,7 @@ internal class FirestoreRiskLexiconDataSource @Inject constructor( firestoreProvider.get() .collection(COLLECTION) .document(DOCUMENT) - .get() + .get(Source.SERVER) .await() .takeIf { it.exists() } ?.toDto() @@ -35,7 +36,6 @@ internal class FirestoreRiskLexiconDataSource @Inject constructor( name = it.string(FIELD_NAME), description = it.string(FIELD_DESCRIPTION), phoneNumber = it[FIELD_PHONE_NUMBER] as? String, - url = it[FIELD_URL] as? String, priority = (it[FIELD_PRIORITY] as? Number)?.toInt() ?: Int.MAX_VALUE, ) }, @@ -60,7 +60,6 @@ internal class FirestoreRiskLexiconDataSource @Inject constructor( const val FIELD_NAME = "name" const val FIELD_DESCRIPTION = "description" const val FIELD_PHONE_NUMBER = "phoneNumber" - const val FIELD_URL = "url" const val FIELD_PRIORITY = "priority" } } diff --git a/data/src/test/java/com/gamss/android/data/safety/FirestoreRiskLexiconDataSourceTest.kt b/data/src/test/java/com/gamss/android/data/safety/FirestoreRiskLexiconDataSourceTest.kt new file mode 100644 index 0000000..ccebe00 --- /dev/null +++ b/data/src/test/java/com/gamss/android/data/safety/FirestoreRiskLexiconDataSourceTest.kt @@ -0,0 +1,52 @@ +package com.gamss.android.data.safety + +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.Source +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import javax.inject.Provider + +class FirestoreRiskLexiconDataSourceTest { + + @Test + fun `원격 사전은 서버 소스로만 조회한다`() = runTest { + val firestore = mockk() + val collection = mockk() + val document = mockk() + val snapshot = mockk() + val provider = mockk>() + + every { provider.get() } returns firestore + every { firestore.collection("app_config") } returns collection + every { collection.document("risk_lexicon") } returns document + every { document.get(Source.SERVER) } returns Tasks.forResult(snapshot) + every { snapshot.exists() } returns true + every { snapshot.getLong("version") } returns 3L + every { snapshot.get("terms") } returns listOf(mapOf("term" to "죽고싶", "level" to "CRITICAL")) + every { snapshot.get("safePhrases") } returns listOf("죽고싶지않") + every { snapshot.get("agencies") } returns listOf( + mapOf( + "id" to "kr-109", + "name" to "자살예방 상담전화", + "description" to "24시간 무료 전문 상담", + "phoneNumber" to "109", + "priority" to 1L, + ), + ) + + val dto = FirestoreRiskLexiconDataSource(provider).fetch() + + assertEquals(3, dto?.version) + assertEquals(listOf("죽고싶"), dto?.terms?.map { it.term }) + verify(exactly = 1) { document.get(Source.SERVER) } + verify(exactly = 0) { document.get() } + } +} diff --git a/data/src/test/java/com/gamss/android/data/safety/RiskLexiconRepositoryImplTest.kt b/data/src/test/java/com/gamss/android/data/safety/RiskLexiconRepositoryImplTest.kt index ad500d7..6bf69d0 100644 --- a/data/src/test/java/com/gamss/android/data/safety/RiskLexiconRepositoryImplTest.kt +++ b/data/src/test/java/com/gamss/android/data/safety/RiskLexiconRepositoryImplTest.kt @@ -41,6 +41,7 @@ class RiskLexiconRepositoryImplTest { assertEquals(1, repository.getLexicon().version) coVerify(exactly = 0) { local.write(any(), any()) } + coVerify(exactly = 0) { local.markFetched(any()) } } @Test @@ -60,6 +61,26 @@ class RiskLexiconRepositoryImplTest { val repository = repository() repository.refresh() + coVerify(exactly = 1) { local.markFetched(any()) } + coVerify(exactly = 0) { local.write(any(), any()) } + assertEquals(5, repository.getLexicon().version) + } + + @Test + fun `원격 사전에 필수 필드가 비면 fetchedAt만 갱신하고 기존 사전을 유지한다`() = runTest { + coEvery { bundled.load() } returns dto(version = 5) + coEvery { local.read() } returns null + coEvery { remote.fetch() } returns RiskLexiconDto( + version = 9, + terms = emptyList(), + safePhrases = emptyList(), + agencies = emptyList(), + ) + + val repository = repository() + repository.refresh() + + coVerify(exactly = 1) { local.markFetched(any()) } coVerify(exactly = 0) { local.write(any(), any()) } assertEquals(5, repository.getLexicon().version) } @@ -73,6 +94,7 @@ class RiskLexiconRepositoryImplTest { val repository = repository() repository.refresh() + coVerify(exactly = 1) { local.markFetched(any()) } coVerify(exactly = 1) { local.write(any(), any()) } val lexicon = repository.getLexicon() assertEquals(9, lexicon.version) From 71c25e8932c95d7f24ac697cab8e035c01f87195 Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:53:24 +0900 Subject: [PATCH 11/11] =?UTF-8?q?fix=20:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20dto=20url=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/gamss/android/data/safety/model/RiskLexiconDto.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/data/src/main/java/com/gamss/android/data/safety/model/RiskLexiconDto.kt b/data/src/main/java/com/gamss/android/data/safety/model/RiskLexiconDto.kt index 77d0121..b304517 100644 --- a/data/src/main/java/com/gamss/android/data/safety/model/RiskLexiconDto.kt +++ b/data/src/main/java/com/gamss/android/data/safety/model/RiskLexiconDto.kt @@ -26,7 +26,6 @@ internal data class SupportAgencyDto( val name: String = "", val description: String = "", val phoneNumber: String? = null, - val url: String? = null, val priority: Int = DEFAULT_PRIORITY, )