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.FirebaseFirestore
import com.google.firebase.firestore.SetOptions import com.google.firebase.firestore.SetOptions
import com.google.firebase.functions.FirebaseFunctions import com.google.firebase.functions.FirebaseFunctions
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
@Singleton @Singleton
class FirestoreCoupleDataSource @Inject constructor( class FirestoreCoupleDataSource @Inject constructor(
@ -45,7 +42,7 @@ class FirestoreCoupleDataSource @Inject constructor(
inviteCode: String, inviteCode: String,
now: Long, now: Long,
wrappedKey: RecoveryKeyManager.WrappedKey wrappedKey: RecoveryKeyManager.WrappedKey
): Unit = suspendCancellableCoroutine { cont -> ) {
val data = mutableMapOf<String, Any>( val data = mutableMapOf<String, Any>(
"id" to coupleId, "id" to coupleId,
"userIds" to listOf(inviterUserId, acceptorUserId), "userIds" to listOf(inviterUserId, acceptorUserId),
@ -57,51 +54,31 @@ class FirestoreCoupleDataSource @Inject constructor(
data["wrappedCoupleKey"] = wrappedKey.cipherB64 data["wrappedCoupleKey"] = wrappedKey.cipherB64
data["kdfSalt"] = wrappedKey.saltB64 data["kdfSalt"] = wrappedKey.saltB64
data["kdfParams"] = wrappedKey.params data["kdfParams"] = wrappedKey.params
coupleRef(coupleId).set(data) coupleRef(coupleId).set(data).await()
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
} }
suspend fun updateWrappedKey(coupleId: String, wrappedKey: RecoveryKeyManager.WrappedKey): Unit = suspend fun updateWrappedKey(coupleId: String, wrappedKey: RecoveryKeyManager.WrappedKey) {
suspendCancellableCoroutine { cont -> coupleRef(coupleId).set(
coupleRef(coupleId).set( mapOf(
mapOf( "wrappedCoupleKey" to wrappedKey.cipherB64,
"wrappedCoupleKey" to wrappedKey.cipherB64, "kdfSalt" to wrappedKey.saltB64,
"kdfSalt" to wrappedKey.saltB64, "kdfParams" to wrappedKey.params
"kdfParams" to wrappedKey.params ),
), SetOptions.merge()
SetOptions.merge() ).await()
) }
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
private suspend fun updateUserCoupleId(uid: String, coupleId: String): Unit = private suspend fun updateUserCoupleId(uid: String, coupleId: String) {
suspendCancellableCoroutine { cont -> userRef(uid).set(mapOf("coupleId" to coupleId), SetOptions.merge()).await()
userRef(uid).set( }
mapOf("coupleId" to coupleId),
SetOptions.merge()
)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
suspend fun getCoupleById(coupleId: String): Couple? = suspend fun getCoupleById(coupleId: String): Couple? {
suspendCancellableCoroutine { cont -> val snap = coupleRef(coupleId).get().await()
coupleRef(coupleId).get() return if (snap.exists()) snap.toCouple() else null
.addOnSuccessListener { snap -> }
if (!snap.exists()) { cont.resume(null); return@addOnSuccessListener }
cont.resume(snap.toCouple())
}
.addOnFailureListener { cont.resumeWithException(it) }
}
suspend fun updateStreak(coupleId: String) { suspend fun updateStreak(coupleId: String) {
val snap = suspendCancellableCoroutine<DocumentSnapshot> { cont -> val snap = coupleRef(coupleId).get().await()
coupleRef(coupleId).get()
.addOnSuccessListener { cont.resume(it) }
.addOnFailureListener { cont.resumeWithException(it) }
}
val lastAnsweredAt = snap.millisOrNull("lastAnsweredAt") ?: 0L val lastAnsweredAt = snap.millisOrNull("lastAnsweredAt") ?: 0L
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val current = (snap.getLong("streakCount") ?: 0L).toInt() 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. // Same-day answer → nothing to change; skip the redundant write.
if (!next.changed) return if (!next.changed) return
suspendCancellableCoroutine<Unit> { cont -> coupleRef(coupleId).set(
coupleRef(coupleId).set( mapOf("streakCount" to next.newStreak, "lastAnsweredAt" to now),
mapOf("streakCount" to next.newStreak, "lastAnsweredAt" to now), SetOptions.merge()
SetOptions.merge() ).await()
)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
} }
suspend fun leaveCouple() { 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.FieldPath
import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.functions.FirebaseFunctions import com.google.firebase.functions.FirebaseFunctions
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
@Singleton @Singleton
class FirestoreOutcomeDataSource @Inject constructor( class FirestoreOutcomeDataSource @Inject constructor(
@ -25,29 +22,22 @@ class FirestoreOutcomeDataSource @Inject constructor(
val OUTCOME_DAY_KEYS: List<OutcomeDayKey> = OutcomeDay.entries.map { it.key } val OUTCOME_DAY_KEYS: List<OutcomeDayKey> = OutcomeDay.entries.map { it.key }
} }
suspend fun submitOutcome(coupleId: String, dayKey: OutcomeDayKey, scores: OutcomeScores): Unit = suspend fun submitOutcome(coupleId: String, dayKey: OutcomeDayKey, scores: OutcomeScores) {
suspendCancellableCoroutine { cont -> functions.getHttpsCallable("submitOutcomeCallable")
functions.getHttpsCallable("submitOutcomeCallable") .call(
.call( mapOf(
mapOf( "coupleId" to coupleId,
"coupleId" to coupleId, "dayKey" to dayKey,
"dayKey" to dayKey, "scores" to scores.toMap()
"scores" to scores.toMap()
)
) )
.addOnSuccessListener { cont.resume(Unit) } )
.addOnFailureListener { cont.resumeWithException(it) } .await()
} }
suspend fun getOutcome(coupleId: String, dayKey: OutcomeDayKey): Outcome? = suspend fun getOutcome(coupleId: String, dayKey: OutcomeDayKey): Outcome? {
suspendCancellableCoroutine { cont -> val snap = outcomeRef(coupleId, dayKey).get().await()
outcomeRef(coupleId, dayKey) return if (snap.exists()) snap.toOutcome() else null
.get() }
.addOnSuccessListener { snap ->
cont.resume(if (snap.exists()) snap.toOutcome() else null)
}
.addOnFailureListener { cont.resumeWithException(it) }
}
suspend fun getOutcomes(coupleId: String): List<Outcome> { suspend fun getOutcomes(coupleId: String): List<Outcome> {
// Security rules permit reading only the fixed dayKey docs and DENY an // 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.tasks.await
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/** /**
* Checks device integrity using the Play Integrity API. * 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( val nonce = Base64.encodeToString(
"${UUID.randomUUID()}-${System.currentTimeMillis()}".toByteArray(Charsets.UTF_8), "${UUID.randomUUID()}-${System.currentTimeMillis()}".toByteArray(Charsets.UTF_8),
Base64.URL_SAFE or Base64.NO_WRAP, Base64.URL_SAFE or Base64.NO_WRAP,
) )
IntegrityManagerFactory.create(context) return IntegrityManagerFactory.create(context)
.requestIntegrityToken(IntegrityTokenRequest.builder().setNonce(nonce).build()) .requestIntegrityToken(IntegrityTokenRequest.builder().setNonce(nonce).build())
.addOnSuccessListener { cont.resume(it.token()) } .await()
.addOnFailureListener { cont.resumeWithException(it) } .token()
} }
/** Returns the server verdict, or null when verification couldn't be completed. */ /**
private suspend fun verifyWithServer(token: String): Boolean? = * Returns the server verdict, or null when verification couldn't be completed. A failure
suspendCancellableCoroutine { cont -> * (server/network) maps to null NOT an exception so we don't assert PASSED; Firebase App
functions * Check remains the server-side gatekeeper. (Hence runCatching rather than a bare await.)
.getHttpsCallable("checkDeviceIntegrity") */
.call(mapOf("token" to token)) private suspend fun verifyWithServer(token: String): Boolean? = runCatching {
.addOnSuccessListener { result -> val result = functions
val passed = (result.getData() as? Map<*, *>)?.get("passed") as? Boolean .getHttpsCallable("checkDeviceIntegrity")
cont.resume(passed) // null if the server returned no explicit verdict .call(mapOf("token" to token))
} .await()
.addOnFailureListener { (result.getData() as? Map<*, *>)?.get("passed") as? Boolean
// Couldn't verify (server/network). Don't assert PASSED — surface as }.getOrNull()
// UNAVAILABLE. Firebase App Check remains the server-side gatekeeper.
cont.resume(null)
}
}
} }