feat(date-memories): add FirestoreDateReflectionDataSource
This commit is contained in:
parent
631064fcfe
commit
540ef29041
|
|
@ -0,0 +1,118 @@
|
||||||
|
package app.closer.data.remote
|
||||||
|
|
||||||
|
import app.closer.crypto.CoupleEncryptionManager
|
||||||
|
import app.closer.crypto.FieldEncryptor
|
||||||
|
import app.closer.domain.model.DateReflection
|
||||||
|
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 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:
|
||||||
|
* `couples/{coupleId}/date_reflections/{dateId}/answers/{userId}` (+ read-gated `secure/payload`).
|
||||||
|
* "Neither partner reads the other until both reflect" is enforced by the Firestore rule, not client code.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class FirestoreDateReflectionDataSource @Inject constructor(
|
||||||
|
private val db: FirebaseFirestore,
|
||||||
|
private val encryptionManager: CoupleEncryptionManager,
|
||||||
|
private val fieldEncryptor: FieldEncryptor
|
||||||
|
) {
|
||||||
|
private fun answerRef(coupleId: String, dateId: String, userId: String) =
|
||||||
|
db.collection(FirestoreCollections.COUPLES).document(coupleId)
|
||||||
|
.collection(FirestoreCollections.Couples.DATE_REFLECTIONS).document(dateId)
|
||||||
|
.collection(FirestoreCollections.DailyQuestion.ANSWERS).document(userId)
|
||||||
|
|
||||||
|
private fun securePayloadRef(coupleId: String, dateId: String, userId: String) =
|
||||||
|
answerRef(coupleId, dateId, userId).collection("secure").document("payload")
|
||||||
|
|
||||||
|
/** Save my reflection: encrypted content in the gated secure subdoc + a content-free metadata doc. */
|
||||||
|
suspend fun saveReflection(coupleId: String, dateId: String, userId: String, reflection: DateReflection) {
|
||||||
|
val aead = encryptionManager.aeadFor(coupleId)
|
||||||
|
?: throw IllegalStateException("Couple key unavailable for $coupleId")
|
||||||
|
val payload = fieldEncryptor.encrypt(encode(reflection), aead, coupleId)
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
suspendCancellableCoroutine<Unit> { cont ->
|
||||||
|
securePayloadRef(coupleId, dateId, userId).set(mapOf("encryptedPayload" to payload))
|
||||||
|
.addOnSuccessListener { cont.resume(Unit) }.addOnFailureListener { cont.resumeWithException(it) }
|
||||||
|
}
|
||||||
|
suspendCancellableCoroutine<Unit> { cont ->
|
||||||
|
answerRef(coupleId, dateId, userId).set(
|
||||||
|
mapOf(
|
||||||
|
"userId" to userId,
|
||||||
|
"schemaVersion" to DateReflection.SCHEMA_VERSION,
|
||||||
|
"createdAt" to now,
|
||||||
|
"updatedAt" to now,
|
||||||
|
"isRevealed" to false
|
||||||
|
)
|
||||||
|
).addOnSuccessListener { cont.resume(Unit) }.addOnFailureListener { cont.resumeWithException(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True once a user has reflected (metadata doc readable by both → drives the "your turn" state). */
|
||||||
|
suspend fun hasReflected(coupleId: String, dateId: String, userId: String): Boolean =
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
answerRef(coupleId, dateId, userId).get()
|
||||||
|
.addOnSuccessListener { cont.resume(it.exists()) }
|
||||||
|
.addOnFailureListener { cont.resume(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
||||||
|
val reg = answerRef(coupleId, dateId, userId).addSnapshotListener { snap, err ->
|
||||||
|
if (err != null) return@addSnapshotListener
|
||||||
|
trySend(snap?.exists() == true)
|
||||||
|
}
|
||||||
|
awaitClose { reg.remove() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read + decrypt a user's reflection content from the gated `secure` subdoc. Returns null when the
|
||||||
|
* read is denied (partner hasn't reflected yet → privacy gate) or the key is unavailable here.
|
||||||
|
*/
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
}.getOrNull() ?: return null
|
||||||
|
val aead = encryptionManager.aeadFor(coupleId) ?: return null
|
||||||
|
val json = fieldEncryptor.decrypt(payload, aead, coupleId) ?: return null
|
||||||
|
return runCatching {
|
||||||
|
val o = JSONObject(json)
|
||||||
|
DateReflection(
|
||||||
|
dateId = dateId,
|
||||||
|
userId = userId,
|
||||||
|
favoriteMoment = o.optString("favoriteMoment"),
|
||||||
|
surprise = o.optString("surprise"),
|
||||||
|
appreciated = o.optString("appreciated"),
|
||||||
|
isRevealed = true,
|
||||||
|
schemaVersion = o.optInt("schemaVersion", DateReflection.SCHEMA_VERSION)
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encode(r: DateReflection): String = JSONObject().apply {
|
||||||
|
put("favoriteMoment", r.favoriteMoment)
|
||||||
|
put("surprise", r.surprise)
|
||||||
|
put("appreciated", r.appreciated)
|
||||||
|
put("schemaVersion", DateReflection.SCHEMA_VERSION)
|
||||||
|
}.toString()
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue