security: hash question IDs and coarsen categories in Firebase Analytics (SecurityReport #9)

This commit is contained in:
null 2026-06-16 22:58:27 -05:00
parent a412247bf3
commit e28a08e5f1
1 changed files with 44 additions and 8 deletions

View File

@ -3,6 +3,7 @@ package app.closer.core.analytics
import android.os.Bundle
import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.analytics.logEvent
import java.security.MessageDigest
import javax.inject.Inject
import javax.inject.Singleton
@ -11,6 +12,41 @@ class FirebaseAnalyticsTracker @Inject constructor(
private val analytics: FirebaseAnalytics
) : AnalyticsTracker {
/**
* Hashes an identifier for analytics logging.
*
* Uses SHA-256 and truncates to the first 16 hex characters. This is a one-way,
* deterministic transform enough for aggregate analytics correlation while making
* it infeasible to recover the original question content from the logged value.
*/
private fun hashForAnalytics(value: String): String {
if (value.isBlank()) return "empty"
return try {
val digest = MessageDigest.getInstance("SHA-256")
.digest(value.toByteArray(Charsets.UTF_8))
digest.joinToString("", limit = 8, truncated = "") { "%02x".format(it) }
} catch (e: Exception) {
"hash_error"
}
}
/**
* Maps fine-grained category identifiers to a coarse-grained set safe for analytics.
*
* This preserves useful aggregate behavioral data (e.g. which coarse areas drive
* engagement) without leaking the specific, often intimate, category names.
*/
private fun coarseCategory(category: String): String {
return when (category.lowercase().trim()) {
"communication", "conflict", "trust", "intimacy", "emotional_intimacy",
"physical_intimacy", "sex", "desire", "rebuilding_trust" -> "intimacy"
"fun", "play", "adventure", "date_night", "travel", "creativity" -> "fun"
"money", "future", "goals", "family", "values", "lifestyle" -> "life"
"daily_check_in", "app_usage" -> "meta"
else -> "other"
}
}
override fun trackScreenViewed(screenName: String) {
analytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) {
param(FirebaseAnalytics.Param.SCREEN_NAME, screenName)
@ -23,23 +59,23 @@ class FirebaseAnalyticsTracker @Inject constructor(
override fun trackQuestionViewed(questionId: String, category: String) {
analytics.logEvent("question_viewed") {
param("question_id", questionId)
param("category", category)
param("question_hash", hashForAnalytics(questionId))
param("category", coarseCategory(category))
}
}
override fun trackAnswerSubmitted(questionId: String, category: String, answerType: String) {
analytics.logEvent("answer_submitted") {
param("question_id", questionId)
param("category", category)
param("question_hash", hashForAnalytics(questionId))
param("category", coarseCategory(category))
param("answer_type", answerType)
}
}
override fun trackAnswerRevealed(questionId: String, category: String) {
analytics.logEvent("answer_revealed") {
param("question_id", questionId)
param("category", category)
param("question_hash", hashForAnalytics(questionId))
param("category", coarseCategory(category))
}
}
@ -59,13 +95,13 @@ class FirebaseAnalyticsTracker @Inject constructor(
override fun trackSpinWheelStarted(categoryId: String) {
analytics.logEvent("spin_wheel_started") {
param("category_id", categoryId)
param("category_hash", hashForAnalytics(categoryId))
}
}
override fun trackSpinWheelCompleted(categoryId: String, answeredCount: Int) {
analytics.logEvent("spin_wheel_completed") {
param("category_id", categoryId)
param("category_hash", hashForAnalytics(categoryId))
param("answered_count", answeredCount.toLong())
}
}