feat(profile): FirestoreUserDataSource E2EE read/write, EditProfileViewModel wiring, CoupleRepository/UserRepository updates, HomeViewModel polish
This commit is contained in:
parent
941f22cdbd
commit
fb810a12aa
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue