refactor(data): finish Firebase Tasks→.await() across the data layer

Convert the remaining 10 data sources (Bucket, Conversation, QuestionThread,
DatePlan, DateMemory, DateReflection, Answer, User, Auth, DateMatch) from
verbose suspendCancellableCoroutine{cont-> ...addOnSuccess/addOnFailure} wrappers
(and the per-file getDoc/voidAwait/queryAwait/refAwait helper duplicates) to the
kotlinx-coroutines-play-services .await() extension. The data layer now has ZERO
Task-callback wrappers.

Per-block review preserved non-trivial semantics: callbackFlow snapshot listeners
and the DateMatch transaction are untouched; failure→null/false best-effort blocks
became runCatching{ ...await() }.getOrNull()/getOrDefault() (e.g. markRevealed,
hasProfile, PlayIntegrity.verifyWithServer); no-signed-in-user guards became
`?: throw`.

Verified live (real Firebase, paired account): auth + Home reads (couple/user/
answer/challenge), daily-question answer WRITE (secure payload + metadata sets),
and the streak update all succeed — no crash, no PERMISSION_DENIED. Unit suite green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-06 22:00:55 -05:00
parent f160f141d6
commit fcc0d699fb
10 changed files with 196 additions and 462 deletions

View File

@ -16,11 +16,9 @@ import com.google.firebase.auth.GoogleAuthProvider
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.tasks.await
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@Singleton
class FirebaseAuthDataSource @Inject constructor() {
@ -57,42 +55,20 @@ class FirebaseAuthDataSource @Inject constructor() {
?: AuthState.Unauthenticated
suspend fun signInWithEmail(email: String, password: String): String =
suspendCancellableCoroutine { cont ->
auth.signInWithEmailAndPassword(email, password)
.addOnSuccessListener { cont.resume(it.user?.uid ?: "") }
.addOnFailureListener { cont.resumeWithException(it) }
}
auth.signInWithEmailAndPassword(email, password).await().user?.uid ?: ""
suspend fun signUpWithEmail(email: String, password: String): String =
suspendCancellableCoroutine { cont ->
auth.createUserWithEmailAndPassword(email, password)
.addOnSuccessListener { cont.resume(it.user?.uid ?: "") }
.addOnFailureListener { cont.resumeWithException(it) }
}
auth.createUserWithEmailAndPassword(email, password).await().user?.uid ?: ""
suspend fun sendEmailVerification(): Unit =
suspendCancellableCoroutine { cont ->
val user = auth.currentUser
if (user == null) {
cont.resumeWithException(IllegalStateException("No signed-in user"))
return@suspendCancellableCoroutine
}
user.sendEmailVerification()
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
suspend fun sendEmailVerification() {
val user = auth.currentUser ?: throw IllegalStateException("No signed-in user")
user.sendEmailVerification().await()
}
/** Refreshes the cached FirebaseUser so [isEmailVerified] reflects server state. */
suspend fun reloadUser(): Unit =
suspendCancellableCoroutine { cont ->
val user = auth.currentUser
if (user == null) {
cont.resumeWithException(IllegalStateException("No signed-in user"))
return@suspendCancellableCoroutine
}
user.reload()
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
suspend fun reloadUser() {
val user = auth.currentUser ?: throw IllegalStateException("No signed-in user")
user.reload().await()
}
/**
@ -116,11 +92,8 @@ class FirebaseAuthDataSource @Inject constructor() {
}
}
private suspend fun sendResetEmail(email: String): Unit =
suspendCancellableCoroutine { cont ->
auth.sendPasswordResetEmail(email)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
private suspend fun sendResetEmail(email: String) {
auth.sendPasswordResetEmail(email).await()
}
/**
@ -162,58 +135,39 @@ class FirebaseAuthDataSource @Inject constructor() {
private suspend fun reauthenticate(
user: com.google.firebase.auth.FirebaseUser,
credential: com.google.firebase.auth.AuthCredential
): Unit = suspendCancellableCoroutine { cont ->
user.reauthenticate(credential)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
) {
user.reauthenticate(credential).await()
}
private suspend fun updatePassword(
user: com.google.firebase.auth.FirebaseUser,
newPassword: String
): Unit = suspendCancellableCoroutine { cont ->
user.updatePassword(newPassword)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
) {
user.updatePassword(newPassword).await()
}
suspend fun signInWithGoogle(idToken: String): GoogleSignInResult =
suspendCancellableCoroutine { cont ->
suspend fun signInWithGoogle(idToken: String): GoogleSignInResult {
val credential = GoogleAuthProvider.getCredential(idToken, null)
auth.signInWithCredential(credential)
.addOnSuccessListener { result ->
val result = auth.signInWithCredential(credential).await()
val user = auth.currentUser ?: result.user
cont.resume(
GoogleSignInResult(
return GoogleSignInResult(
uid = user?.uid ?: "",
displayName = user?.displayName ?: "",
photoUrl = user?.photoUrl?.toString() ?: "",
email = user?.email ?: "",
isAnonymous = user?.isAnonymous ?: false
)
)
}
.addOnFailureListener { cont.resumeWithException(it) }
}
fun signOut() = auth.signOut()
suspend fun reauthenticateWithEmail(email: String, password: String): Unit =
suspendCancellableCoroutine { cont ->
val credential = EmailAuthProvider.getCredential(email, password)
auth.currentUser
?.reauthenticate(credential)
?.addOnSuccessListener { cont.resume(Unit) }
?.addOnFailureListener { cont.resumeWithException(it) }
?: cont.resumeWithException(IllegalStateException("No signed-in user"))
suspend fun reauthenticateWithEmail(email: String, password: String) {
val user = auth.currentUser ?: throw IllegalStateException("No signed-in user")
user.reauthenticate(EmailAuthProvider.getCredential(email, password)).await()
}
suspend fun deleteAccount(): Unit =
suspendCancellableCoroutine { cont ->
auth.currentUser
?.delete()
?.addOnSuccessListener { cont.resume(Unit) }
?.addOnFailureListener { cont.resumeWithException(it) }
?: cont.resumeWithException(IllegalStateException("No signed-in user"))
suspend fun deleteAccount() {
val user = auth.currentUser ?: throw IllegalStateException("No signed-in user")
user.delete().await()
}
}

View File

@ -11,7 +11,7 @@ import com.google.firebase.firestore.FirebaseFirestore
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.tasks.await
import org.json.JSONArray
import org.json.JSONObject
import java.time.Clock
@ -21,8 +21,6 @@ import java.time.ZoneId
import java.time.format.DateTimeFormatter
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* Syncs daily-question answers to Firestore under the couple's daily question doc.
@ -103,18 +101,9 @@ class FirestoreAnswerDataSource @Inject constructor(
"isRevealed" to false
)
// Encrypted content lives in the read-gated `secure` subdoc (no peek before both answer).
suspendCancellableCoroutine { cont ->
securePayloadRef(coupleId, date, userId)
.set(mapOf("encryptedPayload" to encryptedPayload))
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
suspendCancellableCoroutine { cont ->
answerRef(coupleId, date, userId)
.set(metadata)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
.set(mapOf("encryptedPayload" to encryptedPayload)).await()
answerRef(coupleId, date, userId).set(metadata).await()
}
private fun securePayloadRef(coupleId: String, date: String, userId: String) =
@ -123,23 +112,16 @@ class FirestoreAnswerDataSource @Inject constructor(
/** Reads + decrypts a partner's couple-key answer content from the read-gated `secure` subdoc. */
suspend fun decryptCoupleKeyAnswerFor(coupleId: String, date: String, userId: String): DecodedAnswer? {
val encryptedPayload = runCatching {
suspendCancellableCoroutine<String?> { cont ->
securePayloadRef(coupleId, date, userId)
.get()
.addOnSuccessListener { snap -> cont.resume(snap.getString("encryptedPayload")) }
.addOnFailureListener { cont.resumeWithException(it) }
}
securePayloadRef(coupleId, date, userId).get().await().getString("encryptedPayload")
}.getOrNull()
return decryptCoupleKeyAnswer(coupleId, encryptedPayload)
}
/** Marks the caller's own answer revealed in Firestore — drives the partner-opened push. */
suspend fun markRevealed(coupleId: String, date: String, userId: String): Unit =
suspendCancellableCoroutine { cont ->
suspend fun markRevealed(coupleId: String, date: String, userId: String) {
answerRef(coupleId, date, userId)
.update(mapOf("isRevealed" to true, "updatedAt" to System.currentTimeMillis()))
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
.await()
}
/** Decrypts a couple-key (enc:v1:) answer payload. Returns null if the key/payload is unavailable. */
@ -202,24 +184,17 @@ class FirestoreAnswerDataSource @Inject constructor(
"isRevealed" to answer.isRevealed
)
suspendCancellableCoroutine { cont ->
answerRef(coupleId, date, userId)
.set(data)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
answerRef(coupleId, date, userId).set(data).await()
}
/** Updates the answerKeyReleased flag after the one-time key is sent to the partner. */
suspend fun markAnswerKeyReleased(coupleId: String, date: String, userId: String): Unit =
suspendCancellableCoroutine { cont ->
suspend fun markAnswerKeyReleased(coupleId: String, date: String, userId: String) {
answerRef(coupleId, date, userId)
// updatedAt is stored as epoch millis everywhere else; a serverTimestamp here made
// it a Firestore Timestamp, which crashed toLocalAnswer's getLong("updatedAt") when
// the partner read this answer during reveal. Keep it a Long.
.update(mapOf("answerKeyReleased" to true, "updatedAt" to System.currentTimeMillis()))
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
.await()
}
/**
@ -229,17 +204,9 @@ class FirestoreAnswerDataSource @Inject constructor(
coupleId: String,
userId: String,
date: String = todayLocalDateString()
): LocalAnswer? = suspendCancellableCoroutine { cont ->
answerRef(coupleId, date, userId)
.get()
.addOnSuccessListener { snap ->
if (!snap.exists()) {
cont.resume(null)
return@addOnSuccessListener
}
cont.resume(snap.toLocalAnswer())
}
.addOnFailureListener { cont.resumeWithException(it) }
): LocalAnswer? {
val snap = answerRef(coupleId, date, userId).get().await()
return if (snap.exists()) snap.toLocalAnswer() else null
}
/**
@ -263,39 +230,26 @@ class FirestoreAnswerDataSource @Inject constructor(
/**
* Reads the couple-scoped daily question assignment for today.
*/
suspend fun getDailyQuestionAssignment(coupleId: String, date: String = todayLocalDateString()): DailyQuestionAssignment? =
suspendCancellableCoroutine { cont ->
dailyQuestionRef(coupleId, date)
.get()
.addOnSuccessListener { snap ->
if (!snap.exists()) {
cont.resume(null)
return@addOnSuccessListener
}
cont.resume(
DailyQuestionAssignment(
suspend fun getDailyQuestionAssignment(coupleId: String, date: String = todayLocalDateString()): DailyQuestionAssignment? {
val snap = dailyQuestionRef(coupleId, date).get().await()
if (!snap.exists()) return null
return DailyQuestionAssignment(
questionId = snap.getString("questionId") ?: "",
date = snap.getString("date") ?: date,
assignedAt = snap.getTimestamp("assignedAt"),
expiresAt = snap.getTimestamp("expiresAt")
)
)
}
.addOnFailureListener { cont.resumeWithException(it) }
}
/**
* Calls the cloud function to assign a daily question for the couple immediately.
* Used when a couple is newly created and has no assignment yet.
*/
suspend fun requestDailyQuestionAssignment(coupleId: String, date: String = todayLocalDateString()): Unit =
suspendCancellableCoroutine { cont ->
val functions = com.google.firebase.functions.FirebaseFunctions.getInstance()
functions
suspend fun requestDailyQuestionAssignment(coupleId: String, date: String = todayLocalDateString()) {
com.google.firebase.functions.FirebaseFunctions.getInstance()
.getHttpsCallable("assignDailyQuestionCallable")
.call(mapOf("coupleId" to coupleId, "date" to date))
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
.await()
}
private fun com.google.firebase.firestore.DocumentSnapshot.toLocalAnswer(): LocalAnswer {
@ -341,7 +295,7 @@ class FirestoreAnswerDataSource @Inject constructor(
partnerAnswer: String?,
modeTag: String?,
date: String
): Unit = suspendCancellableCoroutine { cont ->
) {
val aead = encryptionManager.requireAead(coupleId)
val doc = mapOf(
"questionId" to questionId,
@ -358,8 +312,7 @@ class FirestoreAnswerDataSource @Inject constructor(
.collection("lore")
.document(questionId)
.set(doc)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
.await()
}
private suspend fun ensureUserPublicKeyPublished(userId: String) {

View File

@ -8,11 +8,9 @@ import com.google.firebase.firestore.SetOptions
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.tasks.await
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* Firestore data source for couple bucket lists.
@ -50,7 +48,7 @@ class FirestoreBucketListDataSource @Inject constructor(
"completedAt" to item.completedAt,
"isCompleted" to item.isCompleted
)
doc.set(data, SetOptions.merge()).voidAwait()
doc.set(data, SetOptions.merge()).await()
return doc.id
}
@ -65,11 +63,11 @@ class FirestoreBucketListDataSource @Inject constructor(
"completedAt" to item.completedAt,
"isCompleted" to item.isCompleted
)
path.set(data, SetOptions.merge()).voidAwait()
path.set(data, SetOptions.merge()).await()
}
suspend fun getItem(coupleId: String, itemId: String): BucketListItem? {
val snap = itemsRef(coupleId).document(itemId).getDoc()
val snap = itemsRef(coupleId).document(itemId).get().await()
return snap.toBucketListItem(coupleId)
}
@ -86,12 +84,12 @@ class FirestoreBucketListDataSource @Inject constructor(
}
suspend fun getItems(coupleId: String): List<BucketListItem> {
val snap = itemsRef(coupleId).queryAwait()
val snap = itemsRef(coupleId).get().await()
return snap.documents.mapNotNull { it.toBucketListItem(coupleId) }
}
suspend fun deleteItem(coupleId: String, itemId: String) {
itemsRef(coupleId).document(itemId).delete().voidAwait()
itemsRef(coupleId).document(itemId).delete().await()
}
suspend fun completeItem(coupleId: String, itemId: String, completedBy: String) {
@ -101,7 +99,7 @@ class FirestoreBucketListDataSource @Inject constructor(
"completedAt" to System.currentTimeMillis(),
"isCompleted" to true
)
path.set(data, SetOptions.merge()).voidAwait()
path.set(data, SetOptions.merge()).await()
}
// ─── Category queries ────────────────────────────────────────────────────
@ -123,32 +121,10 @@ class FirestoreBucketListDataSource @Inject constructor(
val snap = itemsRef(coupleId)
.whereEqualTo("category", category)
.orderBy("addedAt", com.google.firebase.firestore.Query.Direction.DESCENDING)
.queryAwait()
.get().await()
return snap.documents.mapNotNull { it.toBucketListItem(coupleId) }
}
// ─── Coroutine helpers ───────────────────────────────────────────────────
private suspend fun com.google.firebase.firestore.DocumentReference.getDoc() =
suspendCancellableCoroutine { cont ->
get()
.addOnSuccessListener { cont.resume(it) }
.addOnFailureListener { cont.resumeWithException(it) }
}
private suspend fun com.google.firebase.firestore.Query.queryAwait() =
suspendCancellableCoroutine { cont ->
get()
.addOnSuccessListener { cont.resume(it) }
.addOnFailureListener { cont.resumeWithException(it) }
}
private suspend fun com.google.android.gms.tasks.Task<Void>.voidAwait() =
suspendCancellableCoroutine<Unit> { cont ->
addOnSuccessListener { cont.resume(Unit) }
addOnFailureListener { cont.resumeWithException(it) }
}
// ─── Mapper ──────────────────────────────────────────────────────────────
@Suppress("UNCHECKED_CAST")

View File

@ -13,11 +13,9 @@ import com.google.firebase.firestore.SetOptions
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.tasks.await
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* Backs the Messages inbox + each conversation's chat. Every conversation lives under
@ -65,7 +63,7 @@ class FirestoreConversationDataSource @Inject constructor(
"createdAt" to FieldValue.serverTimestamp()
),
SetOptions.merge()
).voidAwait()
).await()
}
/** Creates the per-question conversation if needed and returns its id. */
@ -80,7 +78,7 @@ class FirestoreConversationDataSource @Inject constructor(
"createdAt" to FieldValue.serverTimestamp()
),
SetOptions.merge()
).voidAwait()
).await()
}
return convId
}
@ -88,7 +86,7 @@ class FirestoreConversationDataSource @Inject constructor(
suspend fun markRead(coupleId: String, conversationId: String, userId: String) {
conversationsRef(coupleId).document(conversationId)
.set(mapOf("reads" to mapOf(userId to FieldValue.serverTimestamp())), SetOptions.merge())
.voidAwait()
.await()
}
// ─── Messages ──────────────────────────────────────────────────────────────────
@ -103,7 +101,7 @@ class FirestoreConversationDataSource @Inject constructor(
"text" to cipher,
"createdAt" to FieldValue.serverTimestamp()
)
).refAwait()
).await()
updateLastMessage(coupleId, conversationId, userId, cipher)
}
@ -118,7 +116,7 @@ class FirestoreConversationDataSource @Inject constructor(
"mediaUrl" to url,
"createdAt" to FieldValue.serverTimestamp()
)
).refAwait()
).await()
// Preview a photo with a fixed (still encrypted) label so the inbox decrypt path is uniform.
updateLastMessage(coupleId, conversationId, userId, fieldEncryptor.encrypt("📷 Photo", aead, coupleId))
}
@ -135,7 +133,7 @@ class FirestoreConversationDataSource @Inject constructor(
"durationMs" to durationMs,
"createdAt" to FieldValue.serverTimestamp()
)
).refAwait()
).await()
updateLastMessage(coupleId, conversationId, userId, fieldEncryptor.encrypt("🎤 Voice message", aead, coupleId))
}
@ -147,7 +145,7 @@ class FirestoreConversationDataSource @Inject constructor(
"lastMessageSenderId" to userId
),
SetOptions.merge()
).voidAwait()
).await()
}
/**
@ -171,15 +169,15 @@ class FirestoreConversationDataSource @Inject constructor(
suspend fun setReaction(coupleId: String, conversationId: String, messageId: String, userId: String, emoji: String?) {
val ref = messagesRef(coupleId, conversationId).document(messageId)
if (emoji == null) {
ref.update("reactions.$userId", FieldValue.delete()).voidAwait()
ref.update("reactions.$userId", FieldValue.delete()).await()
} else {
ref.update(mapOf("reactions.$userId" to emoji)).voidAwait()
ref.update(mapOf("reactions.$userId" to emoji)).await()
}
}
/** Unsend: tombstones a message (author-only) and reflects it in the inbox preview if it was last. */
suspend fun deleteMessage(coupleId: String, conversationId: String, messageId: String) {
messagesRef(coupleId, conversationId).document(messageId).update("deleted", true).voidAwait()
messagesRef(coupleId, conversationId).document(messageId).update("deleted", true).await()
val latest = runCatching {
messagesRef(coupleId, conversationId)
.orderBy("createdAt", Query.Direction.DESCENDING).limit(1).get().await()
@ -189,7 +187,7 @@ class FirestoreConversationDataSource @Inject constructor(
conversationsRef(coupleId).document(conversationId).set(
mapOf("lastMessagePreview" to fieldEncryptor.encrypt("Message deleted", aead, coupleId)),
SetOptions.merge()
).voidAwait()
).await()
}
}
@ -330,21 +328,4 @@ class FirestoreConversationDataSource @Inject constructor(
)
}
private suspend fun <T> com.google.android.gms.tasks.Task<T>.await(): T =
suspendCancellableCoroutine { cont ->
addOnSuccessListener { cont.resume(it) }
addOnFailureListener { cont.resumeWithException(it) }
}
private suspend fun com.google.android.gms.tasks.Task<Void>.voidAwait() =
suspendCancellableCoroutine<Unit> { cont ->
addOnSuccessListener { cont.resume(Unit) }
addOnFailureListener { cont.resumeWithException(it) }
}
private suspend fun com.google.android.gms.tasks.Task<com.google.firebase.firestore.DocumentReference>.refAwait() =
suspendCancellableCoroutine<Unit> { cont ->
addOnSuccessListener { cont.resume(Unit) }
addOnFailureListener { cont.resumeWithException(it) }
}
}

