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

View File

@ -11,7 +11,7 @@ import com.google.firebase.firestore.FirebaseFirestore
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.tasks.await
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.time.Clock import java.time.Clock
@ -21,8 +21,6 @@ import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton 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. * Syncs daily-question answers to Firestore under the couple's daily question doc.
@ -103,18 +101,9 @@ class FirestoreAnswerDataSource @Inject constructor(
"isRevealed" to false "isRevealed" to false
) )
// Encrypted content lives in the read-gated `secure` subdoc (no peek before both answer). // Encrypted content lives in the read-gated `secure` subdoc (no peek before both answer).
suspendCancellableCoroutine { cont ->
securePayloadRef(coupleId, date, userId) securePayloadRef(coupleId, date, userId)
.set(mapOf("encryptedPayload" to encryptedPayload)) .set(mapOf("encryptedPayload" to encryptedPayload)).await()
.addOnSuccessListener { cont.resume(Unit) } answerRef(coupleId, date, userId).set(metadata).await()
.addOnFailureListener { cont.resumeWithException(it) }
}
suspendCancellableCoroutine { cont ->
answerRef(coupleId, date, userId)
.set(metadata)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
} }
private fun securePayloadRef(coupleId: String, date: String, userId: String) = 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. */ /** 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? { suspend fun decryptCoupleKeyAnswerFor(coupleId: String, date: String, userId: String): DecodedAnswer? {
val encryptedPayload = runCatching { val encryptedPayload = runCatching {
suspendCancellableCoroutine<String?> { cont -> securePayloadRef(coupleId, date, userId).get().await().getString("encryptedPayload")
securePayloadRef(coupleId, date, userId)
.get()
.addOnSuccessListener { snap -> cont.resume(snap.getString("encryptedPayload")) }
.addOnFailureListener { cont.resumeWithException(it) }
}
}.getOrNull() }.getOrNull()
return decryptCoupleKeyAnswer(coupleId, encryptedPayload) return decryptCoupleKeyAnswer(coupleId, encryptedPayload)
} }
/** Marks the caller's own answer revealed in Firestore — drives the partner-opened push. */ /** Marks the caller's own answer revealed in Firestore — drives the partner-opened push. */
suspend fun markRevealed(coupleId: String, date: String, userId: String): Unit = suspend fun markRevealed(coupleId: String, date: String, userId: String) {
suspendCancellableCoroutine { cont ->
answerRef(coupleId, date, userId) answerRef(coupleId, date, userId)
.update(mapOf("isRevealed" to true, "updatedAt" to System.currentTimeMillis())) .update(mapOf("isRevealed" to true, "updatedAt" to System.currentTimeMillis()))
.addOnSuccessListener { cont.resume(Unit) } .await()
.addOnFailureListener { cont.resumeWithException(it) }
} }
/** Decrypts a couple-key (enc:v1:) answer payload. Returns null if the key/payload is unavailable. */ /** 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 "isRevealed" to answer.isRevealed
) )
suspendCancellableCoroutine { cont -> answerRef(coupleId, date, userId).set(data).await()
answerRef(coupleId, date, userId)
.set(data)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
} }
/** Updates the answerKeyReleased flag after the one-time key is sent to the partner. */ /** Updates the answerKeyReleased flag after the one-time key is sent to the partner. */
suspend fun markAnswerKeyReleased(coupleId: String, date: String, userId: String): Unit = suspend fun markAnswerKeyReleased(coupleId: String, date: String, userId: String) {
suspendCancellableCoroutine { cont ->
answerRef(coupleId, date, userId) answerRef(coupleId, date, userId)
// updatedAt is stored as epoch millis everywhere else; a serverTimestamp here made // updatedAt is stored as epoch millis everywhere else; a serverTimestamp here made
// it a Firestore Timestamp, which crashed toLocalAnswer's getLong("updatedAt") when // it a Firestore Timestamp, which crashed toLocalAnswer's getLong("updatedAt") when
// the partner read this answer during reveal. Keep it a Long. // the partner read this answer during reveal. Keep it a Long.
.update(mapOf("answerKeyReleased" to true, "updatedAt" to System.currentTimeMillis())) .update(mapOf("answerKeyReleased" to true, "updatedAt" to System.currentTimeMillis()))
.addOnSuccessListener { cont.resume(Unit) } .await()
.addOnFailureListener { cont.resumeWithException(it) }
} }
/** /**
@ -229,17 +204,9 @@ class FirestoreAnswerDataSource @Inject constructor(
coupleId: String, coupleId: String,
userId: String, userId: String,
date: String = todayLocalDateString() date: String = todayLocalDateString()
): LocalAnswer? = suspendCancellableCoroutine { cont -> ): LocalAnswer? {
answerRef(coupleId, date, userId) val snap = answerRef(coupleId, date, userId).get().await()
.get() return if (snap.exists()) snap.toLocalAnswer() else null
.addOnSuccessListener { snap ->
if (!snap.exists()) {
cont.resume(null)
return@addOnSuccessListener
}
cont.resume(snap.toLocalAnswer())
}
.addOnFailureListener { cont.resumeWithException(it) }
} }
/** /**
@ -263,39 +230,26 @@ class FirestoreAnswerDataSource @Inject constructor(
/** /**
* Reads the couple-scoped daily question assignment for today. * Reads the couple-scoped daily question assignment for today.
*/ */
suspend fun getDailyQuestionAssignment(coupleId: String, date: String = todayLocalDateString()): DailyQuestionAssignment? = suspend fun getDailyQuestionAssignment(coupleId: String, date: String = todayLocalDateString()): DailyQuestionAssignment? {
suspendCancellableCoroutine { cont -> val snap = dailyQuestionRef(coupleId, date).get().await()
dailyQuestionRef(coupleId, date) if (!snap.exists()) return null
.get() return DailyQuestionAssignment(
.addOnSuccessListener { snap ->
if (!snap.exists()) {
cont.resume(null)
return@addOnSuccessListener
}
cont.resume(
DailyQuestionAssignment(
questionId = snap.getString("questionId") ?: "", questionId = snap.getString("questionId") ?: "",
date = snap.getString("date") ?: date, date = snap.getString("date") ?: date,
assignedAt = snap.getTimestamp("assignedAt"), assignedAt = snap.getTimestamp("assignedAt"),
expiresAt = snap.getTimestamp("expiresAt") expiresAt = snap.getTimestamp("expiresAt")
) )
)
}
.addOnFailureListener { cont.resumeWithException(it) }
} }
/** /**
* Calls the cloud function to assign a daily question for the couple immediately. * 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. * Used when a couple is newly created and has no assignment yet.
*/ */
suspend fun requestDailyQuestionAssignment(coupleId: String, date: String = todayLocalDateString()): Unit = suspend fun requestDailyQuestionAssignment(coupleId: String, date: String = todayLocalDateString()) {
suspendCancellableCoroutine { cont -> com.google.firebase.functions.FirebaseFunctions.getInstance()
val functions = com.google.firebase.functions.FirebaseFunctions.getInstance()
functions
.getHttpsCallable("assignDailyQuestionCallable") .getHttpsCallable("assignDailyQuestionCallable")
.call(mapOf("coupleId" to coupleId, "date" to date)) .call(mapOf("coupleId" to coupleId, "date" to date))
.addOnSuccessListener { cont.resume(Unit) } .await()
.addOnFailureListener { cont.resumeWithException(it) }
} }
private fun com.google.firebase.firestore.DocumentSnapshot.toLocalAnswer(): LocalAnswer { private fun com.google.firebase.firestore.DocumentSnapshot.toLocalAnswer(): LocalAnswer {
@ -341,7 +295,7 @@ class FirestoreAnswerDataSource @Inject constructor(
partnerAnswer: String?, partnerAnswer: String?,
modeTag: String?, modeTag: String?,
date: String date: String
): Unit = suspendCancellableCoroutine { cont -> ) {
val aead = encryptionManager.requireAead(coupleId) val aead = encryptionManager.requireAead(coupleId)
val doc = mapOf( val doc = mapOf(
"questionId" to questionId, "questionId" to questionId,
@ -358,8 +312,7 @@ class FirestoreAnswerDataSource @Inject constructor(
.collection("lore") .collection("lore")
.document(questionId) .document(questionId)
.set(doc) .set(doc)
.addOnSuccessListener { cont.resume(Unit) } .await()
.addOnFailureListener { cont.resumeWithException(it) }
} }
private suspend fun ensureUserPublicKeyPublished(userId: String) { 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.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.tasks.await
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/** /**
* Firestore data source for couple bucket lists. * Firestore data source for couple bucket lists.
@ -50,7 +48,7 @@ class FirestoreBucketListDataSource @Inject constructor(
"completedAt" to item.completedAt, "completedAt" to item.completedAt,
"isCompleted" to item.isCompleted "isCompleted" to item.isCompleted
) )
doc.set(data, SetOptions.merge()).voidAwait() doc.set(data, SetOptions.merge()).await()
return doc.id return doc.id
} }
@ -65,11 +63,11 @@ class FirestoreBucketListDataSource @Inject constructor(
"completedAt" to item.completedAt, "completedAt" to item.completedAt,
"isCompleted" to item.isCompleted "isCompleted" to item.isCompleted
) )
path.set(data, SetOptions.merge()).voidAwait() path.set(data, SetOptions.merge()).await()
} }
suspend fun getItem(coupleId: String, itemId: String): BucketListItem? { 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) return snap.toBucketListItem(coupleId)
} }
@ -86,12 +84,12 @@ class FirestoreBucketListDataSource @Inject constructor(
} }
suspend fun getItems(coupleId: String): List<BucketListItem> { 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) } return snap.documents.mapNotNull { it.toBucketListItem(coupleId) }
} }
suspend fun deleteItem(coupleId: String, itemId: String) { 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) { suspend fun completeItem(coupleId: String, itemId: String, completedBy: String) {
@ -101,7 +99,7 @@ class FirestoreBucketListDataSource @Inject constructor(
"completedAt" to System.currentTimeMillis(), "completedAt" to System.currentTimeMillis(),
"isCompleted" to true "isCompleted" to true
) )
path.set(data, SetOptions.merge()).voidAwait() path.set(data, SetOptions.merge()).await()
} }
// ─── Category queries ──────────────────────────────────────────────────── // ─── Category queries ────────────────────────────────────────────────────
@ -123,32 +121,10 @@ class FirestoreBucketListDataSource @Inject constructor(
val snap = itemsRef(coupleId) val snap = itemsRef(coupleId)
.whereEqualTo("category", category) .whereEqualTo("category", category)
.orderBy("addedAt", com.google.firebase.firestore.Query.Direction.DESCENDING) .orderBy("addedAt", com.google.firebase.firestore.Query.Direction.DESCENDING)
.queryAwait() .get().await()
return snap.documents.mapNotNull { it.toBucketListItem(coupleId) } 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 ────────────────────────────────────────────────────────────── // ─── Mapper ──────────────────────────────────────────────────────────────
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")

View File

@ -13,11 +13,9 @@ import com.google.firebase.firestore.SetOptions
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.tasks.await
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/** /**
* Backs the Messages inbox + each conversation's chat. Every conversation lives under * Backs the Messages inbox + each conversation's chat. Every conversation lives under
@ -65,7 +63,7 @@ class FirestoreConversationDataSource @Inject constructor(
"createdAt" to FieldValue.serverTimestamp() "createdAt" to FieldValue.serverTimestamp()
), ),
SetOptions.merge() SetOptions.merge()
).voidAwait() ).await()
} }
/** Creates the per-question conversation if needed and returns its id. */ /** Creates the per-question conversation if needed and returns its id. */
@ -80,7 +78,7 @@ class FirestoreConversationDataSource @Inject constructor(
"createdAt" to FieldValue.serverTimestamp() "createdAt" to FieldValue.serverTimestamp()
), ),
SetOptions.merge() SetOptions.merge()
).voidAwait() ).await()
} }
return convId return convId
} }
@ -88,7 +86,7 @@ class FirestoreConversationDataSource @Inject constructor(
suspend fun markRead(coupleId: String, conversationId: String, userId: String) { suspend fun markRead(coupleId: String, conversationId: String, userId: String) {
conversationsRef(coupleId).document(conversationId) conversationsRef(coupleId).document(conversationId)
.set(mapOf("reads" to mapOf(userId to FieldValue.serverTimestamp())), SetOptions.merge()) .set(mapOf("reads" to mapOf(userId to FieldValue.serverTimestamp())), SetOptions.merge())
.voidAwait() .await()
} }
// ─── Messages ────────────────────────────────────────────────────────────────── // ─── Messages ──────────────────────────────────────────────────────────────────
@ -103,7 +101,7 @@ class FirestoreConversationDataSource @Inject constructor(
"text" to cipher, "text" to cipher,
"createdAt" to FieldValue.serverTimestamp() "createdAt" to FieldValue.serverTimestamp()
) )
).refAwait() ).await()
updateLastMessage(coupleId, conversationId, userId, cipher) updateLastMessage(coupleId, conversationId, userId, cipher)
} }
@ -118,7 +116,7 @@ class FirestoreConversationDataSource @Inject constructor(
"mediaUrl" to url, "mediaUrl" to url,
"createdAt" to FieldValue.serverTimestamp() "createdAt" to FieldValue.serverTimestamp()
) )
).refAwait() ).await()
// Preview a photo with a fixed (still encrypted) label so the inbox decrypt path is uniform. // 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)) updateLastMessage(coupleId, conversationId, userId, fieldEncryptor.encrypt("📷 Photo", aead, coupleId))
} }
@ -135,7 +133,7 @@ class FirestoreConversationDataSource @Inject constructor(
"durationMs" to durationMs, "durationMs" to durationMs,
"createdAt" to FieldValue.serverTimestamp() "createdAt" to FieldValue.serverTimestamp()
) )
).refAwait() ).await()
updateLastMessage(coupleId, conversationId, userId, fieldEncryptor.encrypt("🎤 Voice message", aead, coupleId)) updateLastMessage(coupleId, conversationId, userId, fieldEncryptor.encrypt("🎤 Voice message", aead, coupleId))
} }
@ -147,7 +145,7 @@ class FirestoreConversationDataSource @Inject constructor(
"lastMessageSenderId" to userId "lastMessageSenderId" to userId
), ),
SetOptions.merge() 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?) { suspend fun setReaction(coupleId: String, conversationId: String, messageId: String, userId: String, emoji: String?) {
val ref = messagesRef(coupleId, conversationId).document(messageId) val ref = messagesRef(coupleId, conversationId).document(messageId)
if (emoji == null) { if (emoji == null) {
ref.update("reactions.$userId", FieldValue.delete()).voidAwait() ref.update("reactions.$userId", FieldValue.delete()).await()
} else { } 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. */ /** 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) { 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 { val latest = runCatching {
messagesRef(coupleId, conversationId) messagesRef(coupleId, conversationId)
.orderBy("createdAt", Query.Direction.DESCENDING).limit(1).get().await() .orderBy("createdAt", Query.Direction.DESCENDING).limit(1).get().await()
@ -189,7 +187,7 @@ class FirestoreConversationDataSource @Inject constructor(
conversationsRef(coupleId).document(conversationId).set( conversationsRef(coupleId).document(conversationId).set(
mapOf("lastMessagePreview" to fieldEncryptor.encrypt("Message deleted", aead, coupleId)), mapOf("lastMessagePreview" to fieldEncryptor.encrypt("Message deleted", aead, coupleId)),
SetOptions.merge() 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.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.tasks.await import kotlinx.coroutines.tasks.await
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/** /**
* Firestore data source for revealed mutual date matches. * Firestore data source for revealed mutual date matches.
@ -67,7 +64,7 @@ class FirestoreDateMatchDataSource @Inject constructor(private val db: FirebaseF
val snap = matchesRef(coupleId) val snap = matchesRef(coupleId)
.whereEqualTo("dateIdeaId", dateIdeaId) .whereEqualTo("dateIdeaId", dateIdeaId)
.limit(1) .limit(1)
.queryAwait() .get().await()
return snap.documents.firstOrNull()?.toDateMatch(coupleId) return snap.documents.firstOrNull()?.toDateMatch(coupleId)
} }
@ -81,15 +78,6 @@ class FirestoreDateMatchDataSource @Inject constructor(private val db: FirebaseF
awaitClose { listener.remove() } 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 ────────────────────────────────────────────────────────────── // ─── Mapper ──────────────────────────────────────────────────────────────
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")

View File

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

View File

@ -11,12 +11,10 @@ import org.json.JSONArray
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.suspendCancellableCoroutine
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/** /**
* Firestore data source for date plan preferences and plans. * Firestore data source for date plan preferences and plans.
@ -57,7 +55,7 @@ class FirestoreDatePlanDataSource @Inject constructor(
"createdAt" to preference.createdAt, "createdAt" to preference.createdAt,
"updatedAt" to preference.updatedAt "updatedAt" to preference.updatedAt
) )
path.set(data, SetOptions.merge()).voidAwait() path.set(data, SetOptions.merge()).await()
} }
suspend fun getPreference(coupleId: String, dateIdeaId: String): DatePlanPreference? { suspend fun getPreference(coupleId: String, dateIdeaId: String): DatePlanPreference? {
@ -65,7 +63,7 @@ class FirestoreDatePlanDataSource @Inject constructor(
val snap = preferencesRef(coupleId) val snap = preferencesRef(coupleId)
.whereEqualTo("dateIdeaId", dateIdeaId) .whereEqualTo("dateIdeaId", dateIdeaId)
.limit(1) .limit(1)
.queryAwait() .get().await()
return snap.documents.firstOrNull()?.toDatePlanPreference(coupleId) return snap.documents.firstOrNull()?.toDatePlanPreference(coupleId)
} }
@ -84,13 +82,13 @@ class FirestoreDatePlanDataSource @Inject constructor(
suspend fun createPlan(coupleId: String, plan: DatePlan): String { suspend fun createPlan(coupleId: String, plan: DatePlan): String {
val doc = plansRef(coupleId).document() 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 return doc.id
} }
suspend fun updatePlan(coupleId: String, plan: DatePlan) { suspend fun updatePlan(coupleId: String, plan: DatePlan) {
val path = plansRef(coupleId).document(plan.id) 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? { 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) return snap.toDatePlan(coupleId)
} }
@ -137,34 +135,12 @@ class FirestoreDatePlanDataSource @Inject constructor(
val snap = plansRef(coupleId) val snap = plansRef(coupleId)
.whereEqualTo("dateIdeaId", dateIdeaId) .whereEqualTo("dateIdeaId", dateIdeaId)
.limit(1) .limit(1)
.queryAwait() .get().await()
return snap.documents.firstOrNull()?.toDatePlan(coupleId) return snap.documents.firstOrNull()?.toDatePlan(coupleId)
} }
suspend fun deletePlan(coupleId: String, planId: String) { suspend fun deletePlan(coupleId: String, planId: String) {
plansRef(coupleId).document(planId).delete().voidAwait() plansRef(coupleId).document(planId).delete().await()
}
// ─── 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) }
} }
// ─── Mappers ───────────────────────────────────────────────────────────── // ─── Mappers ─────────────────────────────────────────────────────────────

View File

@ -7,12 +7,10 @@ import com.google.firebase.firestore.FirebaseFirestore
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.tasks.await
import org.json.JSONObject import org.json.JSONObject
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/** /**
* Post-date reflections mirrors the daily-question couple-key reveal: * Post-date reflections mirrors the daily-question couple-key reveal:
@ -55,11 +53,7 @@ class FirestoreDateReflectionDataSource @Inject constructor(
"isRevealed" to false "isRevealed" to false
) )
) )
suspendCancellableCoroutine<Unit> { cont -> batch.commit().await()
batch.commit()
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
} }
/** /**
@ -71,10 +65,7 @@ class FirestoreDateReflectionDataSource @Inject constructor(
val aead = encryptionManager.aeadFor(coupleId) val aead = encryptionManager.aeadFor(coupleId)
?: throw IllegalStateException("Couple key unavailable for $coupleId") ?: throw IllegalStateException("Couple key unavailable for $coupleId")
val payload = fieldEncryptor.encrypt(encode(reflection), aead, coupleId) val payload = fieldEncryptor.encrypt(encode(reflection), aead, coupleId)
suspendCancellableCoroutine<Unit> { cont -> securePayloadRef(coupleId, dateId, userId).set(mapOf("encryptedPayload" to payload)).await()
securePayloadRef(coupleId, dateId, userId).set(mapOf("encryptedPayload" to payload))
.addOnSuccessListener { cont.resume(Unit) }.addOnFailureListener { cont.resumeWithException(it) }
}
} }
/** /**
@ -85,11 +76,7 @@ class FirestoreDateReflectionDataSource @Inject constructor(
* Best-effort callers can still `runCatching { }.getOrDefault(false)`. * Best-effort callers can still `runCatching { }.getOrDefault(false)`.
*/ */
suspend fun hasReflected(coupleId: String, dateId: String, userId: String): Boolean = suspend fun hasReflected(coupleId: String, dateId: String, userId: String): Boolean =
suspendCancellableCoroutine { cont -> answerRef(coupleId, dateId, userId).get().await().exists()
answerRef(coupleId, dateId, userId).get()
.addOnSuccessListener { cont.resume(it.exists()) }
.addOnFailureListener { cont.resumeWithException(it) }
}
/** Live "has this user reflected?" — lets the reflection screen complete the reveal in real time. */ /** 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 { 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? { suspend fun decryptReflectionFor(coupleId: String, dateId: String, userId: String): DateReflection? {
val payload = runCatching { val payload = runCatching {
suspendCancellableCoroutine<String?> { cont -> securePayloadRef(coupleId, dateId, userId).get().await().getString("encryptedPayload")
securePayloadRef(coupleId, dateId, userId).get()
.addOnSuccessListener { cont.resume(it.getString("encryptedPayload")) }
.addOnFailureListener { cont.resumeWithException(it) }
}
}.getOrNull() ?: return null }.getOrNull() ?: return null
val aead = encryptionManager.aeadFor(coupleId) ?: return null val aead = encryptionManager.aeadFor(coupleId) ?: return null
val json = fieldEncryptor.decrypt(payload, aead, 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. */ /** Flag my reflection revealed — drives the optional "[partner] opened your reflection" push. */
suspend fun markRevealed(coupleId: String, dateId: String, userId: String): Unit = suspend fun markRevealed(coupleId: String, dateId: String, userId: String) {
suspendCancellableCoroutine { cont -> // Best-effort: a failed reveal flag just skips the optional push.
answerRef(coupleId, dateId, userId).update(mapOf("isRevealed" to true)) runCatching { answerRef(coupleId, dateId, userId).update(mapOf("isRevealed" to true)).await() }
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resume(Unit) } // best-effort
} }
private fun encode(r: DateReflection): String = JSONObject().apply { 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.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.tasks.await
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@Singleton @Singleton
class FirestoreQuestionThreadDataSource @Inject constructor( class FirestoreQuestionThreadDataSource @Inject constructor(
@ -48,7 +46,7 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
val snap = threadsRef(coupleId) val snap = threadsRef(coupleId)
.whereEqualTo("questionId", questionId) .whereEqualTo("questionId", questionId)
.limit(1) .limit(1)
.getAwait() .get().await()
return snap.documents.firstOrNull()?.id return snap.documents.firstOrNull()?.id
} }
@ -65,7 +63,7 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
"createdAt" to now, "createdAt" to now,
"updatedAt" to now "updatedAt" to now
) )
).voidAwait() ).await()
return doc.id return doc.id
} }
@ -81,7 +79,7 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
suspend fun updateThreadStatus(coupleId: String, threadId: String, status: QuestionThreadStatus) { suspend fun updateThreadStatus(coupleId: String, threadId: String, status: QuestionThreadStatus) {
threadsRef(coupleId).document(threadId) threadsRef(coupleId).document(threadId)
.update("status", status.toFirestoreValue()) .update("status", status.toFirestoreValue())
.voidAwait() .await()
} }
// ─── Answers ───────────────────────────────────────────────────────────────── // ─── Answers ─────────────────────────────────────────────────────────────────
@ -123,7 +121,7 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
"createdAt" to FieldValue.serverTimestamp(), "createdAt" to FieldValue.serverTimestamp(),
"updatedAt" to FieldValue.serverTimestamp() "updatedAt" to FieldValue.serverTimestamp()
) )
).voidAwait() ).await()
} }
// Call after releasing the one-time key so the answer doc reflects the released state. // 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) .collection(FirestoreCollections.QuestionThreads.ANSWERS)
.document(userId) .document(userId)
.update(mapOf("answerKeyReleased" to true, "updatedAt" to FieldValue.serverTimestamp())) .update(mapOf("answerKeyReleased" to true, "updatedAt" to FieldValue.serverTimestamp()))
.voidAwait() .await()
} }
suspend fun getAnswerCount(coupleId: String, threadId: String): Int { suspend fun getAnswerCount(coupleId: String, threadId: String): Int {
val snap = threadsRef(coupleId) val snap = threadsRef(coupleId)
.document(threadId) .document(threadId)
.collection(FirestoreCollections.QuestionThreads.ANSWERS) .collection(FirestoreCollections.QuestionThreads.ANSWERS)
.getAwait() .get().await()
return snap.size() return snap.size()
} }
@ -169,7 +167,7 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
"text" to fieldEncryptor.encrypt(message.text, aead, coupleId), "text" to fieldEncryptor.encrypt(message.text, aead, coupleId),
"createdAt" to FieldValue.serverTimestamp() "createdAt" to FieldValue.serverTimestamp()
) )
).refAwait() ).await()
} }
/** /**
@ -190,7 +188,7 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
"mediaUrl" to url, "mediaUrl" to url,
"createdAt" to FieldValue.serverTimestamp() "createdAt" to FieldValue.serverTimestamp()
) )
).refAwait() ).await()
} }
/** Downloads + decrypts an image message's bytes for display (couple key, on-device). */ /** 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, "emoji" to reaction.emoji,
"createdAt" to FieldValue.serverTimestamp() "createdAt" to FieldValue.serverTimestamp()
) )
).voidAwait() ).await()
} }
fun observeReactions(coupleId: String, threadId: String): Flow<List<QuestionReaction>> = callbackFlow { fun observeReactions(coupleId: String, threadId: String): Flow<List<QuestionReaction>> = callbackFlow {
@ -242,32 +240,6 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
awaitClose { listener.remove() } 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 ──────────────────────────────────────────────────────── // ─── Document mappers ────────────────────────────────────────────────────────
private fun DocumentSnapshot.toQuestionThread(coupleId: String) = QuestionThread( 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.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.tasks.await
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@Singleton @Singleton
class FirestoreUserDataSource @Inject constructor( class FirestoreUserDataSource @Inject constructor(
@ -44,11 +42,7 @@ class FirestoreUserDataSource @Inject constructor(
} }
private suspend fun getSnapshot(uid: String): DocumentSnapshot? = private suspend fun getSnapshot(uid: String): DocumentSnapshot? =
suspendCancellableCoroutine { cont -> userRef(uid).get().await().takeIf { it.exists() }
userRef(uid).get()
.addOnSuccessListener { cont.resume(it.takeIf { s -> s.exists() }) }
.addOnFailureListener { cont.resumeWithException(it) }
}
private fun snapshotToUser(id: String, data: DocumentSnapshot): User { private fun snapshotToUser(id: String, data: DocumentSnapshot): User {
val coupleId = data.getString("coupleId") val coupleId = data.getString("coupleId")
@ -67,14 +61,9 @@ class FirestoreUserDataSource @Inject constructor(
) )
} }
suspend fun getUser(uid: String): User? = suspend fun getUser(uid: String): User? {
suspendCancellableCoroutine { cont -> val snap = userRef(uid).get().await()
userRef(uid).get() return if (snap.exists()) snapshotToUser(snap.id, snap) else null
.addOnSuccessListener { snap ->
if (!snap.exists()) { cont.resume(null); return@addOnSuccessListener }
cont.resume(snapshotToUser(snap.id, snap))
}
.addOnFailureListener { cont.resumeWithException(it) }
} }
fun observeUser(uid: String): Flow<User?> = callbackFlow { fun observeUser(uid: String): Flow<User?> = callbackFlow {
@ -88,8 +77,7 @@ class FirestoreUserDataSource @Inject constructor(
awaitClose { listener.remove() } awaitClose { listener.remove() }
} }
suspend fun createUser(user: User): Unit = suspend fun createUser(user: User) {
suspendCancellableCoroutine { cont ->
userRef(user.id).set( userRef(user.id).set(
mapOf( mapOf(
"email" to user.email, "email" to user.email,
@ -103,9 +91,7 @@ class FirestoreUserDataSource @Inject constructor(
"createdAt" to user.createdAt, "createdAt" to user.createdAt,
"lastActiveAt" to user.lastActiveAt "lastActiveAt" to user.lastActiveAt
) )
) ).await()
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
} }
/** Age-gate DOB (O-AGE-001). Set once when a Google/legacy user has no birthDate yet. */ /** 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 = suspend fun updatePhotoUrl(uid: String, photoUrl: String) {
suspendCancellableCoroutine { cont ->
userRef(uid).set( userRef(uid).set(
mapOf("photoUrl" to photoUrl, "lastActiveAt" to System.currentTimeMillis()), mapOf("photoUrl" to photoUrl, "lastActiveAt" to System.currentTimeMillis()),
SetOptions.merge() SetOptions.merge()
) ).await()
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
} }
suspend fun updateSex(uid: String, sex: String) { 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 = private suspend fun setMerge(uid: String, data: Map<String, Any?>) {
suspendCancellableCoroutine { cont -> userRef(uid).set(data, SetOptions.merge()).await()
userRef(uid).set(data, SetOptions.merge())
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
} }
/** /**
@ -198,31 +178,23 @@ class FirestoreUserDataSource @Inject constructor(
val ENCRYPTED_PROFILE_FIELDS = listOf("displayName", "sex") val ENCRYPTED_PROFILE_FIELDS = listOf("displayName", "sex")
} }
suspend fun hasProfile(uid: String): Boolean = suspend fun hasProfile(uid: String): Boolean = runCatching {
suspendCancellableCoroutine { cont -> val snap = userRef(uid).get().await()
userRef(uid).get() snap.exists() && !snap.getString("displayName").isNullOrBlank()
.addOnSuccessListener { snap -> }.getOrDefault(false)
val name = snap.getString("displayName") ?: ""
cont.resume(snap.exists() && name.isNotBlank())
}
.addOnFailureListener { cont.resume(false) }
}
suspend fun storeFcmToken(uid: String, token: String): Unit = suspend fun storeFcmToken(uid: String, token: String) {
suspendCancellableCoroutine { cont ->
userRef(uid).set( userRef(uid).set(
mapOf("fcmToken" to token, "lastActiveAt" to System.currentTimeMillis()), mapOf("fcmToken" to token, "lastActiveAt" to System.currentTimeMillis()),
SetOptions.merge() SetOptions.merge()
) ).await()
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
} }
suspend fun storeTokenMetadata( suspend fun storeTokenMetadata(
uid: String, uid: String,
token: String, token: String,
metadata: DeviceMetadata metadata: DeviceMetadata
): Unit = suspendCancellableCoroutine { cont -> ) {
userRef(uid).collection(FirestoreCollections.Users.FCM_TOKENS).document(token) userRef(uid).collection(FirestoreCollections.Users.FCM_TOKENS).document(token)
.set( .set(
mapOf( mapOf(
@ -234,15 +206,11 @@ class FirestoreUserDataSource @Inject constructor(
"updatedAt" to metadata.timestamp "updatedAt" to metadata.timestamp
) )
) )
.addOnSuccessListener { cont.resume(Unit) } .await()
.addOnFailureListener { cont.resumeWithException(it) }
} }
suspend fun clearCoupleId(uid: String): Unit = suspend fun clearCoupleId(uid: String) {
suspendCancellableCoroutine { cont -> userRef(uid).update(mapOf("coupleId" to null)).await()
userRef(uid).update(mapOf("coupleId" to null))
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
} }
suspend fun updateNotificationPrefs( suspend fun updateNotificationPrefs(
@ -252,7 +220,7 @@ class FirestoreUserDataSource @Inject constructor(
dailyReminder: Boolean, dailyReminder: Boolean,
streakReminder: Boolean, streakReminder: Boolean,
promotional: Boolean promotional: Boolean
): Unit = suspendCancellableCoroutine { cont -> ) {
userRef(uid).set( userRef(uid).set(
mapOf( mapOf(
"notifPartnerAnswered" to partnerAnswered, "notifPartnerAnswered" to partnerAnswered,
@ -262,9 +230,7 @@ class FirestoreUserDataSource @Inject constructor(
"notifPromotional" to promotional "notifPromotional" to promotional
), ),
SetOptions.merge() SetOptions.merge()
) ).await()
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
} }
/** /**
@ -279,7 +245,7 @@ class FirestoreUserDataSource @Inject constructor(
startMinutes: Int, startMinutes: Int,
endMinutes: Int, endMinutes: Int,
timezone: String timezone: String
): Unit = suspendCancellableCoroutine { cont -> ) {
userRef(uid).set( userRef(uid).set(
mapOf( mapOf(
"quietHoursEnabled" to enabled, "quietHoursEnabled" to enabled,
@ -288,15 +254,10 @@ class FirestoreUserDataSource @Inject constructor(
"timezone" to timezone "timezone" to timezone
), ),
SetOptions.merge() SetOptions.merge()
) ).await()
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
} }
suspend fun deleteUserData(uid: String): Unit = suspend fun deleteUserData(uid: String) {
suspendCancellableCoroutine { cont -> userRef(uid).delete().await()
userRef(uid).delete()
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
} }
} }