diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 8324d01..5c3ed70 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 553a344..11251c6 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,6 +3,13 @@
+
+
+
+
+
+
+
+ Log.w(TAG, "application scope task failed", throwable)
+ },
+ )
+
+ override fun onCreate() {
+ super.onCreate()
+ applicationScope.launch { refreshRiskLexicon() }
+ }
+}
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..35dd47e
--- /dev/null
+++ b/data/src/main/assets/risk_lexicon.json
@@ -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
+ }
+ ]
+}
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..b318090
--- /dev/null
+++ b/data/src/main/java/com/gamss/android/data/safety/BundledRiskLexiconDataSource.kt
@@ -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(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..126299d
--- /dev/null
+++ b/data/src/main/java/com/gamss/android/data/safety/FirestoreRiskLexiconDataSource.kt
@@ -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,
+) {
+ suspend fun fetch(): RiskLexiconDto? =
+ firestoreProvider.get()
+ .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