View File

@ -8,12 +8,9 @@ import com.google.firebase.firestore.FirebaseFirestore
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.tasks.await
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* Firestore data source for revealed mutual date matches.
@ -67,7 +64,7 @@ class FirestoreDateMatchDataSource @Inject constructor(private val db: FirebaseF
val snap = matchesRef(coupleId)
.whereEqualTo("dateIdeaId", dateIdeaId)
.limit(1)
.queryAwait()
.get().await()
return snap.documents.firstOrNull()?.toDateMatch(coupleId)
}
@ -81,15 +78,6 @@ class FirestoreDateMatchDataSource @Inject constructor(private val db: FirebaseF
awaitClose { listener.remove() }
}
// ─── Coroutine helpers ───────────────────────────────────────────────────
private suspend fun com.google.firebase.firestore.Query.queryAwait() =
suspendCancellableCoroutine { cont ->
get()
.addOnSuccessListener { cont.resume(it) }
.addOnFailureListener { cont.resumeWithException(it) }
}
// ─── Mapper ──────────────────────────────────────────────────────────────
@Suppress("UNCHECKED_CAST")

View File

@ -7,11 +7,9 @@ import com.google.firebase.firestore.SetOptions
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.tasks.await
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* Completed-date log for the Date Replay timeline: `couples/{coupleId}/date_history/{id}`.
@ -35,8 +33,7 @@ class FirestoreDateMemoryDataSource @Inject constructor(private val db: Firebase
)
/** Idempotent (merge on doc id = matchId — both partners marking the same date = one record). */
suspend fun markCompleted(coupleId: String, memory: DateMemory): Unit =
suspendCancellableCoroutine { cont ->
suspend fun markCompleted(coupleId: String, memory: DateMemory) {
historyRef(coupleId).document(memory.id).set(
mapOf(
"dateIdeaId" to memory.dateIdeaId,
@ -46,7 +43,7 @@ class FirestoreDateMemoryDataSource @Inject constructor(private val db: Firebase
"addedBy" to memory.addedBy
),
SetOptions.merge()
).addOnSuccessListener { cont.resume(Unit) }.addOnFailureListener { cont.resumeWithException(it) }
).await()
}
fun observeHistory(coupleId: String): Flow<List<DateMemory>> = callbackFlow {
@ -60,15 +57,10 @@ class FirestoreDateMemoryDataSource @Inject constructor(private val db: Firebase
}
suspend fun getHistoryOnce(coupleId: String): List<DateMemory> =
suspendCancellableCoroutine { cont ->
historyRef(coupleId).orderBy("completedAt", Query.Direction.DESCENDING).get()
.addOnSuccessListener { snap -> cont.resume(snap.documents.map(::toMemory)) }
.addOnFailureListener { cont.resumeWithException(it) }
}
historyRef(coupleId).orderBy("completedAt", Query.Direction.DESCENDING).get().await()
.documents.map(::toMemory)
suspend fun delete(coupleId: String, id: String): Unit =
suspendCancellableCoroutine { cont ->
historyRef(coupleId).document(id).delete()
.addOnSuccessListener { cont.resume(Unit) }.addOnFailureListener { cont.resumeWithException(it) }
suspend fun delete(coupleId: String, id: String) {
historyRef(coupleId).document(id).delete().await()
}
}

View File

@ -11,12 +11,10 @@ import org.json.JSONArray
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.suspendCancellableCoroutine
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* Firestore data source for date plan preferences and plans.
@ -57,7 +55,7 @@ class FirestoreDatePlanDataSource @Inject constructor(
"createdAt" to preference.createdAt,
"updatedAt" to preference.updatedAt
)
path.set(data, SetOptions.merge()).voidAwait()
path.set(data, SetOptions.merge()).await()
}
suspend fun getPreference(coupleId: String, dateIdeaId: String): DatePlanPreference? {
@ -65,7 +63,7 @@ class FirestoreDatePlanDataSource @Inject constructor(
val snap = preferencesRef(coupleId)
.whereEqualTo("dateIdeaId", dateIdeaId)
.limit(1)
.queryAwait()
.get().await()
return snap.documents.firstOrNull()?.toDatePlanPreference(coupleId)
}
@ -84,13 +82,13 @@ class FirestoreDatePlanDataSource @Inject constructor(
suspend fun createPlan(coupleId: String, plan: DatePlan): String {
val doc = plansRef(coupleId).document()
doc.set(plan.toEncryptedData(coupleId, includeCreatedAt = true), SetOptions.merge()).voidAwait()
doc.set(plan.toEncryptedData(coupleId, includeCreatedAt = true), SetOptions.merge()).await()
return doc.id
}
suspend fun updatePlan(coupleId: String, plan: DatePlan) {
val path = plansRef(coupleId).document(plan.id)
path.set(plan.toEncryptedData(coupleId, includeCreatedAt = false), SetOptions.merge()).voidAwait()
path.set(plan.toEncryptedData(coupleId, includeCreatedAt = false), SetOptions.merge()).await()
}
/**
@ -117,7 +115,7 @@ class FirestoreDatePlanDataSource @Inject constructor(
}
suspend fun getPlan(coupleId: String, planId: String): DatePlan? {
val snap = plansRef(coupleId).document(planId).getDoc()
val snap = plansRef(coupleId).document(planId).get().await()
return snap.toDatePlan(coupleId)
}
@ -137,34 +135,12 @@ class FirestoreDatePlanDataSource @Inject constructor(
val snap = plansRef(coupleId)
.whereEqualTo("dateIdeaId", dateIdeaId)
.limit(1)
.queryAwait()
.get().await()
return snap.documents.firstOrNull()?.toDatePlan(coupleId)
}
suspend fun deletePlan(coupleId: String, planId: String) {
plansRef(coupleId).document(planId).delete().voidAwait()
}
// ─── Coroutine helpers ───────────────────────────────────────────────────
private suspend fun com.google.firebase.firestore.Query.queryAwait() =
suspendCancellableCoroutine { cont ->
get()
.addOnSuccessListener { cont.resume(it) }
.addOnFailureListener { cont.resumeWithException(it) }
}
private suspend fun com.google.android.gms.tasks.Task<Void>.voidAwait() =
suspendCancellableCoroutine<Unit> { cont ->
addOnSuccessListener { cont.resume(Unit) }
addOnFailureListener { cont.resumeWithException(it) }
}
private suspend fun com.google.firebase.firestore.DocumentReference.getDoc() =
suspendCancellableCoroutine { cont ->
get()
.addOnSuccessListener { cont.resume(it) }
.addOnFailureListener { cont.resumeWithException(it) }
plansRef(coupleId).document(planId).delete().await()
}
// ─── Mappers ─────────────────────────────────────────────────────────────

View File

@ -7,12 +7,10 @@ import com.google.firebase.firestore.FirebaseFirestore
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.tasks.await
import org.json.JSONObject
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* Post-date reflections mirrors the daily-question couple-key reveal:
@ -55,11 +53,7 @@ class FirestoreDateReflectionDataSource @Inject constructor(
"isRevealed" to false
)
)
suspendCancellableCoroutine<Unit> { cont ->
batch.commit()
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
batch.commit().await()
}
/**
@ -71,10 +65,7 @@ class FirestoreDateReflectionDataSource @Inject constructor(
val aead = encryptionManager.aeadFor(coupleId)
?: throw IllegalStateException("Couple key unavailable for $coupleId")
val payload = fieldEncryptor.encrypt(encode(reflection), aead, coupleId)
suspendCancellableCoroutine<Unit> { cont ->
securePayloadRef(coupleId, dateId, userId).set(mapOf("encryptedPayload" to payload))
.addOnSuccessListener { cont.resume(Unit) }.addOnFailureListener { cont.resumeWithException(it) }
}
securePayloadRef(coupleId, dateId, userId).set(mapOf("encryptedPayload" to payload)).await()
}
/**
@ -85,11 +76,7 @@ class FirestoreDateReflectionDataSource @Inject constructor(
* Best-effort callers can still `runCatching { }.getOrDefault(false)`.
*/
suspend fun hasReflected(coupleId: String, dateId: String, userId: String): Boolean =
suspendCancellableCoroutine { cont ->
answerRef(coupleId, dateId, userId).get()
.addOnSuccessListener { cont.resume(it.exists()) }
.addOnFailureListener { cont.resumeWithException(it) }
}
answerRef(coupleId, dateId, userId).get().await().exists()
/** Live "has this user reflected?" — lets the reflection screen complete the reveal in real time. */
fun observeReflected(coupleId: String, dateId: String, userId: String): Flow<Boolean> = callbackFlow {
@ -106,11 +93,7 @@ class FirestoreDateReflectionDataSource @Inject constructor(
*/
suspend fun decryptReflectionFor(coupleId: String, dateId: String, userId: String): DateReflection? {
val payload = runCatching {
suspendCancellableCoroutine<String?> { cont ->
securePayloadRef(coupleId, dateId, userId).get()
.addOnSuccessListener { cont.resume(it.getString("encryptedPayload")) }
.addOnFailureListener { cont.resumeWithException(it) }
}
securePayloadRef(coupleId, dateId, userId).get().await().getString("encryptedPayload")
}.getOrNull() ?: return null
val aead = encryptionManager.aeadFor(coupleId) ?: return null
val json = fieldEncryptor.decrypt(payload, aead, coupleId) ?: return null
@ -130,11 +113,9 @@ class FirestoreDateReflectionDataSource @Inject constructor(
}
/** Flag my reflection revealed — drives the optional "[partner] opened your reflection" push. */
suspend fun markRevealed(coupleId: String, dateId: String, userId: String): Unit =
suspendCancellableCoroutine { cont ->
answerRef(coupleId, dateId, userId).update(mapOf("isRevealed" to true))
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resume(Unit) } // best-effort
suspend fun markRevealed(coupleId: String, dateId: String, userId: String) {
// Best-effort: a failed reveal flag just skips the optional push.
runCatching { answerRef(coupleId, dateId, userId).update(mapOf("isRevealed" to true)).await() }
}
private fun encode(r: DateReflection): String = JSONObject().apply {

View File

@ -19,11 +19,9 @@ import com.google.firebase.firestore.Query
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.tasks.await
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@Singleton
class FirestoreQuestionThreadDataSource @Inject constructor(
@ -48,7 +46,7 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
val snap = threadsRef(coupleId)
.whereEqualTo("questionId", questionId)
.limit(1)
.getAwait()
.get().await()
return snap.documents.firstOrNull()?.id
}
@ -65,7 +63,7 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
"createdAt" to now,
"updatedAt" to now
)
).voidAwait()
).await()
return doc.id
}
@ -81,7 +79,7 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
suspend fun updateThreadStatus(coupleId: String, threadId: String, status: QuestionThreadStatus) {
threadsRef(coupleId).document(threadId)
.update("status", status.toFirestoreValue())
.voidAwait()
.await()
}
// ─── Answers ─────────────────────────────────────────────────────────────────
@ -123,7 +121,7 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
"createdAt" to FieldValue.serverTimestamp(),
"updatedAt" to FieldValue.serverTimestamp()
)
).voidAwait()
).await()
}
// Call after releasing the one-time key so the answer doc reflects the released state.
@ -133,14 +131,14 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
.collection(FirestoreCollections.QuestionThreads.ANSWERS)
.document(userId)
.update(mapOf("answerKeyReleased" to true, "updatedAt" to FieldValue.serverTimestamp()))
.voidAwait()
.await()
}
suspend fun getAnswerCount(coupleId: String, threadId: String): Int {
val snap = threadsRef(coupleId)
.document(threadId)
.collection(FirestoreCollections.QuestionThreads.ANSWERS)
.getAwait()
.get().await()
return snap.size()
}
@ -169,7 +167,7 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
"text" to fieldEncryptor.encrypt(message.text, aead, coupleId),
"createdAt" to FieldValue.serverTimestamp()
)
).refAwait()
).await()
}
/**
@ -190,7 +188,7 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
"mediaUrl" to url,
"createdAt" to FieldValue.serverTimestamp()
)
).refAwait()
).await()
}
/** Downloads + decrypts an image message's bytes for display (couple key, on-device). */
@ -228,7 +226,7 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
"emoji" to reaction.emoji,
"createdAt" to FieldValue.serverTimestamp()
)
).voidAwait()
).await()
}
fun observeReactions(coupleId: String, threadId: String): Flow<List<QuestionReaction>> = callbackFlow {
@ -242,32 +240,6 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
awaitClose { listener.remove() }
}
// ─── Coroutine helpers ───────────────────────────────────────────────────────
private suspend fun com.google.firebase.firestore.CollectionReference.getAwait() =
get().queryAwait()
private suspend fun com.google.firebase.firestore.Query.getAwait() =
get().queryAwait()
private suspend fun com.google.android.gms.tasks.Task<com.google.firebase.firestore.QuerySnapshot>.queryAwait() =
suspendCancellableCoroutine { cont ->
addOnSuccessListener { cont.resume(it) }
addOnFailureListener { cont.resumeWithException(it) }
}
private suspend fun com.google.android.gms.tasks.Task<Void>.voidAwait() =
suspendCancellableCoroutine<Unit> { cont ->
addOnSuccessListener { cont.resume(Unit) }
addOnFailureListener { cont.resumeWithException(it) }
}
private suspend fun com.google.android.gms.tasks.Task<com.google.firebase.firestore.DocumentReference>.refAwait() =
suspendCancellableCoroutine<Unit> { cont ->
addOnSuccessListener { cont.resume(Unit) }
addOnFailureListener { cont.resumeWithException(it) }
}
// ─── Document mappers ────────────────────────────────────────────────────────
private fun DocumentSnapshot.toQuestionThread(coupleId: String) = QuestionThread(

View File

@ -10,11 +10,9 @@ import com.google.firebase.firestore.SetOptions
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.tasks.await
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@Singleton
class FirestoreUserDataSource @Inject constructor(
@ -44,11 +42,7 @@ class FirestoreUserDataSource @Inject constructor(
}
private suspend fun getSnapshot(uid: String): DocumentSnapshot? =
suspendCancellableCoroutine { cont ->
userRef(uid).get()
.addOnSuccessListener { cont.resume(it.takeIf { s -> s.exists() }) }
.addOnFailureListener { cont.resumeWithException(it) }
}
userRef(uid).get().await().takeIf { it.exists() }
private fun snapshotToUser(id: String, data: DocumentSnapshot): User {
val coupleId = data.getString("coupleId")
@ -67,14 +61,9 @@ class FirestoreUserDataSource @Inject constructor(
)
}
suspend fun getUser(uid: String): User? =
suspendCancellableCoroutine { cont ->
userRef(uid).get()
.addOnSuccessListener { snap ->
if (!snap.exists()) { cont.resume(null); return@addOnSuccessListener }
cont.resume(snapshotToUser(snap.id, snap))
}
.addOnFailureListener { cont.resumeWithException(it) }
suspend fun getUser(uid: String): User? {
val snap = userRef(uid).get().await()
return if (snap.exists()) snapshotToUser(snap.id, snap) else null
}
fun observeUser(uid: String): Flow<User?> = callbackFlow {
@ -88,8 +77,7 @@ class FirestoreUserDataSource @Inject constructor(
awaitClose { listener.remove() }
}
suspend fun createUser(user: User): Unit =
suspendCancellableCoroutine { cont ->
suspend fun createUser(user: User) {
userRef(user.id).set(
mapOf(
"email" to user.email,
@ -103,9 +91,7 @@ class FirestoreUserDataSource @Inject constructor(
"createdAt" to user.createdAt,
"lastActiveAt" to user.lastActiveAt
)
)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
).await()
}
/** Age-gate DOB (O-AGE-001). Set once when a Google/legacy user has no birthDate yet. */
@ -126,14 +112,11 @@ class FirestoreUserDataSource @Inject constructor(
)
}
suspend fun updatePhotoUrl(uid: String, photoUrl: String): Unit =
suspendCancellableCoroutine { cont ->
suspend fun updatePhotoUrl(uid: String, photoUrl: String) {
userRef(uid).set(
mapOf("photoUrl" to photoUrl, "lastActiveAt" to System.currentTimeMillis()),
SetOptions.merge()
)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
).await()
}
suspend fun updateSex(uid: String, sex: String) {
@ -149,11 +132,8 @@ class FirestoreUserDataSource @Inject constructor(
)
}
private suspend fun setMerge(uid: String, data: Map<String, Any?>): Unit =
suspendCancellableCoroutine { cont ->
userRef(uid).set(data, SetOptions.merge())
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
private suspend fun setMerge(uid: String, data: Map<String, Any?>) {
userRef(uid).set(data, SetOptions.merge()).await()
}
/**
@ -198,31 +178,23 @@ class FirestoreUserDataSource @Inject constructor(
val ENCRYPTED_PROFILE_FIELDS = listOf("displayName", "sex")
}
suspend fun hasProfile(uid: String): Boolean =
suspendCancellableCoroutine { cont ->
userRef(uid).get()
.addOnSuccessListener { snap ->
val name = snap.getString("displayName") ?: ""
cont.resume(snap.exists() && name.isNotBlank())
}
.addOnFailureListener { cont.resume(false) }
}
suspend fun hasProfile(uid: String): Boolean = runCatching {
val snap = userRef(uid).get().await()
snap.exists() && !snap.getString("displayName").isNullOrBlank()
}.getOrDefault(false)
suspend fun storeFcmToken(uid: String, token: String): Unit =
suspendCancellableCoroutine { cont ->
suspend fun storeFcmToken(uid: String, token: String) {
userRef(uid).set(
mapOf("fcmToken" to token, "lastActiveAt" to System.currentTimeMillis()),
SetOptions.merge()
)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
).await()
}
suspend fun storeTokenMetadata(
uid: String,
token: String,
metadata: DeviceMetadata
): Unit = suspendCancellableCoroutine { cont ->
) {
userRef(uid).collection(FirestoreCollections.Users.FCM_TOKENS).document(token)
.set(
mapOf(
@ -234,15 +206,11 @@ class FirestoreUserDataSource @Inject constructor(
"updatedAt" to metadata.timestamp
)
)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
.await()
}
suspend fun clearCoupleId(uid: String): Unit =
suspendCancellableCoroutine { cont ->
userRef(uid).update(mapOf("coupleId" to null))
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
suspend fun clearCoupleId(uid: String) {
userRef(uid).update(mapOf("coupleId" to null)).await()
}
suspend fun updateNotificationPrefs(
@ -252,7 +220,7 @@ class FirestoreUserDataSource @Inject constructor(
dailyReminder: Boolean,
streakReminder: Boolean,
promotional: Boolean
): Unit = suspendCancellableCoroutine { cont ->
) {
userRef(uid).set(
mapOf(
"notifPartnerAnswered" to partnerAnswered,
@ -262,9 +230,7 @@ class FirestoreUserDataSource @Inject constructor(
"notifPromotional" to promotional
),
SetOptions.merge()
)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
).await()
}
/**
@ -279,7 +245,7 @@ class FirestoreUserDataSource @Inject constructor(
startMinutes: Int,
endMinutes: Int,
timezone: String
): Unit = suspendCancellableCoroutine { cont ->
) {
userRef(uid).set(
mapOf(
"quietHoursEnabled" to enabled,
@ -288,15 +254,10 @@ class FirestoreUserDataSource @Inject constructor(
"timezone" to timezone
),
SetOptions.merge()
)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
).await()
}
suspend fun deleteUserData(uid: String): Unit =
suspendCancellableCoroutine { cont ->
userRef(uid).delete()
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
suspend fun deleteUserData(uid: String) {
userRef(uid).delete().await()
}
}