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.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,43 +55,21 @@ 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) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends a password-reset email, translating Firebase specifics into typed [PasswordResetException]s
|
* Sends a password-reset email, translating Firebase specifics into typed [PasswordResetException]s
|
||||||
|
|
@ -116,12 +92,9 @@ 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) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Changes the signed-in user's password: re-authenticates with [currentPassword] (Firebase requires
|
* Changes the signed-in user's password: re-authenticates with [currentPassword] (Firebase requires
|
||||||
|
|
@ -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)
|
val result = auth.signInWithCredential(credential).await()
|
||||||
auth.signInWithCredential(credential)
|
val user = auth.currentUser ?: result.user
|
||||||
.addOnSuccessListener { result ->
|
return GoogleSignInResult(
|
||||||
val user = auth.currentUser ?: result.user
|
uid = user?.uid ?: "",
|
||||||
cont.resume(
|
displayName = user?.displayName ?: "",
|
||||||
GoogleSignInResult(
|
photoUrl = user?.photoUrl?.toString() ?: "",
|
||||||
uid = user?.uid ?: "",
|
email = user?.email ?: "",
|
||||||
displayName = user?.displayName ?: "",
|
isAnonymous = user?.isAnonymous ?: false
|
||||||
photoUrl = user?.photoUrl?.toString() ?: "",
|
)
|
||||||
email = user?.email ?: "",
|
}
|
||||||
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"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)).await()
|
||||||
.set(mapOf("encryptedPayload" to encryptedPayload))
|
answerRef(coupleId, date, userId).set(metadata).await()
|
||||||
.addOnSuccessListener { cont.resume(Unit) }
|
|
||||||
.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,24 +112,17 @@ 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()))
|
.await()
|
||||||
.addOnSuccessListener { cont.resume(Unit) }
|
}
|
||||||
.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. */
|
||||||
fun decryptCoupleKeyAnswer(coupleId: String, encryptedPayload: String?): DecodedAnswer? {
|
fun decryptCoupleKeyAnswer(coupleId: String, encryptedPayload: String?): DecodedAnswer? {
|
||||||
|
|
@ -202,25 +184,18 @@ 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()))
|
.await()
|
||||||
.addOnSuccessListener { cont.resume(Unit) }
|
}
|
||||||
.addOnFailureListener { cont.resumeWithException(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches a partner's answer for the current local date.
|
* Fetches a partner's answer for the current local date.
|
||||||
|
|
@ -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,40 +230,27 @@ 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 ->
|
questionId = snap.getString("questionId") ?: "",
|
||||||
if (!snap.exists()) {
|
date = snap.getString("date") ?: date,
|
||||||
cont.resume(null)
|
assignedAt = snap.getTimestamp("assignedAt"),
|
||||||
return@addOnSuccessListener
|
expiresAt = snap.getTimestamp("expiresAt")
|
||||||
}
|
)
|
||||||
cont.resume(
|
}
|
||||||
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.
|
* 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()
|
.getHttpsCallable("assignDailyQuestionCallable")
|
||||||
functions
|
.call(mapOf("coupleId" to coupleId, "date" to date))
|
||||||
.getHttpsCallable("assignDailyQuestionCallable")
|
.await()
|
||||||
.call(mapOf("coupleId" to coupleId, "date" to date))
|
}
|
||||||
.addOnSuccessListener { cont.resume(Unit) }
|
|
||||||
.addOnFailureListener { cont.resumeWithException(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun com.google.firebase.firestore.DocumentSnapshot.toLocalAnswer(): LocalAnswer {
|
private fun com.google.firebase.firestore.DocumentSnapshot.toLocalAnswer(): LocalAnswer {
|
||||||
// All answers are sealed (schemaVersion 3): content lives in encryptedPayload and is
|
// All answers are sealed (schemaVersion 3): content lives in encryptedPayload and is
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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,19 +33,18 @@ 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,
|
"title" to memory.title,
|
||||||
"title" to memory.title,
|
"category" to memory.category,
|
||||||
"category" to memory.category,
|
"completedAt" to memory.completedAt,
|
||||||
"completedAt" to memory.completedAt,
|
"addedBy" to memory.addedBy
|
||||||
"addedBy" to memory.addedBy
|
),
|
||||||
),
|
SetOptions.merge()
|
||||||
SetOptions.merge()
|
).await()
|
||||||
).addOnSuccessListener { cont.resume(Unit) }.addOnFailureListener { cont.resumeWithException(it) }
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fun observeHistory(coupleId: String): Flow<List<DateMemory>> = callbackFlow {
|
fun observeHistory(coupleId: String): Flow<List<DateMemory>> = callbackFlow {
|
||||||
val reg = historyRef(coupleId)
|
val reg = historyRef(coupleId)
|
||||||
|
|
@ -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) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,36 +135,14 @@ 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 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun com.google.firebase.firestore.DocumentSnapshot.toDatePlanPreference(coupleId: String): DatePlanPreference? {
|
private fun com.google.firebase.firestore.DocumentSnapshot.toDatePlanPreference(coupleId: String): DatePlanPreference? {
|
||||||
|
|
|
||||||
|
|
@ -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,12 +113,10 @@ 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 {
|
||||||
put("favoriteMoment", r.favoriteMoment)
|
put("favoriteMoment", r.favoriteMoment)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,15 +61,10 @@ 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 {
|
||||||
val listener = userRef(uid).addSnapshotListener { snap, error ->
|
val listener = userRef(uid).addSnapshotListener { snap, error ->
|
||||||
|
|
@ -88,25 +77,22 @@ 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,
|
"displayName" to encryptProfileField(user.displayName, user.coupleId),
|
||||||
"displayName" to encryptProfileField(user.displayName, user.coupleId),
|
"photoUrl" to user.photoUrl,
|
||||||
"photoUrl" to user.photoUrl,
|
"sex" to encryptProfileField(user.sex, user.coupleId),
|
||||||
"sex" to encryptProfileField(user.sex, user.coupleId),
|
"partnerId" to user.partnerId,
|
||||||
"partnerId" to user.partnerId,
|
"coupleId" to user.coupleId,
|
||||||
"coupleId" to user.coupleId,
|
"plan" to user.plan,
|
||||||
"plan" to user.plan,
|
"birthDate" to user.birthDate,
|
||||||
"birthDate" to user.birthDate,
|
"createdAt" to user.createdAt,
|
||||||
"createdAt" to user.createdAt,
|
"lastActiveAt" to user.lastActiveAt
|
||||||
"lastActiveAt" to user.lastActiveAt
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
.addOnSuccessListener { cont.resume(Unit) }
|
).await()
|
||||||
.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. */
|
||||||
suspend fun updateBirthDate(uid: String, birthDateMillis: Long): Unit =
|
suspend fun updateBirthDate(uid: String, birthDateMillis: Long): Unit =
|
||||||
|
|
@ -126,15 +112,12 @@ 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) {
|
||||||
// Never write the locked placeholder back as the value (would corrupt + then encrypt it).
|
// Never write the locked placeholder back as the value (would corrupt + then encrypt it).
|
||||||
|
|
@ -149,12 +132,9 @@ 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) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encrypt any still-plaintext profile fields once the couple key is available (idempotent — skips
|
* Encrypt any still-plaintext profile fields once the couple key is available (idempotent — skips
|
||||||
|
|
@ -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,16 +206,12 @@ 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(
|
||||||
uid: String,
|
uid: String,
|
||||||
|
|
@ -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) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue