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 package app.closer.data.remote
import app.closer.core.notifications.TokenRegistrar.DeviceMetadata import app.closer.core.notifications.TokenRegistrar.DeviceMetadata
import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.FieldEncryptor
import app.closer.domain.model.User import app.closer.domain.model.User
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 kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
@ -14,22 +17,54 @@ import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
@Singleton @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 userRef(uid: String) = db.collection(FirestoreCollections.USERS).document(uid)
private fun snapshotToUser(id: String, data: com.google.firebase.firestore.DocumentSnapshot): User = // ── Profile-metadata encryption (couple-key, AAD=coupleId). Currently: `sex`. ──────────────────
User( // 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, id = id,
email = data.getString("email") ?: "", email = data.getString("email") ?: "",
displayName = data.getString("displayName") ?: "", displayName = data.getString("displayName") ?: "",
photoUrl = data.getString("photoUrl") ?: "", photoUrl = data.getString("photoUrl") ?: "",
sex = data.getString("sex") ?: "", sex = decryptProfileField(data.getString("sex") ?: "", coupleId),
partnerId = data.getString("partnerId"), partnerId = data.getString("partnerId"),
coupleId = data.getString("coupleId"), coupleId = coupleId,
plan = data.getString("plan") ?: "free", plan = data.getString("plan") ?: "free",
createdAt = data.getLong("createdAt") ?: 0L, createdAt = data.getLong("createdAt") ?: 0L,
lastActiveAt = data.getLong("lastActiveAt") ?: 0L lastActiveAt = data.getLong("lastActiveAt") ?: 0L
) )
}
suspend fun getUser(uid: String): User? = suspend fun getUser(uid: String): User? =
suspendCancellableCoroutine { cont -> suspendCancellableCoroutine { cont ->
@ -59,7 +94,7 @@ class FirestoreUserDataSource @Inject constructor(private val db: FirebaseFirest
"email" to user.email, "email" to user.email,
"displayName" to user.displayName, "displayName" to user.displayName,
"photoUrl" to user.photoUrl, "photoUrl" to user.photoUrl,
"sex" to user.sex, "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,
@ -91,16 +126,59 @@ class FirestoreUserDataSource @Inject constructor(private val db: FirebaseFirest
.addOnFailureListener { cont.resumeWithException(it) } .addOnFailureListener { cont.resumeWithException(it) }
} }
suspend fun updateSex(uid: String, sex: String): Unit = suspend fun updateSex(uid: String, sex: String) {
suspendCancellableCoroutine { cont -> // Never write the locked placeholder back as the value (would corrupt + then encrypt it).
userRef(uid).set( require(sex != FieldEncryptor.LOCKED_PLACEHOLDER) { "Refusing to persist the locked placeholder as sex" }
mapOf("sex" to sex, "lastActiveAt" to System.currentTimeMillis()), val coupleId = getSnapshot(uid)?.getString("coupleId")
SetOptions.merge() 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) } .addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) } .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 = suspend fun hasProfile(uid: String): Boolean =
suspendCancellableCoroutine { cont -> suspendCancellableCoroutine { cont ->
userRef(uid).get() userRef(uid).get()

View File

@ -43,6 +43,13 @@ class CoupleRepositoryImpl @Inject constructor(
override suspend fun leaveCouple(userId: String): Result<Unit> = runCatching { override suspend fun leaveCouple(userId: String): Result<Unit> = runCatching {
val coupleId = userDataSource.getUser(userId)?.coupleId 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() coupleDataSource.leaveCouple()
if (coupleId != null) encryptionManager.deleteKeyset(coupleId) 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 clearCoupleId(uid: String) = dataSource.clearCoupleId(uid)
override suspend fun migrateProfileFields(uid: String) = dataSource.migrateProfileFields(uid)
override suspend fun deleteUserData(uid: String) = dataSource.deleteUserData(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 updateQuietHours(uid: String, enabled: Boolean, startMinutes: Int, endMinutes: Int, timezone: String)
suspend fun clearCoupleId(uid: 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) suspend fun deleteUserData(uid: String)
} }

View File

@ -247,6 +247,15 @@ class HomeViewModel @Inject constructor(
val encryptionStatus = couple?.let(encryptionManager::checkStatus) val encryptionStatus = couple?.let(encryptionManager::checkStatus)
val needsRecovery = encryptionStatus == EncryptionStatus.NEEDS_RECOVERY 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 // Outcome check-in due-state calculation
val appSettings = settingsRepository.settings.first() val appSettings = settingsRepository.settings.first()
val outcomeBaselineShownAt = appSettings.outcomeBaselineShownAt val outcomeBaselineShownAt = appSettings.outcomeBaselineShownAt

View File

@ -4,6 +4,7 @@ import android.net.Uri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.closer.crypto.CoupleEncryptionManager import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.FieldEncryptor
import app.closer.data.remote.FirebaseStorageDataSource import app.closer.data.remote.FirebaseStorageDataSource
import app.closer.domain.repository.AuthRepository import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository import app.closer.domain.repository.CoupleRepository
@ -61,8 +62,10 @@ class EditProfileViewModel @Inject constructor(
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
displayName = user?.displayName ?: "", // A field that can't be decrypted on this device reads back as the locked placeholder;
sex = user?.sex ?: "", // 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 ?: "", photoUrl = user?.photoUrl ?: "",
email = authRepository.currentUserEmail ?: user?.email ?: "", email = authRepository.currentUserEmail ?: user?.email ?: "",
isAnonymous = authRepository.isAnonymous, isAnonymous = authRepository.isAnonymous,
@ -125,3 +128,7 @@ class EditProfileViewModel @Inject constructor(
fun dismissError() = _uiState.update { it.copy(error = null) } fun dismissError() = _uiState.update { it.copy(error = null) }
fun onSuccessHandled() = _uiState.update { it.copy(success = false) } 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