From aff1150295bbed3cfa22ffdfc86bb6fe6ed7d22d Mon Sep 17 00:00:00 2001 From: null Date: Fri, 19 Jun 2026 22:20:49 -0500 Subject: [PATCH] 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 --- .../analytics/NoopRetentionAnalytics.kt | 16 + .../closer/analytics/RetentionAnalytics.kt | 60 ++++ .../app/closer/analytics/RetentionEvent.kt | 277 ++++++++++++++++++ .../java/app/closer/di/AnalyticsModule.kt | 18 ++ .../java/app/closer/ui/home/HomeViewModel.kt | 139 ++++++++- 5 files changed, 496 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/app/closer/analytics/NoopRetentionAnalytics.kt create mode 100644 app/src/main/java/app/closer/analytics/RetentionAnalytics.kt create mode 100644 app/src/main/java/app/closer/analytics/RetentionEvent.kt create mode 100644 app/src/main/java/app/closer/di/AnalyticsModule.kt diff --git a/app/src/main/java/app/closer/analytics/NoopRetentionAnalytics.kt b/app/src/main/java/app/closer/analytics/NoopRetentionAnalytics.kt new file mode 100644 index 00000000..118729e6 --- /dev/null +++ b/app/src/main/java/app/closer/analytics/NoopRetentionAnalytics.kt @@ -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 +} diff --git a/app/src/main/java/app/closer/analytics/RetentionAnalytics.kt b/app/src/main/java/app/closer/analytics/RetentionAnalytics.kt new file mode 100644 index 00000000..2993e15e --- /dev/null +++ b/app/src/main/java/app/closer/analytics/RetentionAnalytics.kt @@ -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" + } + } +} diff --git a/app/src/main/java/app/closer/analytics/RetentionEvent.kt b/app/src/main/java/app/closer/analytics/RetentionEvent.kt new file mode 100644 index 00000000..b2a33744 --- /dev/null +++ b/app/src/main/java/app/closer/analytics/RetentionEvent.kt @@ -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, + ) +} diff --git a/app/src/main/java/app/closer/di/AnalyticsModule.kt b/app/src/main/java/app/closer/di/AnalyticsModule.kt new file mode 100644 index 00000000..b582e10d --- /dev/null +++ b/app/src/main/java/app/closer/di/AnalyticsModule.kt @@ -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 +} diff --git a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt index 566c8d21..8fc1c601 100644 --- a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt +++ b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt @@ -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 = 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 = _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?, + 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() ) } }