refactor(data): modernize Firebase Tasks to .await() (safe subset)

Replace verbose suspendCancellableCoroutine{ cont -> task.addOnSuccessListener
{cont.resume}.addOnFailureListener{cont.resumeWithException} } wrappers with the
kotlinx-coroutines-play-services .await() extension in the files whose blocks are
all one-shot Tasks (no snapshot listeners / transactions to endanger):
FirestoreOutcomeDataSource, FirestoreCoupleDataSource, PlayIntegrityChecker.

Notably fixes FirestoreOutcomeDataSource, which mixed both styles in one file.
PlayIntegrityChecker.verifyWithServer keeps its failure→null semantics via
runCatching{ ... .await() }.getOrNull() (a bare await would throw instead).

The remaining ~11 data sources also use the callback pattern but mix in
callbackFlow snapshot listeners / transactions, so they need per-block review —
tracked in Future.md rather than bulk-rewritten on untested I/O.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-06 21:36:09 -05:00
parent 61a8609298
commit 7507ca63e4
3 changed files with 55 additions and 98 deletions

View File

@ -8,12 +8,9 @@ import com.google.firebase.firestore.DocumentSnapshot
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.SetOptions
import com.google.firebase.functions.FirebaseFunctions
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 FirestoreCoupleDataSource @Inject constructor(
@ -45,7 +42,7 @@ class FirestoreCoupleDataSource @Inject constructor(
inviteCode: String,
now: Long,
wrappedKey: RecoveryKeyManager.WrappedKey
): Unit = suspendCancellableCoroutine { cont ->
) {
val data = mutableMapOf<String, Any>(
"id" to coupleId,
"userIds" to listOf(inviterUserId, acceptorUserId),
@ -57,51 +54,31 @@ class FirestoreCoupleDataSource @Inject constructor(
data["wrappedCoupleKey"] = wrappedKey.cipherB64
data["kdfSalt"] = wrappedKey.saltB64
data["kdfParams"] = wrappedKey.params
coupleRef(coupleId).set(data)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
coupleRef(coupleId).set(data).await()
}
suspend fun updateWrappedKey(coupleId: String, wrappedKey: RecoveryKeyManager.WrappedKey): Unit =
suspendCancellableCoroutine { cont ->
coupleRef(coupleId).set(
mapOf(
"wrappedCoupleKey" to wrappedKey.cipherB64,
"kdfSalt" to wrappedKey.saltB64,
"kdfParams" to wrappedKey.params
),
SetOptions.merge()
)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
suspend fun updateWrappedKey(coupleId: String, wrappedKey: RecoveryKeyManager.WrappedKey) {
coupleRef(coupleId).set(
mapOf(
"wrappedCoupleKey" to wrappedKey.cipherB64,
"kdfSalt" to wrappedKey.saltB64,
"kdfParams" to wrappedKey.params
),
SetOptions.merge()
).await()
}
private suspend fun updateUserCoupleId(uid: String, coupleId: String): Unit =
suspendCancellableCoroutine { cont ->
userRef(uid).set(
mapOf("coupleId" to coupleId),
SetOptions.merge()
)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
private suspend fun updateUserCoupleId(uid: String, coupleId: String) {
userRef(uid).set(mapOf("coupleId" to coupleId), SetOptions.merge()).await()
}
suspend fun getCoupleById(coupleId: String): Couple? =
suspendCancellableCoroutine { cont ->
coupleRef(coupleId).get()
.addOnSuccessListener { snap ->
if (!snap.exists()) { cont.resume(null); return@addOnSuccessListener }
cont.resume(snap.toCouple())
}
.addOnFailureListener { cont.resumeWithException(it) }
}
suspend fun getCoupleById(coupleId: String): Couple? {
val snap = coupleRef(coupleId).get().await()
return if (snap.exists()) snap.toCouple() else null
}
suspend fun updateStreak(coupleId: String) {
val snap = suspendCancellableCoroutine<DocumentSnapshot> { cont ->
coupleRef(coupleId).get()
.addOnSuccessListener { cont.resume(it) }
.addOnFailureListener { cont.resumeWithException(it) }
}
val snap = coupleRef(coupleId).get().await()
val lastAnsweredAt = snap.millisOrNull("lastAnsweredAt") ?: 0L
val now = System.currentTimeMillis()
val current = (snap.getLong("streakCount") ?: 0L).toInt()
@ -114,14 +91,10 @@ class FirestoreCoupleDataSource @Inject constructor(
)
// Same-day answer → nothing to change; skip the redundant write.
if (!next.changed) return
suspendCancellableCoroutine<Unit> { cont ->
coupleRef(coupleId).set(
mapOf("streakCount" to next.newStreak, "lastAnsweredAt" to now),
SetOptions.merge()
)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
coupleRef(coupleId).set(
mapOf("streakCount" to next.newStreak, "lastAnsweredAt" to now),
SetOptions.merge()
).await()
}
suspend fun leaveCouple() {

View File

@ -8,12 +8,9 @@ import com.google.firebase.firestore.DocumentSnapshot
import com.google.firebase.firestore.FieldPath
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.functions.FirebaseFunctions
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 FirestoreOutcomeDataSource @Inject constructor(
@ -25,29 +22,22 @@ class FirestoreOutcomeDataSource @Inject constructor(
val OUTCOME_DAY_KEYS: List<OutcomeDayKey> = OutcomeDay.entries.map { it.key }
}
suspend fun submitOutcome(coupleId: String, dayKey: OutcomeDayKey, scores: OutcomeScores): Unit =
suspendCancellableCoroutine { cont ->
functions.getHttpsCallable("submitOutcomeCallable")
.call(
mapOf(
"coupleId" to coupleId,
"dayKey" to dayKey,
"scores" to scores.toMap()
)
suspend fun submitOutcome(coupleId: String, dayKey: OutcomeDayKey, scores: OutcomeScores) {
functions.getHttpsCallable("submitOutcomeCallable")
.call(
mapOf(
"coupleId" to coupleId,
"dayKey" to dayKey,
"scores" to scores.toMap()
)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
)
.await()
}
suspend fun getOutcome(coupleId: String, dayKey: OutcomeDayKey): Outcome? =
suspendCancellableCoroutine { cont ->
outcomeRef(coupleId, dayKey)
.get()
.addOnSuccessListener { snap ->
cont.resume(if (snap.exists()) snap.toOutcome() else null)
}
.addOnFailureListener { cont.resumeWithException(it) }
}
suspend fun getOutcome(coupleId: String, dayKey: OutcomeDayKey): Outcome? {
val snap = outcomeRef(coupleId, dayKey).get().await()
return if (snap.exists()) snap.toOutcome() else null
}
suspend fun getOutcomes(coupleId: String): List<Outcome> {
// Security rules permit reading only the fixed dayKey docs and DENY an

View File

@ -12,12 +12,10 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.tasks.await
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* Checks device integrity using the Play Integrity API.
@ -73,31 +71,27 @@ class PlayIntegrityChecker @Inject constructor(
}
}
private suspend fun requestToken(): String = suspendCancellableCoroutine { cont ->
private suspend fun requestToken(): String {
val nonce = Base64.encodeToString(
"${UUID.randomUUID()}-${System.currentTimeMillis()}".toByteArray(Charsets.UTF_8),
Base64.URL_SAFE or Base64.NO_WRAP,
)
IntegrityManagerFactory.create(context)
return IntegrityManagerFactory.create(context)
.requestIntegrityToken(IntegrityTokenRequest.builder().setNonce(nonce).build())
.addOnSuccessListener { cont.resume(it.token()) }
.addOnFailureListener { cont.resumeWithException(it) }
.await()
.token()
}
/** Returns the server verdict, or null when verification couldn't be completed. */
private suspend fun verifyWithServer(token: String): Boolean? =
suspendCancellableCoroutine { cont ->
functions
.getHttpsCallable("checkDeviceIntegrity")
.call(mapOf("token" to token))
.addOnSuccessListener { result ->
val passed = (result.getData() as? Map<*, *>)?.get("passed") as? Boolean
cont.resume(passed) // null if the server returned no explicit verdict
}
.addOnFailureListener {
// Couldn't verify (server/network). Don't assert PASSED — surface as
// UNAVAILABLE. Firebase App Check remains the server-side gatekeeper.
cont.resume(null)
}
}
/**
* Returns the server verdict, or null when verification couldn't be completed. A failure
* (server/network) maps to null NOT an exception so we don't assert PASSED; Firebase App
* Check remains the server-side gatekeeper. (Hence runCatching rather than a bare await.)
*/
private suspend fun verifyWithServer(token: String): Boolean? = runCatching {
val result = functions
.getHttpsCallable("checkDeviceIntegrity")
.call(mapOf("token" to token))
.await()
(result.getData() as? Map<*, *>)?.get("passed") as? Boolean
}.getOrNull()
}