Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
10 commits
Select commit Hold shift + click to select a range
223d732
feat: 위험 표현 감지 도메인 로직 추가 - 원문에서 자살·자해 위험 표현을 찾아 위험도와 안내할 상담 기관을 반환하는 …
soyeonLee126 Aug 3, 2026
486f403
feat: 위험 표현 사전 원격 갱신 구현 - 앱 내장 assets JSON을 기본으로 두고 Firestore app_con…
soyeonLee126 Aug 3, 2026
9376eb7
feat: 상담 기관 안내 다이얼로그 추가 - 위험 표현이 감지되면 상담 창구를 priority 순으로 노출하고 전화 걸기를…
soyeonLee126 Aug 3, 2026
5ea16e3
feat: 감정 분석 화면에 위험 표현 감지 연결 - onAnalyze() 에서 감정 분석 전에 원문을 검사하고, CRITI…
soyeonLee126 Aug 3, 2026
1fa2e4d
refactor: Firestore 인스턴스를 실제 조회 시점까지 지연 생성 - FirebaseFirestore 를 Prov…
soyeonLee126 Aug 3, 2026
c46ba45
chore: 위험 표현 감지 관련 주석 정리 - 코드에서 유추 가능한 설명 주석을 제거하고, RiskTermMatcher 의…
soyeonLee126 Aug 3, 2026
e908c6b
feat: 위험 표현 사전에 죽을거야/죽을래 계열 추가 - 죽을거야, 죽을게, 죽을래, 죽어버릴, 죽어야지, 죽고말, 죽는게…
soyeonLee126 Aug 4, 2026
2fc993e
feat: 공식 자살 경고신호 표현을 위험 표현 사전에 반영 - 보건복지부·자살예방센터가 정리한 언어적 경고신호에서 9개를 …
soyeonLee126 Aug 5, 2026
c617ec8
Merge remote-tracking branch 'origin/develop' into feature/risk-keywo…
soyeonLee126 Aug 6, 2026
34c4208
refactor: 위험 감지 코드 정리 및 designsystem 토큰 적용 - RiskDetection.matchedTer…
soyeonLee126 Aug 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,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)
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@

<uses-permission android:name="android.permission.INTERNET"/>

<queries>
<intent>
<action android:name="android.intent.action.DIAL" />
<data android:scheme="tel" />
</intent>
</queries>

<application
android:name=".GamssApplication"
android:allowBackup="true"
Expand Down
28 changes: 27 additions & 1 deletion app/src/main/kotlin/com/gamss/android/app/GamssApplication.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,33 @@
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() }
}
}
1 change: 1 addition & 0 deletions data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
103 changes: 103 additions & 0 deletions data/src/main/assets/risk_lexicon.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
{
"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" },
{ "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": "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
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -15,4 +17,8 @@ internal object FirebaseModule {
@Provides
@Singleton
fun provideFirebaseAuth(): FirebaseAuth = Firebase.auth

@Provides
@Singleton
fun provideFirebaseFirestore(): FirebaseFirestore = Firebase.firestore
}
40 changes: 40 additions & 0 deletions data/src/main/java/com/gamss/android/data/di/SafetyModule.kt
Original file line number Diff line number Diff line change
@@ -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<Preferences> = PreferenceDataStoreFactory.create(
produceFile = { context.preferencesDataStoreFile(RISK_LEXICON_DATA_STORE_NAME) },
)

private const val RISK_LEXICON_DATA_STORE_NAME = "risk_lexicon"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
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<RiskLexiconDto>(reader.readText())
}
}

private companion object {
const val ASSET_NAME = "risk_lexicon.json"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
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.Provider
import javax.inject.Singleton

@Singleton
internal class FirestoreRiskLexiconDataSource @Inject constructor(
private val firestoreProvider: Provider<FirebaseFirestore>,
) {
suspend fun fetch(): RiskLexiconDto? =
firestoreProvider.get()
.collection(COLLECTION)
.document(DOCUMENT)
.get()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Source 미지정 시 기본값(Source.DEFAULT)은 서버 접속 실패 시 예외 대신 Firestore SDK 자체 오프라인 캐시의 낡은 스냅샷을 조용히 반환합니다. adopt()는 fetch()가 non-null이기만 하면 버전 비교 전에 markFetched(now())부터 호출해 24시간 TTL을 리셋하므로, 오프라인 상태에서 낡은 캐시를 받아도 "방금 서버 확인 완료"로 잘못 기록됩니다 —>실제로는 신규 원격 사전을 놓친 채로 하루를 더 기다리게 됩니다.

.get(Source.SERVER)로 바꾸면 서버 접속 실패 시 확실히 예외가 발생하게 될것 같습니다!

.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<String>(),
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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FIELD_URL을 읽어 SupportAgencyDto.url에 채우고 toDomain()에서 버리는 죽은 데이터 경로인데 혹시 나중에 쓸 계획 있나요? 아니라면 FIELD_URL/SupportAgencyDto.url도 같이 제거하는 게 깔끔할 것 같습니다!

priority = (it[FIELD_PRIORITY] as? Number)?.toInt() ?: Int.MAX_VALUE,
)
},
)

private fun DocumentSnapshot.objectList(field: String): List<Map<*, *>> =
(get(field) as? List<*>).orEmpty().filterIsInstance<Map<*, *>>()

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"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.gamss.android.data.safety

import javax.inject.Qualifier

@Qualifier
@Retention(AnnotationRetention.BINARY)
internal annotation class RiskLexiconDataStore
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
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<Preferences>,
) {
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
}
}

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")
}
}
Loading
Loading