fix(daily-question): deterministic per-day offset replaces RANDOM(); shared DailyQuestionResolver; auth profile fallback

This commit is contained in:
null 2026-06-23 22:55:55 -05:00
parent 6d74c6acec
commit 77208ff1e6
9 changed files with 183 additions and 80 deletions

View File

@ -12,20 +12,42 @@ interface QuestionDao {
@Query("SELECT * FROM question WHERE id = :id AND status = 'active' AND TRIM(text) <> '' LIMIT 1")
suspend fun getQuestionById(id: String): QuestionEntity?
@Query("SELECT * FROM question WHERE status = 'active' AND is_premium = 0 AND TRIM(text) <> '' AND category_id <> 'unknown' ORDER BY RANDOM() LIMIT 1")
suspend fun getDailyQuestion(): QuestionEntity?
// Daily-question pools are selected DETERMINISTICALLY per day: a stable `ORDER BY id`
// plus a date-derived OFFSET (computed in the repository). RANDOM() previously returned a
// different question on every call, which made the daily question change between reloads
// (breaking "already answered" detection → re-asking) and differ between the two partners.
// Ordering by id + a shared day offset yields the same question across reloads and across
// both partners' devices (they share this question DB).
@Query("SELECT * FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND tags LIKE '%' || :modeTag || '%' AND TRIM(text) <> '' ORDER BY RANDOM() LIMIT 1")
suspend fun getDailyQuestionByModeTag(modeTag: String): QuestionEntity?
@Query("SELECT COUNT(*) FROM question WHERE status = 'active' AND is_premium = 0 AND TRIM(text) <> '' AND category_id <> 'unknown'")
suspend fun countDailyQuestion(): Int
@Query("SELECT * FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND tags LIKE '%quick_answer%' AND TRIM(text) <> '' ORDER BY RANDOM() LIMIT 1")
suspend fun getDailyQuestionFromPack(): QuestionEntity?
@Query("SELECT * FROM question WHERE status = 'active' AND is_premium = 0 AND TRIM(text) <> '' AND category_id <> 'unknown' ORDER BY id LIMIT 1 OFFSET :offset")
suspend fun getDailyQuestionAt(offset: Int): QuestionEntity?
@Query("SELECT * FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND is_premium = 0 AND tags LIKE '%' || :modeTag || '%' AND TRIM(text) <> '' ORDER BY RANDOM() LIMIT 1")
suspend fun getFreeDailyQuestionByModeTag(modeTag: String): QuestionEntity?
@Query("SELECT COUNT(*) FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND tags LIKE '%' || :modeTag || '%' AND TRIM(text) <> ''")
suspend fun countDailyQuestionByModeTag(modeTag: String): Int
@Query("SELECT * FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND is_premium = 0 AND tags LIKE '%quick_answer%' AND TRIM(text) <> '' ORDER BY RANDOM() LIMIT 1")
suspend fun getFreeDailyQuestionFromPack(): QuestionEntity?
@Query("SELECT * FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND tags LIKE '%' || :modeTag || '%' AND TRIM(text) <> '' ORDER BY id LIMIT 1 OFFSET :offset")
suspend fun getDailyQuestionByModeTagAt(modeTag: String, offset: Int): QuestionEntity?
@Query("SELECT COUNT(*) FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND tags LIKE '%quick_answer%' AND TRIM(text) <> ''")
suspend fun countDailyQuestionFromPack(): Int
@Query("SELECT * FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND tags LIKE '%quick_answer%' AND TRIM(text) <> '' ORDER BY id LIMIT 1 OFFSET :offset")
suspend fun getDailyQuestionFromPackAt(offset: Int): QuestionEntity?
@Query("SELECT COUNT(*) FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND is_premium = 0 AND tags LIKE '%' || :modeTag || '%' AND TRIM(text) <> ''")
suspend fun countFreeDailyQuestionByModeTag(modeTag: String): Int
@Query("SELECT * FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND is_premium = 0 AND tags LIKE '%' || :modeTag || '%' AND TRIM(text) <> '' ORDER BY id LIMIT 1 OFFSET :offset")
suspend fun getFreeDailyQuestionByModeTagAt(modeTag: String, offset: Int): QuestionEntity?
@Query("SELECT COUNT(*) FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND is_premium = 0 AND tags LIKE '%quick_answer%' AND TRIM(text) <> ''")
suspend fun countFreeDailyQuestionFromPack(): Int
@Query("SELECT * FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND is_premium = 0 AND tags LIKE '%quick_answer%' AND TRIM(text) <> '' ORDER BY id LIMIT 1 OFFSET :offset")
suspend fun getFreeDailyQuestionFromPackAt(offset: Int): QuestionEntity?
@Query("SELECT * FROM question WHERE category_id = :categoryId AND status = 'active' AND TRIM(text) <> '' ORDER BY depth_level ASC, id ASC")
suspend fun getQuestionsByCategory(categoryId: String): List<QuestionEntity>

View File

@ -24,6 +24,9 @@ class FirebaseAuthDataSource @Inject constructor() {
val currentUserId: String? get() = auth.currentUser?.uid
val currentUserEmail: String? get() = auth.currentUser?.email
/** Provider profile photo (e.g. the Google avatar) — fallback when the user doc has none yet. */
val currentUserPhotoUrl: String? get() = auth.currentUser?.photoUrl?.toString()
val currentUserDisplayName: String? get() = auth.currentUser?.displayName
val isSignedIn: Boolean get() = auth.currentUser != null
val isAnonymous: Boolean get() = auth.currentUser?.isAnonymous ?: false
val isEmailVerified: Boolean get() = auth.currentUser?.isEmailVerified ?: false

View File

@ -18,6 +18,8 @@ class FirebaseAuthRepositoryImpl @Inject constructor(
override val authState: Flow<AuthState> = dataSource.authState
override val currentUserId: String? get() = dataSource.currentUserId
override val currentUserEmail: String? get() = dataSource.currentUserEmail
override val currentUserPhotoUrl: String? get() = dataSource.currentUserPhotoUrl
override val currentUserDisplayName: String? get() = dataSource.currentUserDisplayName
override val isSignedIn: Boolean get() = dataSource.isSignedIn
override val isAnonymous: Boolean get() = dataSource.isAnonymous
override val isGoogleAccount: Boolean get() = dataSource.isGoogleAccount

View File

@ -7,6 +7,7 @@ import app.closer.data.local.mapper.toQuestionCategory
import app.closer.domain.model.Question
import app.closer.domain.model.QuestionCategory
import app.closer.domain.repository.QuestionRepository
import java.time.LocalDate
import javax.inject.Inject
import javax.inject.Singleton
@ -15,22 +16,53 @@ class RoomQuestionRepository @Inject constructor(
private val questionDao: QuestionDao,
private val categoryDao: CategoryDao
) : QuestionRepository {
/**
* Deterministic per-day index into a pool of [count] questions. Seeded by the local
* calendar day so the chosen daily question is stable across reloads and identical on
* both partners' devices (which share this question DB) never RANDOM().
*/
private fun dailyOffset(count: Int): Int =
if (count <= 0) 0 else (LocalDate.now().toEpochDay() % count).toInt()
override suspend fun getDailyQuestion(): Question? {
return questionDao.getDailyQuestion()?.toQuestion()
val count = questionDao.countDailyQuestion()
if (count == 0) return null
return questionDao.getDailyQuestionAt(dailyOffset(count))?.toQuestion()
}
override suspend fun getDailyQuestionForMode(modeTag: String, isPremium: Boolean): Question? {
return if (isPremium) {
questionDao.getDailyQuestionByModeTag(modeTag)?.toQuestion()
?: questionDao.getDailyQuestionFromPack()?.toQuestion()
?: questionDao.getDailyQuestion()?.toQuestion()
dailyByModeTag(modeTag) ?: dailyFromPack() ?: getDailyQuestion()
} else {
questionDao.getFreeDailyQuestionByModeTag(modeTag)?.toQuestion()
?: questionDao.getFreeDailyQuestionFromPack()?.toQuestion()
?: questionDao.getDailyQuestion()?.toQuestion()
freeDailyByModeTag(modeTag) ?: freeDailyFromPack() ?: getDailyQuestion()
}
}
private suspend fun dailyByModeTag(modeTag: String): Question? {
val count = questionDao.countDailyQuestionByModeTag(modeTag)
if (count == 0) return null
return questionDao.getDailyQuestionByModeTagAt(modeTag, dailyOffset(count))?.toQuestion()
}
private suspend fun dailyFromPack(): Question? {
val count = questionDao.countDailyQuestionFromPack()
if (count == 0) return null
return questionDao.getDailyQuestionFromPackAt(dailyOffset(count))?.toQuestion()
}
private suspend fun freeDailyByModeTag(modeTag: String): Question? {
val count = questionDao.countFreeDailyQuestionByModeTag(modeTag)
if (count == 0) return null
return questionDao.getFreeDailyQuestionByModeTagAt(modeTag, dailyOffset(count))?.toQuestion()
}
private suspend fun freeDailyFromPack(): Question? {
val count = questionDao.countFreeDailyQuestionFromPack()
if (count == 0) return null
return questionDao.getFreeDailyQuestionFromPackAt(dailyOffset(count))?.toQuestion()
}
override suspend fun getQuestionById(id: String): Question? {
return questionDao.getQuestionById(id)?.toQuestion()
}

View File

@ -8,6 +8,8 @@ interface AuthRepository {
val authState: Flow<AuthState>
val currentUserId: String?
val currentUserEmail: String?
val currentUserPhotoUrl: String?
val currentUserDisplayName: String?
val isSignedIn: Boolean
val isAnonymous: Boolean
val isGoogleAccount: Boolean

View File

@ -0,0 +1,83 @@
package app.closer.domain.usecase
import app.closer.core.billing.EntitlementChecker
import app.closer.core.crash.CrashReporter
import app.closer.data.remote.FirestoreAnswerDataSource
import app.closer.domain.DailyModeResolver
import app.closer.domain.model.Question
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository
import app.closer.domain.repository.QuestionRepository
import kotlinx.coroutines.flow.first
import javax.inject.Inject
import javax.inject.Singleton
/**
* Single source of truth for "today's daily question" so the Home card and the daily-question
* screen always resolve the SAME question. Previously Home used [QuestionRepository.getDailyQuestion]
* (a different pool) while the screen used the Firestore assignment + mode pool they showed
* different questions, so Home's answered/waiting detection never matched what the user answered,
* which made it re-ask after answering.
*
* Resolution order (paired): Firestore assignment local question by that id deterministic
* mode-tagged daily question deterministic generic daily question. Unpaired falls straight to
* the deterministic local question for today's mode.
*/
@Singleton
class DailyQuestionResolver @Inject constructor(
private val questionRepository: QuestionRepository,
private val coupleRepository: CoupleRepository,
private val authRepository: AuthRepository,
private val firestoreAnswerDataSource: FirestoreAnswerDataSource,
private val entitlementChecker: EntitlementChecker,
private val crashReporter: CrashReporter,
) {
data class Resolved(
val coupleId: String?,
val question: Question?,
val date: String,
val mode: DailyModeResolver.DailyMode,
)
suspend fun resolve(): Resolved {
val mode = DailyModeResolver.resolve()
val today = FirestoreAnswerDataSource.todayLocalDateString()
val isPremium = runCatching { entitlementChecker.isPremium().first() }.getOrDefault(false)
val couple = authRepository.currentUserId?.let { uid ->
runCatching { coupleRepository.getCoupleForUser(uid) }
.onFailure { crashReporter.recordException(it) }
.getOrNull()
}
if (couple == null) {
val q = questionRepository.getDailyQuestionForMode(mode.modeTag, isPremium)
?: questionRepository.getDailyQuestion()
return Resolved(coupleId = null, question = q, date = today, mode = mode)
}
val coupleId = couple.id
val assignment = runCatching {
firestoreAnswerDataSource.getDailyQuestionAssignment(coupleId, today)
}.onFailure { crashReporter.recordException(it) }.getOrNull()
val question = if (assignment != null) {
questionRepository.getQuestionById(assignment.questionId)
?: questionRepository.getDailyQuestionForMode(mode.modeTag, isPremium)
?: questionRepository.getDailyQuestion()
} else {
// No assignment yet — request one, but stay usable with the deterministic local
// question (identical on both devices) if the call fails.
runCatching {
firestoreAnswerDataSource.requestDailyQuestionAssignment(coupleId, today)
questionRepository.getQuestionById(
firestoreAnswerDataSource.getDailyQuestionAssignment(coupleId, today)?.questionId ?: ""
)
}.onFailure { crashReporter.recordException(it) }.getOrNull()
?: questionRepository.getDailyQuestionForMode(mode.modeTag, isPremium)
?: questionRepository.getDailyQuestion()
}
return Resolved(coupleId = coupleId, question = question, date = today, mode = mode)
}
}

View File

@ -161,7 +161,8 @@ class HomeViewModel @Inject constructor(
private val sealedRevealManager: SealedRevealManager,
private val outcomeRepository: OutcomeRepository,
private val settingsRepository: SettingsRepository,
private val activityDataSource: app.closer.data.remote.FirestoreActivityDataSource
private val activityDataSource: app.closer.data.remote.FirestoreActivityDataSource,
private val dailyQuestionResolver: app.closer.domain.usecase.DailyQuestionResolver
) : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState())
@ -198,7 +199,9 @@ class HomeViewModel @Inject constructor(
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
try {
val dailyQuestion = questionRepository.getDailyQuestion()
// Resolve via the shared resolver so Home shows the SAME question as the
// daily-question screen (assignment-backed), keeping answered/waiting state in sync.
val dailyQuestion = dailyQuestionResolver.resolve().question
val categories = questionRepository.getCategories()
.take(6)
.map { category ->

View File

@ -106,10 +106,18 @@ class PairingSuccessViewModel @Inject constructor(
val partnerId = couple?.userIds?.firstOrNull { it != myId }
val partner = partnerId?.let { runCatching { userRepository.getUser(it) }.getOrNull() }
// Fall back to the Firebase Auth profile (e.g. the Google avatar) if the user
// doc photo/name hasn't propagated yet, so both faces show on the congrats screen.
val myPhoto = me?.photoUrl?.takeIf { it.isNotBlank() }
?: authRepository.currentUserPhotoUrl?.takeIf { it.isNotBlank() }
?: ""
val myName = me?.displayName?.takeIf { n -> n.isNotBlank() }
?: authRepository.currentUserDisplayName?.takeIf { n -> n.isNotBlank() }
?: "You"
_uiState.update {
it.copy(
myName = me?.displayName?.takeIf { n -> n.isNotBlank() } ?: "You",
myPhotoUrl = me?.photoUrl ?: "",
myName = myName,
myPhotoUrl = myPhoto,
partnerName = partner?.displayName?.takeIf { n -> n.isNotBlank() } ?: "Your partner",
partnerPhotoUrl = partner?.photoUrl ?: ""
)

View File

@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.analytics.RetentionAnalytics
import app.closer.analytics.RetentionEvent
import app.closer.core.billing.EntitlementChecker
import app.closer.core.crash.CrashReporter
import app.closer.data.remote.FirestoreAnswerDataSource
import app.closer.domain.DailyModeResolver
@ -14,7 +13,7 @@ import app.closer.domain.model.Question
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository
import app.closer.domain.repository.LocalAnswerRepository
import app.closer.domain.repository.QuestionRepository
import app.closer.domain.usecase.DailyQuestionResolver
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.ListenerRegistration
import dagger.hilt.android.lifecycle.HiltViewModel
@ -22,7 +21,6 @@ import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@ -43,13 +41,12 @@ data class LocalQuestionUiState(
@HiltViewModel
class DailyQuestionViewModel @Inject constructor(
private val repository: QuestionRepository,
private val localAnswerRepository: LocalAnswerRepository,
private val firestoreAnswerDataSource: FirestoreAnswerDataSource,
private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository,
private val crashReporter: CrashReporter,
private val entitlementChecker: EntitlementChecker,
private val dailyQuestionResolver: DailyQuestionResolver,
private val retentionAnalytics: RetentionAnalytics,
private val db: FirebaseFirestore
) : ViewModel() {
@ -73,9 +70,11 @@ class DailyQuestionViewModel @Inject constructor(
viewModelScope.launch {
_uiState.value = LocalQuestionUiState(isLoading = true)
try {
val resolvedMode = DailyModeResolver.resolve()
val today = FirestoreAnswerDataSource.todayLocalDateString()
val (coupleId, question) = loadCoupleAndQuestion(today, resolvedMode)
val resolved = dailyQuestionResolver.resolve()
val resolvedMode = resolved.mode
val today = resolved.date
val coupleId = resolved.coupleId
val question = resolved.question
val answer = question?.let { localAnswerRepository.getAnswer(it.id) }
val partnerHasAnswered = coupleId?.let {
runCatching { checkPartnerAnswered(it, today) }.getOrDefault(false)
@ -132,57 +131,6 @@ class DailyQuestionViewModel @Inject constructor(
}
}
/**
* Resolves the current couple (if any) and the daily question.
*
* For paired users, read the couple's assigned daily question from Firestore
* so both partners see the same prompt. If no assignment exists yet, request
* one from the cloud function and fall back to a local mode-tagged question.
*
* For unpaired users, fall back to a mode-tagged question from the daily_fun_mc pack.
*/
private suspend fun loadCoupleAndQuestion(
today: String,
mode: DailyModeResolver.DailyMode
): Pair<String?, Question?> {
val isPremium = runCatching { entitlementChecker.isPremium().first() }.getOrDefault(false)
val couple = authRepository.currentUserId?.let { uid ->
runCatching { coupleRepository.getCoupleForUser(uid) }
.onFailure { crashReporter.recordException(it) }
.getOrNull()
}
if (couple == null) {
return null to (repository.getDailyQuestionForMode(mode.modeTag, isPremium)
?: repository.getDailyQuestion())
}
val coupleId = couple.id
val assignment = runCatching {
firestoreAnswerDataSource.getDailyQuestionAssignment(coupleId, today)
}.onFailure { crashReporter.recordException(it) }.getOrNull()
val question = if (assignment != null) {
repository.getQuestionById(assignment.questionId)
?: repository.getDailyQuestionForMode(mode.modeTag, isPremium)
?: repository.getDailyQuestion()
} else {
// No assignment yet. Request immediate assignment, but keep the app
// usable with a local mode-tagged question in case the call fails.
runCatching {
firestoreAnswerDataSource.requestDailyQuestionAssignment(coupleId, today)
repository.getQuestionById(
firestoreAnswerDataSource.getDailyQuestionAssignment(coupleId, today)?.questionId ?: ""
)
}.onFailure { crashReporter.recordException(it) }.getOrNull()
?: repository.getDailyQuestionForMode(mode.modeTag, isPremium)
?: repository.getDailyQuestion()
}
return coupleId to question
}
fun updateWrittenText(text: String) {
_uiState.update { it.copy(pendingWrittenText = text, submitted = false) }
}