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> = + (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..38172a5 --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/safety/RiskLexiconLocalDataSource.kt @@ -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, +) { + 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") + } +} 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..a7d5c7e --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/safety/RiskLexiconRepositoryImpl.kt @@ -0,0 +1,92 @@ +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 + +@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) { + 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..77d0121 --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/safety/model/RiskLexiconDto.kt @@ -0,0 +1,58 @@ +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, +) + +/** 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() }.sortedBy { it.priority }, +) + +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() }, + 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..e893a23 --- /dev/null +++ b/data/src/test/java/com/gamss/android/data/safety/BundledRiskLexiconContentTest.kt @@ -0,0 +1,128 @@ +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.Test +import java.io.File + +class BundledRiskLexiconContentTest { + + private val lexicon = JSON + .decodeFromString(File(ASSET_PATH).readText()) + .toDomain() + + private val matcher = RiskTermMatcher() + + @Test + fun `차단해야 하는 문장은 CRITICAL로 판정한다`() { + assertNoMismatch(CRITICAL_SENTENCES) { it == RiskLevel.CRITICAL } + } + + @Test + fun `안내가 필요한 문장은 감지한다`() { + assertNoMismatch(WARNING_SENTENCES) { it != RiskLevel.NONE } + } + + @Test + fun `일상적인 문장은 감지하지 않는다`() { + assertNoMismatch(BENIGN_SENTENCES) { it == RiskLevel.NONE } + } + + @Test + 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(emptyList(), mismatched) + } + + 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/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..4246a5d --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/safety/DetectRiskInTextUseCase.kt @@ -0,0 +1,13 @@ +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..16671cc --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/safety/RefreshRiskLexiconUseCase.kt @@ -0,0 +1,11 @@ +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..bdf735c --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskDetection.kt @@ -0,0 +1,12 @@ +package com.gamss.android.domain.safety + +data class RiskDetection( + val level: RiskLevel, + val agencies: List, +) { + val shouldBlock: Boolean get() = level == RiskLevel.CRITICAL + + companion object { + val None = RiskDetection(level = RiskLevel.NONE, 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..cda7777 --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskLevel.kt @@ -0,0 +1,7 @@ +package com.gamss.android.domain.safety + +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..65d92f8 --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskLexicon.kt @@ -0,0 +1,8 @@ +package com.gamss.android.domain.safety + +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..23d4c02 --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskLexiconRepository.kt @@ -0,0 +1,13 @@ +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..0eb995c --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/safety/RiskTermMatcher.kt @@ -0,0 +1,86 @@ +package com.gamss.android.domain.safety + +import java.text.Normalizer +import javax.inject.Inject + +/** + * 공백과 구두점을 지운 뒤 부분 문자열로 찾되, 두 글자 이하 표현은 어절 경계에서 시작할 때만 + * 인정한다. 공백을 지우면 "과자 살까" 와 "혼자 살아보니" 안에 "자살" 이 생겨 평범한 일기가 + * 차단되기 때문이다. 세 글자 이상은 "죽고 싶다" 처럼 어절이 갈려도 잡아야 하므로 위치를 보지 않는다. + */ +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 = 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() + + 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 { + /** 치환은 길이를 보존해야 한다. 길이가 바뀌면 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 new file mode 100644 index 0000000..5ee19ba --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/safety/SupportAgency.kt @@ -0,0 +1,9 @@ +package com.gamss.android.domain.safety + +data class SupportAgency( + val id: String, + val name: String, + val description: String, + val phoneNumber: 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..606047e --- /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("a", "자살예방 상담전화", "24시간 무료 전문 상담", "109", 1), + SupportAgency("b", "청소년 상담전화", "청소년 전문 상담", "1388", 3), + ), + ) + + private val useCase = DetectRiskInTextUseCase( + repository = FakeRiskLexiconRepository(lexicon), + matcher = RiskTermMatcher(), + ) + + @Test + fun 사전에_있는_위험_표현을_감지하고_기관을_안내한다() = 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..ca6f73c --- /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("a", "자살예방 상담전화", "24시간 무료 전문 상담", "109", 1), + SupportAgency("b", "청소년 상담전화", "청소년 전문 상담", "1388", 3), + ), + ) + + @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 감지되면_사전의_기관_목록을_그대로_반환한다() { + 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) + } +} 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..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 @@ -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,9 +22,11 @@ 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.domain.emotion.EmotionResult +import com.gamss.android.feature.emotion.component.SupportAgencyDialog import org.orbitmvi.orbit.compose.collectAsState private const val MAX_DIARY_LEN = 140 @@ -34,6 +38,16 @@ fun EmotionScreen( viewModel: EmotionViewModel = hiltViewModel(), ) { val state by viewModel.collectAsState() + val context = LocalContext.current + + state.riskDetection?.let { detection -> + SupportAgencyDialog( + agencies = detection.agencies, + riskLevel = detection.level, + onCallClick = { agency -> context.dialOrNotify(agency.phoneNumber) }, + onDismiss = viewModel::onRiskGuidanceDismiss, + ) + } Scaffold( topBar = { TopAppBar(title = { Text("오늘의 일기 감정") }) }, @@ -79,6 +93,17 @@ fun EmotionScreen( } } +private fun Context.dialOrNotify(phoneNumber: String?) { + if (phoneNumber == null) return + if (!dial(phoneNumber)) { + Toast.makeText( + this, + getString(R.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..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 @@ -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,18 @@ 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 +45,8 @@ class EmotionViewModel @Inject constructor( is AppResult.Failure -> reduce { state.copy(isRunning = false, error = "감정 분석에 실패했어요") } } } + + fun onRiskGuidanceDismiss() = intent { + reduce { state.copy(riskDetection = null) } + } } diff --git a/feature/emotion/src/main/java/com/gamss/android/feature/emotion/ExternalLink.kt b/feature/emotion/src/main/java/com/gamss/android/feature/emotion/ExternalLink.kt new file mode 100644 index 0000000..aba22a2 --- /dev/null +++ b/feature/emotion/src/main/java/com/gamss/android/feature/emotion/ExternalLink.kt @@ -0,0 +1,16 @@ +package com.gamss.android.feature.emotion + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.core.net.toUri + +@Suppress("SwallowedException") +fun Context.dial(phoneNumber: String): Boolean = + try { + startActivity(Intent(Intent.ACTION_DIAL, "tel:${Uri.encode(phoneNumber)}".toUri())) + true + } catch (e: ActivityNotFoundException) { + false + } diff --git a/feature/emotion/src/main/java/com/gamss/android/feature/emotion/component/SupportAgencyDialog.kt b/feature/emotion/src/main/java/com/gamss/android/feature/emotion/component/SupportAgencyDialog.kt new file mode 100644 index 0000000..a0c90a9 --- /dev/null +++ b/feature/emotion/src/main/java/com/gamss/android/feature/emotion/component/SupportAgencyDialog.kt @@ -0,0 +1,91 @@ +package com.gamss.android.feature.emotion.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +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 +internal fun SupportAgencyDialog( + agencies: List, + riskLevel: RiskLevel, + 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(GamssTheme.spacing.spacing200), + ) { + Text( + text = bodyTextFor(riskLevel), + style = GamssTheme.typography.body4Regular, + ) + agencies.forEach { agency -> + SupportAgencyRow(agency = agency, onCallClick = onCallClick) + } + Text( + text = stringResource(R.string.safety_dialog_emergency), + style = GamssTheme.typography.body5Medium, + ) + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.safety_dialog_confirm)) + } + }, + ) +} + +@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, + 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 = GamssTheme.typography.subtitle4) + Text(text = agency.description, style = GamssTheme.typography.body5Medium) + } + agency.phoneNumber?.let { phoneNumber -> + TextButton( + onClick = { onCallClick(agency) }, + modifier = Modifier.semantics { contentDescription = callDescription }, + ) { + Text("$phoneNumber ${stringResource(R.string.safety_agency_call)}") + } + } + } +} diff --git a/feature/emotion/src/main/res/values/strings.xml b/feature/emotion/src/main/res/values/strings.xml new file mode 100644 index 0000000..ef67071 --- /dev/null +++ b/feature/emotion/src/main/res/values/strings.xml @@ -0,0 +1,11 @@ + + + 혼자 감당하지 않아도 괜찮아요 + 작성하신 내용에서 많이 힘든 신호가 보여요. 잠시 멈추고, 전문가와 먼저 이야기해 보시면 좋겠어요. + 작성하신 내용에서 힘든 신호가 보여요. 필요하다면 전문가와 이야기할 수 있어요. + 전화 앱을 열 수 없어요. %1$s 로 직접 걸어주세요. + 지금 당장 위험한 상황이라면 119로 전화해 주세요. + 닫기 + 전화하기 + %1$s에 전화하기 + 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..9e40234 --- /dev/null +++ b/firestore.rules @@ -0,0 +1,15 @@ +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" }