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:
parent
f160f141d6
commit
fcc0d699fb
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue