feat(profile): FirestoreUserDataSource E2EE read/write, EditProfileViewModel wiring, CoupleRepository/UserRepository updates, HomeViewModel polish

This commit is contained in:
null 2026-06-30 02:18:10 -05:00
parent 941f22cdbd
commit fb810a12aa
6 changed files with 118 additions and 13 deletions

View File

@ -1,7 +1,10 @@
package app.closer.data.remote
import app.closer.core.notifications.TokenRegistrar.DeviceMetadata
import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.FieldEncryptor
import app.closer.domain.model.User
import com.google.firebase.firestore.DocumentSnapshot
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.SetOptions
import kotlinx.coroutines.channels.awaitClose
@ -14,22 +17,54 @@ import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@Singleton
class FirestoreUserDataSource @Inject constructor(private val db: FirebaseFirestore) {
class FirestoreUserDataSource @Inject constructor(
private val db: FirebaseFirestore,
private val encryptionManager: CoupleEncryptionManager,
private val fieldEncryptor: FieldEncryptor
) {
private fun userRef(uid: String) = db.collection(FirestoreCollections.USERS).document(uid)
private fun snapshotToUser(id: String, data: com.google.firebase.firestore.DocumentSnapshot): User =
User(
// ── Profile-metadata encryption (couple-key, AAD=coupleId). Currently: `sex`. ──────────────────
// Profile fields transit through plaintext (set at onboarding before a couple key exists) and are
// migrated to ciphertext at pairing, so reads must TOLERATE plaintext — unlike FieldEncryptor's
// fail-closed default. Never surface raw ciphertext: show the real value, or LOCKED if the key is
// missing on this device.
private fun decryptProfileField(raw: String, coupleId: String?): String {
if (raw.isBlank() || !fieldEncryptor.isEncrypted(raw)) return raw // legacy/plaintext passthrough
val aead = coupleId?.let { encryptionManager.aeadFor(it) }
return fieldEncryptor.decrypt(raw, aead, coupleId ?: "") ?: FieldEncryptor.LOCKED_PLACEHOLDER
}
/** Encrypt a profile value under the user's couple key when available; otherwise store plaintext. */
private fun encryptProfileField(value: String, coupleId: String?): String {
if (value.isBlank()) return value
if (fieldEncryptor.isEncrypted(value)) return value // already ciphertext (idempotent)
val aead = coupleId?.let { encryptionManager.aeadFor(it) } ?: return value
return fieldEncryptor.encrypt(value, aead, coupleId!!)
}
private suspend fun getSnapshot(uid: String): DocumentSnapshot? =
suspendCancellableCoroutine { cont ->
userRef(uid).get()
.addOnSuccessListener { cont.resume(it.takeIf { s -> s.exists() }) }
.addOnFailureListener { cont.resumeWithException(it) }
}
private fun snapshotToUser(id: String, data: DocumentSnapshot): User {
val coupleId = data.getString("coupleId")
return User(
id = id,
email = data.getString("email") ?: "",
displayName = data.getString("displayName") ?: "",
photoUrl = data.getString("photoUrl") ?: "",
sex = data.getString("sex") ?: "",
sex = decryptProfileField(data.getString("sex") ?: "", coupleId),
partnerId = data.getString("partnerId"),
coupleId = data.getString("coupleId"),
coupleId = coupleId,
plan = data.getString("plan") ?: "free",
createdAt = data.getLong("createdAt") ?: 0L,
lastActiveAt = data.getLong("lastActiveAt") ?: 0L
)
}
suspend fun getUser(uid: String): User? =
suspendCancellableCoroutine { cont ->
@ -59,7 +94,7 @@ class FirestoreUserDataSource @Inject constructor(private val db: FirebaseFirest
"email" to user.email,
"displayName" to user.displayName,
"photoUrl" to user.photoUrl,
"sex" to user.sex,
"sex" to encryptProfileField(user.sex, user.coupleId),
"partnerId" to user.partnerId,
"coupleId" to user.coupleId,
"plan" to user.plan,
@ -91,16 +126,59 @@ class FirestoreUserDataSource @Inject constructor(private val db: FirebaseFirest
.addOnFailureListener { cont.resumeWithException(it) }
}
suspend fun updateSex(uid: String, sex: String): Unit =
suspendCancellableCoroutine { cont ->
userRef(uid).set(
mapOf("sex" to sex, "lastActiveAt" to System.currentTimeMillis()),
SetOptions.merge()
suspend fun updateSex(uid: String, sex: String) {
// Never write the locked placeholder back as the value (would corrupt + then encrypt it).
require(sex != FieldEncryptor.LOCKED_PLACEHOLDER) { "Refusing to persist the locked placeholder as sex" }
val coupleId = getSnapshot(uid)?.getString("coupleId")
setMerge(
uid,
mapOf(
"sex" to encryptProfileField(sex, coupleId),
"lastActiveAt" to System.currentTimeMillis()
)
)
}
private suspend fun setMerge(uid: String, data: Map<String, Any?>): Unit =
suspendCancellableCoroutine { cont ->
userRef(uid).set(data, SetOptions.merge())
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
/**
* Encrypt any still-plaintext profile fields once the couple key is available (idempotent skips
* fields already `enc:v1:`). Targeted write: does NOT bump `lastActiveAt`. Safe to call on every
* key-unlock; heals pre-pairing-plaintext and legacy users.
*/
suspend fun migrateProfileFields(uid: String) {
val snap = getSnapshot(uid) ?: return
val coupleId = snap.getString("coupleId") ?: return
if (encryptionManager.aeadFor(coupleId) == null) return
val updates = mutableMapOf<String, Any?>()
val rawSex = snap.getString("sex") ?: ""
if (rawSex.isNotBlank() && !fieldEncryptor.isEncrypted(rawSex)) {
updates["sex"] = encryptProfileField(rawSex, coupleId)
}
if (updates.isNotEmpty()) setMerge(uid, updates)
}
/**
* Decrypt profile fields back to plaintext BEFORE the couple key/coupleId are torn down on unpair
* otherwise the user could never read their own (couple-key + AAD-bound) fields again. Must run
* while the key still exists. No-op for fields that can't be decrypted here.
*/
suspend fun revertProfileFieldsToPlaintext(uid: String, coupleId: String) {
val aead = encryptionManager.aeadFor(coupleId) ?: return
val snap = getSnapshot(uid) ?: return
val updates = mutableMapOf<String, Any?>()
val rawSex = snap.getString("sex") ?: ""
if (fieldEncryptor.isEncrypted(rawSex)) {
fieldEncryptor.decrypt(rawSex, aead, coupleId)?.let { updates["sex"] = it }
}
if (updates.isNotEmpty()) setMerge(uid, updates)
}
suspend fun hasProfile(uid: String): Boolean =
suspendCancellableCoroutine { cont ->
userRef(uid).get()

View File

@ -43,6 +43,13 @@ class CoupleRepositoryImpl @Inject constructor(
override suspend fun leaveCouple(userId: String): Result<Unit> = runCatching {
val coupleId = userDataSource.getUser(userId)?.coupleId
// Decrypt the couple-key-encrypted profile metadata back to plaintext BEFORE the key is torn
// down — otherwise these AAD(coupleId)-bound fields become permanently unreadable on unpair.
// Best-effort: never block the unpair on it.
if (coupleId != null) {
runCatching { userDataSource.revertProfileFieldsToPlaintext(userId, coupleId) }
.onFailure { crashReporter.recordException(it) }
}
coupleDataSource.leaveCouple()
if (coupleId != null) encryptionManager.deleteKeyset(coupleId)
}

View File

@ -60,5 +60,7 @@ class UserRepositoryImpl @Inject constructor(
override suspend fun clearCoupleId(uid: String) = dataSource.clearCoupleId(uid)
override suspend fun migrateProfileFields(uid: String) = dataSource.migrateProfileFields(uid)
override suspend fun deleteUserData(uid: String) = dataSource.deleteUserData(uid)
}

View File

@ -24,5 +24,7 @@ interface UserRepository {
)
suspend fun updateQuietHours(uid: String, enabled: Boolean, startMinutes: Int, endMinutes: Int, timezone: String)
suspend fun clearCoupleId(uid: String)
/** Encrypt any still-plaintext profile metadata once the couple key is available (idempotent). */
suspend fun migrateProfileFields(uid: String)
suspend fun deleteUserData(uid: String)
}

View File

@ -247,6 +247,15 @@ class HomeViewModel @Inject constructor(
val encryptionStatus = couple?.let(encryptionManager::checkStatus)
val needsRecovery = encryptionStatus == EncryptionStatus.NEEDS_RECOVERY
// Heal profile-metadata encryption once the couple key is available (idempotent + targeted;
// covers pre-pairing-plaintext + legacy users). Skipped when the key isn't present yet.
if (uid != null && encryptionStatus != null && !needsRecovery) {
launch {
runCatching { userRepository.migrateProfileFields(uid) }
.onFailure { Log.w(TAG, "profile encryption migration failed", it) }
}
}
// Outcome check-in due-state calculation
val appSettings = settingsRepository.settings.first()
val outcomeBaselineShownAt = appSettings.outcomeBaselineShownAt

View File

@ -4,6 +4,7 @@ import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.FieldEncryptor
import app.closer.data.remote.FirebaseStorageDataSource
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository
@ -61,8 +62,10 @@ class EditProfileViewModel @Inject constructor(
_uiState.update {
it.copy(
isLoading = false,
displayName = user?.displayName ?: "",
sex = user?.sex ?: "",
// A field that can't be decrypted on this device reads back as the locked placeholder;
// treat it as unset so it's never re-saved (and re-encrypted) as the value.
displayName = (user?.displayName ?: "").stripLockedPlaceholder(),
sex = (user?.sex ?: "").stripLockedPlaceholder(),
photoUrl = user?.photoUrl ?: "",
email = authRepository.currentUserEmail ?: user?.email ?: "",
isAnonymous = authRepository.isAnonymous,
@ -125,3 +128,7 @@ class EditProfileViewModel @Inject constructor(
fun dismissError() = _uiState.update { it.copy(error = null) }
fun onSuccessHandled() = _uiState.update { it.copy(success = false) }
}
/** Blank out the "couldn't unlock" placeholder so it never sits in editable form state. */
private fun String.stripLockedPlaceholder(): String =
if (this == FieldEncryptor.LOCKED_PLACEHOLDER) "" else this