feat: E2EE recovery flow, iOS parity updates, onboarding + pairing polish
This commit is contained in:
parent
62d99505c9
commit
af70280daa
|
|
@ -41,19 +41,24 @@ class CoupleEncryptionManager @Inject constructor(
|
|||
private val keyManager: RecoveryKeyManager
|
||||
) {
|
||||
/**
|
||||
* Called by the inviter when creating an invite.
|
||||
* Generates a new couple keyset + recovery phrase, wraps the keyset,
|
||||
* and stores it locally under the invite slot (coupleId unknown yet).
|
||||
* Generates a new couple keyset + recovery phrase but does NOT store them yet —
|
||||
* storage is deferred until the server confirms the invite code via [storeInviteSetup].
|
||||
* This avoids storing the keyset under a client-generated code that may differ from
|
||||
* the code the server ultimately uses (which would break reconciliation).
|
||||
*/
|
||||
suspend fun setupForNewCouple(inviteCode: String): SetupResult = withContext(Dispatchers.Default) {
|
||||
suspend fun setupForNewCouple(): SetupResult = withContext(Dispatchers.Default) {
|
||||
val phrase = keyManager.generateRecoveryPhrase()
|
||||
val handle = keyManager.newCoupleKeyset()
|
||||
val wrapped = keyManager.wrap(handle, phrase)
|
||||
keyStore.storeInviteKeyset(inviteCode, handle)
|
||||
keyStore.storeInvitePhrase(inviteCode, phrase)
|
||||
SetupResult(handle, wrapped, phrase)
|
||||
}
|
||||
|
||||
/** Persists a [SetupResult] under the server-confirmed [inviteCode]. */
|
||||
fun storeInviteSetup(inviteCode: String, result: SetupResult) {
|
||||
keyStore.storeInviteKeyset(inviteCode, result.handle)
|
||||
keyStore.storeInvitePhrase(inviteCode, result.recoveryPhrase)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the acceptor during pairing, and on any device during recovery.
|
||||
* Derives the wrap key from [phrase], unwraps the keyset, and stores it
|
||||
|
|
|
|||
|
|
@ -92,6 +92,30 @@ class RecoveryKeyManager @Inject constructor() {
|
|||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts [phrase] with a key derived from [inviteCode] via Argon2id + AES-256-GCM.
|
||||
* Output format: base64(salt[16] || gcm_ciphertext). Argon2id makes offline brute-force
|
||||
* infeasible even for the 6-char code space (~30 bits entropy).
|
||||
*/
|
||||
fun encryptPhraseWithCode(inviteCode: String, phrase: String): String {
|
||||
val salt = ByteArray(SALT_BYTES).also { SecureRandom().nextBytes(it) }
|
||||
val key = deriveKey(inviteCode, salt)
|
||||
val ct = AesGcmJce(key).encrypt(phrase.toByteArray(Charsets.UTF_8), PHRASE_CIPHER_AAD)
|
||||
return Base64.getEncoder().encodeToString(salt + ct)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts a ciphertext produced by [encryptPhraseWithCode] using [inviteCode].
|
||||
* Throws if the code is wrong or the ciphertext is malformed.
|
||||
*/
|
||||
fun decryptPhraseWithCode(inviteCode: String, ciphertextB64: String): String {
|
||||
val payload = Base64.getDecoder().decode(ciphertextB64)
|
||||
val salt = payload.copyOf(SALT_BYTES)
|
||||
val ct = payload.copyOfRange(SALT_BYTES, payload.size)
|
||||
val key = deriveKey(inviteCode, salt)
|
||||
return String(AesGcmJce(key).decrypt(ct, PHRASE_CIPHER_AAD), Charsets.UTF_8)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PHRASE_WORD_COUNT = 10
|
||||
private const val SALT_BYTES = 16
|
||||
|
|
@ -101,6 +125,7 @@ class RecoveryKeyManager @Inject constructor() {
|
|||
private const val ARGON2_PARALLELISM = 1
|
||||
private const val PARAMS_TAG = "argon2id;v=19;m=47104;t=3;p=1"
|
||||
private val WRAP_AAD = "closer_couple_key".toByteArray(Charsets.UTF_8)
|
||||
private val PHRASE_CIPHER_AAD = "closer_invite_phrase".toByteArray(Charsets.UTF_8)
|
||||
|
||||
// 256-word list -> 10 words -> ~80 bits raw entropy; Argon2id makes brute-force infeasible.
|
||||
val WORDLIST = arrayOf(
|
||||
|
|
|
|||
|
|
@ -1,42 +1,39 @@
|
|||
package app.closer.data.remote
|
||||
|
||||
import app.closer.crypto.RecoveryKeyManager
|
||||
import app.closer.domain.model.Invite
|
||||
import app.closer.domain.repository.AcceptInviteResult
|
||||
import com.google.firebase.firestore.FirebaseFirestore
|
||||
import com.google.firebase.functions.FirebaseFunctions
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.random.Random
|
||||
|
||||
@Singleton
|
||||
class FirestoreInviteDataSource @Inject constructor(
|
||||
private val db: FirebaseFirestore,
|
||||
private val functions: FirebaseFunctions
|
||||
) {
|
||||
private fun inviteRef(code: String) = db.collection(FirestoreCollections.INVITES).document(code)
|
||||
|
||||
fun generateCode(): String = (1..6)
|
||||
.map { CODE_CHARS[Random.nextInt(CODE_CHARS.length)] }
|
||||
.joinToString("")
|
||||
|
||||
/**
|
||||
* Creates an invite server-side. [code] is client-generated and used as both the
|
||||
* Firestore document ID and the KDF input for phrase encryption; the server validates
|
||||
* uniqueness and returns the confirmed code. [encryptedRecoveryPhrase] is an
|
||||
* Argon2id+AES-GCM blob produced by [RecoveryKeyManager.encryptPhraseWithCode] —
|
||||
* the server stores it opaquely and never sees the plaintext phrase.
|
||||
*/
|
||||
suspend fun createInvite(
|
||||
code: String,
|
||||
inviterUserId: String,
|
||||
wrappedKey: RecoveryKeyManager.WrappedKey,
|
||||
recoveryPhrase: String
|
||||
encryptedRecoveryPhrase: String
|
||||
): CreateInviteResponse {
|
||||
@Suppress("DEPRECATION")
|
||||
val result = functions.getHttpsCallable("createInviteCallable")
|
||||
.call(
|
||||
mapOf(
|
||||
"code" to code,
|
||||
"wrappedCoupleKey" to wrappedKey.cipherB64,
|
||||
"kdfSalt" to wrappedKey.saltB64,
|
||||
"kdfParams" to wrappedKey.params,
|
||||
"recoveryPhrase" to recoveryPhrase
|
||||
"encryptedRecoveryPhrase" to encryptedRecoveryPhrase
|
||||
)
|
||||
)
|
||||
.await()
|
||||
|
|
@ -54,43 +51,13 @@ class FirestoreInviteDataSource @Inject constructor(
|
|||
val expiresAt: com.google.firebase.Timestamp
|
||||
)
|
||||
|
||||
suspend fun getInviteByCode(code: String): Invite? =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
inviteRef(code).get()
|
||||
.addOnSuccessListener { snap ->
|
||||
if (!snap.exists()) { cont.resume(null); return@addOnSuccessListener }
|
||||
cont.resume(
|
||||
Invite(
|
||||
id = snap.id,
|
||||
code = snap.getString("code") ?: snap.id,
|
||||
inviterUserId = snap.getString("inviterUserId") ?: "",
|
||||
inviteeEmail = snap.getString("inviteeEmail"),
|
||||
coupleId = snap.getString("coupleId"),
|
||||
status = snap.getString("status") ?: "pending",
|
||||
createdAt = snap.getTimestamp("createdAt")?.toDate()?.time
|
||||
?: snap.getLong("createdAt") ?: 0L,
|
||||
expiresAt = snap.getTimestamp("expiresAt")?.toDate()?.time
|
||||
?: snap.getLong("expiresAt") ?: 0L,
|
||||
acceptedAt = snap.getTimestamp("acceptedAt")?.toDate()?.time
|
||||
?: snap.getLong("acceptedAt"),
|
||||
acceptedByUserId = snap.getString("acceptedByUserId"),
|
||||
wrappedCoupleKey = snap.getString("wrappedCoupleKey"),
|
||||
kdfSalt = snap.getString("kdfSalt"),
|
||||
kdfParams = snap.getString("kdfParams")
|
||||
)
|
||||
)
|
||||
}
|
||||
.addOnFailureListener { cont.resumeWithException(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts an invite server-side via the [acceptInviteCallable] Cloud Function.
|
||||
*
|
||||
* The client no longer reads the invite document directly (issue #9 fix).
|
||||
* The function reads the recovery phrase from the invite doc and returns it,
|
||||
* so the acceptor never needs to type it manually.
|
||||
* Accepts an invite server-side. Returns [AcceptInviteResult] with
|
||||
* [AcceptInviteResult.recoveryPhrase] holding the raw encrypted blob from the server;
|
||||
* the caller ([InviteRepositoryImpl]) decrypts it with the invite code before
|
||||
* surfacing it to the domain layer.
|
||||
*/
|
||||
suspend fun acceptInvite(code: String): app.closer.domain.repository.AcceptInviteResult {
|
||||
suspend fun acceptInvite(code: String): AcceptInviteResult {
|
||||
@Suppress("DEPRECATION")
|
||||
val result = functions.getHttpsCallable("acceptInviteCallable")
|
||||
.call(mapOf("code" to code))
|
||||
|
|
@ -108,9 +75,10 @@ class FirestoreInviteDataSource @Inject constructor(
|
|||
?: throw IllegalStateException("Missing kdfSalt in acceptInvite response")
|
||||
val kdfParams = data["kdfParams"] as? String
|
||||
?: throw IllegalStateException("Missing kdfParams in acceptInvite response")
|
||||
val recoveryPhrase = data["recoveryPhrase"] as? String
|
||||
// Raw encrypted blob — repository decrypts before returning to callers.
|
||||
val encryptedRecoveryPhrase = data["encryptedRecoveryPhrase"] as? String
|
||||
|
||||
return app.closer.domain.repository.AcceptInviteResult(
|
||||
return AcceptInviteResult(
|
||||
coupleId = coupleId,
|
||||
inviterUserId = inviterUserId,
|
||||
wrappedKey = RecoveryKeyManager.WrappedKey(
|
||||
|
|
@ -118,11 +86,7 @@ class FirestoreInviteDataSource @Inject constructor(
|
|||
saltB64 = kdfSalt,
|
||||
params = kdfParams
|
||||
),
|
||||
recoveryPhrase = recoveryPhrase
|
||||
recoveryPhrase = encryptedRecoveryPhrase
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +1,66 @@
|
|||
package app.closer.data.repository
|
||||
|
||||
import app.closer.crypto.CoupleEncryptionManager
|
||||
import app.closer.crypto.RecoveryKeyManager
|
||||
import app.closer.data.remote.FirestoreInviteDataSource
|
||||
import app.closer.domain.model.Invite
|
||||
import app.closer.domain.repository.AcceptInviteResult
|
||||
import app.closer.domain.repository.CreateInviteResult
|
||||
import app.closer.domain.repository.InviteRepository
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.random.Random
|
||||
|
||||
@Singleton
|
||||
class InviteRepositoryImpl @Inject constructor(
|
||||
private val dataSource: FirestoreInviteDataSource,
|
||||
private val encryptionManager: CoupleEncryptionManager
|
||||
private val encryptionManager: CoupleEncryptionManager,
|
||||
private val keyManager: RecoveryKeyManager
|
||||
) : InviteRepository {
|
||||
|
||||
override suspend fun createInvite(inviterUserId: String): Result<CreateInviteResult> = runCatching {
|
||||
val localCode = dataSource.generateCode()
|
||||
val setup = encryptionManager.setupForNewCouple(localCode)
|
||||
// The server is the source of truth for the final code; it may differ
|
||||
// from localCode if a collision occurs server-side. The returned code is
|
||||
// what the partner must enter.
|
||||
val response = dataSource.createInvite(localCode, inviterUserId, setup.wrapped, setup.recoveryPhrase)
|
||||
CreateInviteResult(code = response.code, recoveryPhrase = setup.recoveryPhrase)
|
||||
override suspend fun createInvite(): Result<CreateInviteResult> = runCatching {
|
||||
// Generate the keyset + phrase once. Only the code (and the per-code encrypted phrase)
|
||||
// changes on the rare collision retry — the keyset itself stays the same.
|
||||
val setup = encryptionManager.setupForNewCouple()
|
||||
var lastError: Throwable? = null
|
||||
for (attempt in 0 until MAX_CODE_ATTEMPTS) {
|
||||
val code = generateCode()
|
||||
val encryptedPhrase = keyManager.encryptPhraseWithCode(code, setup.recoveryPhrase)
|
||||
val response = runCatching { dataSource.createInvite(code, setup.wrapped, encryptedPhrase) }
|
||||
response.onSuccess { r ->
|
||||
encryptionManager.storeInviteSetup(r.code, setup)
|
||||
return@runCatching CreateInviteResult(r.code, setup.recoveryPhrase)
|
||||
}
|
||||
response.onFailure { e ->
|
||||
if (!isCodeConflict(e)) throw e
|
||||
lastError = e
|
||||
}
|
||||
}
|
||||
throw lastError ?: IllegalStateException("Could not generate a unique invite code")
|
||||
}
|
||||
|
||||
override suspend fun getInviteByCode(code: String): Result<Invite?> = runCatching {
|
||||
dataSource.getInviteByCode(code)
|
||||
override suspend fun acceptInvite(code: String): Result<AcceptInviteResult> = runCatching {
|
||||
val raw = dataSource.acceptInvite(code)
|
||||
// raw.recoveryPhrase holds the encrypted blob from the server; decrypt it here so
|
||||
// callers always receive the plaintext phrase (or null for iOS/plaintext couples).
|
||||
val phrase = raw.recoveryPhrase?.let {
|
||||
runCatching { keyManager.decryptPhraseWithCode(code, it) }.getOrNull()
|
||||
}
|
||||
raw.copy(recoveryPhrase = phrase)
|
||||
}
|
||||
|
||||
override suspend fun acceptInvite(code: String, acceptorUserId: String): Result<AcceptInviteResult> = runCatching {
|
||||
dataSource.acceptInvite(code)
|
||||
private fun isCodeConflict(e: Throwable): Boolean {
|
||||
val msg = e.message ?: return false
|
||||
return msg.contains("already-exists", ignoreCase = true) ||
|
||||
msg.contains("ALREADY_EXISTS", ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun generateCode(): String = (1..CODE_LENGTH)
|
||||
.map { CODE_CHARS[Random.nextInt(CODE_CHARS.length)] }
|
||||
.joinToString("")
|
||||
|
||||
companion object {
|
||||
private const val CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
private const val CODE_LENGTH = 6
|
||||
private const val MAX_CODE_ATTEMPTS = 10
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package app.closer.domain.repository
|
||||
|
||||
import app.closer.crypto.RecoveryKeyManager
|
||||
import app.closer.domain.model.Invite
|
||||
|
||||
data class CreateInviteResult(val code: String, val recoveryPhrase: String)
|
||||
|
||||
|
|
@ -10,8 +9,9 @@ data class CreateInviteResult(val code: String, val recoveryPhrase: String)
|
|||
*
|
||||
* @property inviterUserId The UID of the partner who created the invite.
|
||||
* @property wrappedKey The encrypted couple key the acceptor must unwrap.
|
||||
* @property recoveryPhrase The phrase stored by the inviter; returned by the server so the acceptor
|
||||
* never needs to type it manually.
|
||||
* @property recoveryPhrase The recovery phrase, decrypted client-side from the server's
|
||||
* encryptedRecoveryPhrase blob using the invite code. Null for iOS-originated invites
|
||||
* (encryptionVersion=0) until iOS implements E2EE parity.
|
||||
*/
|
||||
data class AcceptInviteResult(
|
||||
val coupleId: String,
|
||||
|
|
@ -21,7 +21,6 @@ data class AcceptInviteResult(
|
|||
)
|
||||
|
||||
interface InviteRepository {
|
||||
suspend fun createInvite(inviterUserId: String): Result<CreateInviteResult>
|
||||
suspend fun getInviteByCode(code: String): Result<Invite?>
|
||||
suspend fun acceptInvite(code: String, acceptorUserId: String): Result<AcceptInviteResult>
|
||||
suspend fun createInvite(): Result<CreateInviteResult>
|
||||
suspend fun acceptInvite(code: String): Result<AcceptInviteResult>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package app.closer.ui.answers
|
||||
|
||||
import app.closer.R
|
||||
import app.closer.ui.theme.closerBackgroundBrush
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
|
|
@ -162,7 +163,8 @@ private fun AnswerHistoryContent(
|
|||
title = "Nothing here yet",
|
||||
body = "Answer today's question or pick a prompt from a pack. Every answer you save will wait for you here.",
|
||||
actionLabel = "Today's question",
|
||||
onAction = onDailyQuestion
|
||||
onAction = onDailyQuestion,
|
||||
illustrationResId = R.drawable.illustration_couple_history
|
||||
)
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,24 @@
|
|||
package app.closer.ui.components
|
||||
|
||||
import app.closer.ui.theme.closerCardColor
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun EmptyState(
|
||||
|
|
@ -17,7 +26,9 @@ fun EmptyState(
|
|||
body: String,
|
||||
actionLabel: String? = null,
|
||||
onAction: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
@DrawableRes illustrationResId: Int? = null,
|
||||
illustrationSize: Dp = 132.dp
|
||||
) {
|
||||
CloserCard(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
|
|
@ -27,6 +38,16 @@ fun EmptyState(
|
|||
modifier = Modifier.padding(CloserSpacing.Xl),
|
||||
verticalArrangement = Arrangement.spacedBy(CloserSpacing.Md)
|
||||
) {
|
||||
illustrationResId?.let { resId ->
|
||||
Image(
|
||||
painter = painterResource(resId),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(illustrationSize)
|
||||
.clip(RoundedCornerShape(CloserRadii.Tile))
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
package app.closer.ui.onboarding
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
|
|
@ -298,71 +297,15 @@ private fun CtaSlide(onNavigate: (String) -> Unit) {
|
|||
|
||||
@Composable
|
||||
private fun AnswerPreviewVisual() {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Mock question card
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "What's something you want more of in your relationship?",
|
||||
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium),
|
||||
color = Color(0xFF1A1A2E),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
// Two mock answer bubbles
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
MockAnswerBubble("Quality time", selected = true, modifier = Modifier.weight(1f))
|
||||
MockAnswerBubble("Adventure", selected = false, modifier = Modifier.weight(1f))
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
MockAnswerBubble("Deep talks", selected = false, modifier = Modifier.weight(1f))
|
||||
MockAnswerBubble("Playfulness", selected = false, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MockAnswerBubble(label: String, selected: Boolean, modifier: Modifier = Modifier) {
|
||||
val bg by animateColorAsState(
|
||||
if (selected) CloserPalette.PurpleDeep else Color(0xFFF4F0FF),
|
||||
animationSpec = tween(200)
|
||||
Image(
|
||||
painter = painterResource(R.drawable.illustration_couple_onboarding),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.8f)
|
||||
.aspectRatio(2f / 3f)
|
||||
.clip(RoundedCornerShape(28.dp))
|
||||
)
|
||||
val fg by animateColorAsState(
|
||||
if (selected) Color.White else Color(0xFF56306F),
|
||||
animationSpec = tween(200)
|
||||
)
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = bg
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.padding(vertical = 10.dp, horizontal = 6.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = fg,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ class CreateInviteViewModel @Inject constructor(
|
|||
_uiState.update { it.copy(isLoading = false, navigateTo = AppRoute.HOME) }
|
||||
return@launch
|
||||
}
|
||||
inviteRepository.createInvite(userId)
|
||||
inviteRepository.createInvite()
|
||||
.onSuccess { result ->
|
||||
_uiState.update { it.copy(isLoading = false, inviteCode = result.code, recoveryPhrase = result.recoveryPhrase) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,13 +48,13 @@ class InviteConfirmViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun confirmPairing() {
|
||||
val acceptorId = authRepository.currentUserId ?: run {
|
||||
if (authRepository.currentUserId == null) {
|
||||
_uiState.update { it.copy(error = "Not signed in.") }
|
||||
return
|
||||
}
|
||||
_uiState.update { it.copy(isConfirming = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
inviteRepository.acceptInvite(inviteCode, acceptorId)
|
||||
inviteRepository.acceptInvite(inviteCode)
|
||||
.onSuccess { result ->
|
||||
val phrase = result.recoveryPhrase
|
||||
if (phrase != null && result.wrappedKey.cipherB64.isNotBlank()) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package app.closer.ui.pairing
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -27,9 +28,13 @@ import androidx.compose.material3.TextButton
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.closer.R
|
||||
import app.closer.core.navigation.AppRoute
|
||||
import app.closer.ui.auth.AuthBackgroundBrush
|
||||
import app.closer.ui.auth.AuthInk
|
||||
|
|
@ -76,6 +81,17 @@ fun PairPromptScreen(
|
|||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.illustration_couple_invite),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(width = 176.dp, height = 230.dp)
|
||||
.clip(RoundedCornerShape(28.dp))
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
Text(
|
||||
text = "Now bring\nyour person in.",
|
||||
style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Bold),
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
|
|
@ -1,7 +1,7 @@
|
|||
# Closer — Private MVP QA Checklist
|
||||
|
||||
> Manual testing checklist for the internal MVP build. Covers every top-level flow in the app and notes known gaps discovered during the 2025-06 QA pass.
|
||||
> Last updated: 2026-06-21 — reflects pairing security hardening, notification preferences, appearance screen, and email invite removal.
|
||||
> Last updated: 2026-06-21 — reflects pairing security hardening, notification preferences, appearance screen, email invite removal, real PartnerHomeScreen, real SubscriptionScreen, ProGuard log stripping, strings.xml, and support URL fix. All 17 release blocker items resolved or pending deploy.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -42,7 +42,8 @@
|
|||
- [ ] Back navigation from auth screens returns to onboarding.
|
||||
|
||||
### 1.5 Known onboarding gaps (from code scan)
|
||||
- `AccountScreen` shows "Local profile" and disables "Sign in or create account" and "Export your data" rows. These need real wiring before public release.
|
||||
|
||||
- ~~`AccountScreen` shows "Local profile" and disables "Sign in or create account" and "Export your data" rows.~~ ✓ Those disabled rows are gone — Account screen now shows only the recovery phrase card (when paired) and Delete account.
|
||||
- Home header text changes based on `partnerName` presence; confirm both states render.
|
||||
|
||||
---
|
||||
|
|
@ -104,14 +105,20 @@
|
|||
- [ ] Pull/refresh or retry on error works.
|
||||
|
||||
### 3.2 Partner home (`PartnerHomeScreen`)
|
||||
- [ ] Renders `PlaceholderScreen` with correct copy.
|
||||
- [ ] Actions navigate to invite flow or home.
|
||||
- [ ] **Gap**: screen is a placeholder; not a functional partner dashboard yet.
|
||||
|
||||
- [x] ~~Renders `PlaceholderScreen` with correct copy.~~ Screen is now a real dashboard (blocker #5 fixed).
|
||||
- [ ] Partner identity card shows avatar initial, name, and streak count.
|
||||
- [ ] Activity card shows check icon when partner has answered today; hourglass when not.
|
||||
- [ ] "Send a gentle nudge" button visible when partner hasn't answered; disabled while sending.
|
||||
- [ ] Nudge success/error surfaces via snackbar.
|
||||
- [ ] "View today's question" button navigates to `DAILY_QUESTION`.
|
||||
- [ ] Entry point: tapping "with [partnerName]" in the HomeScreen streak card navigates here.
|
||||
- [ ] Loading and error states render without crash.
|
||||
|
||||
### 3.3 Moment cue / special dates section
|
||||
- [ ] `SpecialDatesSection` previews render.
|
||||
- [ ] Home moment cue card text not placeholder.
|
||||
- [ ] Hardcoded names ("Jessica", "Mark") and dates in `SpecialDatesSection` must be replaced with real data before public release.
|
||||
|
||||
- [x] ~~`SpecialDatesSection` previews render with hardcoded names.~~ `SpecialDatesSection` is dead code — it is never called from Home (blocker #2). No hardcoded names render.
|
||||
- [ ] Home moment cue card shows honest placeholder copy ("Birthdays, anniversaries, and planned moments will sit here…") — confirm text is visible and not blank.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -235,8 +242,11 @@
|
|||
- [ ] Tap navigates to detail/builder.
|
||||
|
||||
### 8.3 Date builder (`DateBuilderScreen`)
|
||||
- [ ] Fields render: date, time, budget, duration.
|
||||
- [ ] **Gap**: date and time fields show "TODO: Date picker dialog" / "TODO: Time picker dialog"; they are not interactive yet.
|
||||
|
||||
- [x] ~~Date and time fields show "TODO" placeholders.~~ `DatePickerDialog` and `TimeInput` are already implemented and wired (blocker #1).
|
||||
- [ ] Fields render: date picker, time input, budget, duration chips.
|
||||
- [ ] Date field opens `DatePickerDialog` on tap; selected date reflects in field.
|
||||
- [ ] Time field accepts input correctly.
|
||||
- [ ] Budget input accepts digits only.
|
||||
- [ ] Duration chips selectable.
|
||||
- [ ] Save button calls view model.
|
||||
|
|
@ -262,14 +272,15 @@
|
|||
- [ ] Tapping partner card opens `RELATIONSHIP_SETTINGS` (paired) or `CREATE_INVITE` (unpaired).
|
||||
- [ ] **Appearance** row (palette icon) present and opens `APPEARANCE` screen.
|
||||
- [ ] Notifications, Subscription, Privacy & Terms rows open correct screens.
|
||||
- [ ] No "Email Invite" or "Invite by Email" row present anywhere in settings.
|
||||
- [x] No "Email Invite" or "Invite by Email" row present anywhere in settings — `EmailInviteScreen` deleted (blocker #3).
|
||||
- [ ] Legal links open external URLs.
|
||||
- [ ] Delete account row opens `DELETE_ACCOUNT`.
|
||||
- [ ] Sign out button works and shows loading state.
|
||||
|
||||
### 9.2 Account (`AccountScreen`)
|
||||
|
||||
- [ ] "Local profile" card shown for signed-out state.
|
||||
- [ ] Disabled rows visually greyed out.
|
||||
- [x] ~~Disabled rows ("Auth coming soon", "Export coming soon") visually greyed out.~~ Those rows no longer exist (blocker #6).
|
||||
- [ ] **Recovery phrase card** shown when paired (key icon, monospaced phrase, copy button).
|
||||
- [ ] Copy button copies phrase to clipboard and shows "Recovery phrase copied" snackbar.
|
||||
- [ ] Recovery phrase card absent when not paired (no phrase to show).
|
||||
|
|
@ -293,21 +304,23 @@
|
|||
- [ ] Back navigation returns to Settings.
|
||||
- [ ] "Device default" follows system dark/light mode correctly.
|
||||
|
||||
### 9.4 Privacy (`PrivacyScreen`)
|
||||
### 9.5 Privacy (`PrivacyScreen`)
|
||||
|
||||
- [ ] External links open in browser.
|
||||
- [ ] No browser available case handled via `ExternalLinks.openUrl` Toast fallback.
|
||||
- [ ] Back navigation works.
|
||||
- [ ] Support URL resolves correctly — now `https://closer.app/support`.
|
||||
- [x] Support URL resolves correctly — updated to `https://closer.app/support` (blocker #7).
|
||||
|
||||
### 9.5 Subscription (`SubscriptionScreen`)
|
||||
### 9.6 Subscription (`SubscriptionScreen`)
|
||||
|
||||
- [x] ~~Placeholder screen routing to paywall.~~ Real `SubscriptionViewModel` built (blocker #4).
|
||||
- [ ] **Free state**: star icon, "Unlock Premium" header, benefits list, Upgrade button navigates to `PAYWALL`, Restore link.
|
||||
- [ ] **Premium state**: "You're Premium" card, renewal date (when available), benefits list, "Manage subscription" opens Play Store, Restore link.
|
||||
- [ ] Restore purchases shows snackbar on success; error surfaces via snackbar.
|
||||
- [ ] Reads entitlement reactively — upgrading mid-session reflects immediately without restart.
|
||||
- [ ] **Note**: Requires real RevenueCat API key + active product to fully test both states.
|
||||
|
||||
### 9.6 Relationship settings / Delete account
|
||||
### 9.7 Relationship settings / Delete account
|
||||
- [ ] See pairing section for leave-couple flow.
|
||||
- [ ] Delete account confirmation dialog requires acknowledgment checkbox.
|
||||
- [ ] Delete action calls `AuthRepository.deleteAccount()` and navigates to onboarding.
|
||||
|
|
@ -387,6 +400,8 @@
|
|||
|
||||
These findings came from the static review and should be fixed before public or store release. Do **not** block internal MVP on these unless explicitly required.
|
||||
|
||||
**Summary: 16 of 17 items closed. 1 item pending a deploy command (#17).**
|
||||
|
||||
| # | Area | Issue | Severity | Status |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | Date builder | Date/time picker TODOs — fields are not interactive | High | **Not an issue** — `DatePickerDialog` and `TimeInput` are already implemented and wired |
|
||||
|
|
|
|||
|
|
@ -49,16 +49,20 @@ const admin = __importStar(require("firebase-admin"));
|
|||
* The recovery phrase is stored on the invite document by the inviter and returned
|
||||
* directly — the acceptor never needs to type it manually.
|
||||
*
|
||||
* Response: { coupleId, inviterUserId, wrappedCoupleKey, kdfSalt, kdfParams, recoveryPhrase }
|
||||
* Response: { coupleId, inviterUserId, wrappedCoupleKey, kdfSalt, kdfParams, encryptedRecoveryPhrase }
|
||||
* - encryptedRecoveryPhrase: the Argon2id+AES-GCM blob stored by the inviter. The acceptor
|
||||
* decrypts it client-side using the invite code they entered. The server never sees
|
||||
* the plaintext phrase.
|
||||
*
|
||||
* Operations (all via Admin SDK, so Firestore rules are bypassed):
|
||||
* 1. Verify caller is authenticated and not already paired.
|
||||
* 2. Look up the invite document by code.
|
||||
* 3. Validate status == 'pending' and not expired.
|
||||
* 4. Prevent self-acceptance.
|
||||
* 5. Create the couple document with the wrapped couple key from the invite.
|
||||
* 6. Update both user documents with the new coupleId.
|
||||
* 7. Mark the invite as accepted.
|
||||
* 2. Rate-limit accept attempts per UID.
|
||||
* 3. Look up the invite document by code.
|
||||
* 4. Validate status == 'pending' and not expired.
|
||||
* 5. Prevent self-acceptance.
|
||||
* 6. Create the couple document with the wrapped couple key from the invite.
|
||||
* 7. Update both user documents with the new coupleId.
|
||||
* 8. Mark the invite as accepted and wipe the encrypted phrase from the doc.
|
||||
*/
|
||||
const ACCEPT_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
||||
const ACCEPT_RATE_LIMIT_MAX = 10; // 10 attempts per hour per user
|
||||
|
|
@ -110,7 +114,7 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
|
|||
const wrappedCoupleKey = invite.wrappedCoupleKey;
|
||||
const kdfSalt = invite.kdfSalt;
|
||||
const kdfParams = invite.kdfParams;
|
||||
const recoveryPhrase = invite.recoveryPhrase;
|
||||
const encryptedRecoveryPhrase = invite.encryptedRecoveryPhrase;
|
||||
if (status !== 'pending') {
|
||||
throw new functions.https.HttpsError('failed-precondition', 'Invite has already been used.');
|
||||
}
|
||||
|
|
@ -149,15 +153,14 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
|
|||
});
|
||||
batch.update(db.collection('users').doc(inviterUserId), { coupleId });
|
||||
batch.update(db.collection('users').doc(callerId), { coupleId });
|
||||
// Wipe the recovery phrase from the invite doc on acceptance.
|
||||
// It served its purpose (returned to the acceptor above); leaving key material
|
||||
// in a document that stays in Firestore for 24h is unnecessary exposure.
|
||||
// Wipe the encrypted phrase blob on acceptance — it has been returned to the acceptor
|
||||
// who will decrypt it client-side. No reason to keep it in Firestore after use.
|
||||
batch.update(inviteRef, {
|
||||
status: 'accepted',
|
||||
acceptedByUserId: callerId,
|
||||
acceptedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
coupleId,
|
||||
recoveryPhrase: admin.firestore.FieldValue.delete(),
|
||||
encryptedRecoveryPhrase: admin.firestore.FieldValue.delete(),
|
||||
});
|
||||
await batch.commit();
|
||||
console.log(`[acceptInviteCallable] ${callerId} accepted invite ${code}; created couple ${coupleId}`);
|
||||
|
|
@ -167,7 +170,7 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
|
|||
wrappedCoupleKey: wrappedCoupleKey !== null && wrappedCoupleKey !== void 0 ? wrappedCoupleKey : null,
|
||||
kdfSalt: kdfSalt !== null && kdfSalt !== void 0 ? kdfSalt : null,
|
||||
kdfParams: kdfParams !== null && kdfParams !== void 0 ? kdfParams : null,
|
||||
recoveryPhrase: recoveryPhrase !== null && recoveryPhrase !== void 0 ? recoveryPhrase : null,
|
||||
encryptedRecoveryPhrase: encryptedRecoveryPhrase !== null && encryptedRecoveryPhrase !== void 0 ? encryptedRecoveryPhrase : null,
|
||||
};
|
||||
});
|
||||
//# sourceMappingURL=acceptInviteCallable.js.map
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -41,15 +41,18 @@ const admin = __importStar(require("firebase-admin"));
|
|||
*
|
||||
* Issue #9 / review2.md Risk #1 fix: clients are no longer allowed to create
|
||||
* invites directly. 6-character document IDs are enumerable, so a direct client
|
||||
* write would expose pending invites to scanning. This function generates a
|
||||
* unique 6-character code server-side, stores the invite document, and returns
|
||||
* only the code and expiry to the inviter.
|
||||
* write would expose pending invites to scanning.
|
||||
*
|
||||
* Request body: { wrappedCoupleKey?: string, kdfSalt?: string, kdfParams?: string, recoveryPhrase?: string }
|
||||
* Request body: { code?: string, wrappedCoupleKey?: string, kdfSalt?: string, kdfParams?: string, encryptedRecoveryPhrase?: string }
|
||||
* - code: client-supplied 6-character code (Android). The server validates uniqueness and
|
||||
* returns an error if taken so the client can retry with a new code. If omitted (iOS),
|
||||
* the server generates the code.
|
||||
* - wrappedCoupleKey: base64-encoded couple key wrapped by the inviter's KDF
|
||||
* - kdfSalt: base64 KDF salt
|
||||
* - kdfParams: KDF parameter tag (e.g. argon2id;v=19;m=47104;t=3;p=1)
|
||||
* - recoveryPhrase: recovery phrase for the invite (optional, stored for acceptor)
|
||||
* - encryptedRecoveryPhrase: Argon2id+AES-GCM blob produced by the Android client using
|
||||
* the invite code as the KDF input. The server stores it opaquely and never sees the
|
||||
* plaintext phrase. Omitted by iOS until iOS implements E2EE parity.
|
||||
*
|
||||
* When E2EE fields are omitted the function writes nulls; iOS MVP creates
|
||||
* plaintext couples (encryptionVersion=0 on the resulting couple) and does not
|
||||
|
|
@ -60,7 +63,7 @@ const admin = __importStar(require("firebase-admin"));
|
|||
* Operations (all via Admin SDK, so Firestore rules are bypassed):
|
||||
* 1. Verify caller is authenticated and not already paired.
|
||||
* 2. Rate-limit the caller to 5 invite creations per rolling hour.
|
||||
* 3. Generate a unique 6-character alphanumeric code via transaction.
|
||||
* 3. Use client-supplied code or generate one server-side; validate uniqueness via transaction.
|
||||
* 4. Write the invite document with a 24-hour TTL.
|
||||
*/
|
||||
const CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
|
|
@ -104,10 +107,11 @@ exports.createInviteCallable = functions.https.onCall(async (data, context) => {
|
|||
if (recentInvites.size >= RATE_LIMIT_MAX) {
|
||||
throw new functions.https.HttpsError('resource-exhausted', 'Too many invites created. Try again later.');
|
||||
}
|
||||
const clientCode = data === null || data === void 0 ? void 0 : data.code;
|
||||
const wrappedCoupleKey = data === null || data === void 0 ? void 0 : data.wrappedCoupleKey;
|
||||
const kdfSalt = data === null || data === void 0 ? void 0 : data.kdfSalt;
|
||||
const kdfParams = data === null || data === void 0 ? void 0 : data.kdfParams;
|
||||
const recoveryPhrase = data === null || data === void 0 ? void 0 : data.recoveryPhrase;
|
||||
const encryptedRecoveryPhrase = data === null || data === void 0 ? void 0 : data.encryptedRecoveryPhrase;
|
||||
// E2EE fields must be supplied together or omitted together.
|
||||
const e2eeFields = [wrappedCoupleKey, kdfSalt, kdfParams];
|
||||
const suppliedE2ee = e2eeFields.filter((v) => v != null).length;
|
||||
|
|
@ -115,20 +119,20 @@ exports.createInviteCallable = functions.https.onCall(async (data, context) => {
|
|||
throw new functions.https.HttpsError('invalid-argument', 'E2EE fields (wrappedCoupleKey, kdfSalt, kdfParams) must all be supplied together or omitted together.');
|
||||
}
|
||||
const expiresAt = admin.firestore.Timestamp.fromMillis(now.toMillis() + INVITE_TTL_MS);
|
||||
// Race-safe unique code creation via transaction. We attempt a bounded number
|
||||
// of times; each attempt verifies the candidate code is free before creating.
|
||||
const maxAttempts = 10;
|
||||
// Android supplies its own code (used as the KDF input for phrase encryption, so the server
|
||||
// must use it as-is). iOS omits the code; the server generates one in that case.
|
||||
// Either way, validate uniqueness via transaction and return an error on collision so the
|
||||
// client can retry with a fresh code.
|
||||
let inviteRef = null;
|
||||
let code = null;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const candidate = generateCode();
|
||||
const candidates = clientCode ? [clientCode] : Array.from({ length: 10 }, generateCode);
|
||||
for (const candidate of candidates) {
|
||||
const candidateRef = db.collection('invites').doc(candidate);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const created = await db.runTransaction(async (tx) => {
|
||||
const snap = await tx.get(candidateRef);
|
||||
if (snap.exists) {
|
||||
if (snap.exists)
|
||||
return false;
|
||||
}
|
||||
tx.set(candidateRef, {
|
||||
code: candidate,
|
||||
inviterUserId: callerId,
|
||||
|
|
@ -141,7 +145,7 @@ exports.createInviteCallable = functions.https.onCall(async (data, context) => {
|
|||
wrappedCoupleKey: wrappedCoupleKey !== null && wrappedCoupleKey !== void 0 ? wrappedCoupleKey : null,
|
||||
kdfSalt: kdfSalt !== null && kdfSalt !== void 0 ? kdfSalt : null,
|
||||
kdfParams: kdfParams !== null && kdfParams !== void 0 ? kdfParams : null,
|
||||
recoveryPhrase: recoveryPhrase !== null && recoveryPhrase !== void 0 ? recoveryPhrase : null,
|
||||
encryptedRecoveryPhrase: encryptedRecoveryPhrase !== null && encryptedRecoveryPhrase !== void 0 ? encryptedRecoveryPhrase : null,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
|
|
@ -152,7 +156,8 @@ exports.createInviteCallable = functions.https.onCall(async (data, context) => {
|
|||
}
|
||||
}
|
||||
if (!code || !inviteRef) {
|
||||
throw new functions.https.HttpsError('internal', 'Could not generate a unique invite code. Please try again.');
|
||||
// Client-supplied code collided; the Android client will retry with a new code.
|
||||
throw new functions.https.HttpsError('already-exists', 'Invite code is already taken. Please try again.');
|
||||
}
|
||||
// Write a server-side audit log entry for the inviter. This is not read by
|
||||
// clients and supports the rate-limit count as well as future abuse review.
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
{"version":3,"file":"createInviteCallable.js","sourceRoot":"","sources":["../../src/couples/createInviteCallable.ts"],"names":[],"mappings":"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"}
|
||||
{"version":3,"file":"createInviteCallable.js","sourceRoot":"","sources":["../../src/couples/createInviteCallable.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,sDAAuC;AAEvC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,MAAM,UAAU,GAAG,kCAAkC,CAAA;AACrD,MAAM,WAAW,GAAG,CAAC,CAAA;AACrB,MAAM,aAAa,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAA;AACzC,MAAM,oBAAoB,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAA;AAC3C,MAAM,cAAc,GAAG,CAAC,CAAA;AAExB,SAAS,YAAY;IACnB,IAAI,IAAI,GAAG,EAAE,CAAA;IACb,MAAM,YAAY,GAAG,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,CAAA;IAC9C,sEAAsE;IACtE,OAAO,CAAC,QAAQ,CAAC,CAAC,cAAc,CAAC,YAAY,CAAC,CAAA;IAC9C,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,WAAW,EAAE,CAAC,EAAE,EAAE,CAAC;QACrC,IAAI,IAAI,UAAU,CAAC,YAAY,CAAC,CAAC,CAAC,GAAG,UAAU,CAAC,MAAM,CAAC,CAAA;IACzD,CAAC;IACD,OAAO,IAAI,CAAA;AACb,CAAC;AAEY,QAAA,oBAAoB,GAAG,SAAS,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,EAAE,IAAS,EAAE,OAAO,EAAE,EAAE;;IACtF,MAAM,QAAQ,GAAG,MAAA,OAAO,CAAC,IAAI,0CAAE,GAAG,CAAA;IAClC,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,MAAM,IAAI,SAAS,CAAC,KAAK,CAAC,UAAU,CAAC,iBAAiB,EAAE,oBAAoB,CAAC,CAAA;IAC/E,CAAC;IAED,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IAE5B,qCAAqC;IACrC,MAAM,SAAS,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,GAAG,EAAE,CAAA;IAClE,IAAI,SAAS,CAAC,MAAM,IAAI,CAAA,MAAA,SAAS,CAAC,IAAI,EAAE,0CAAE,QAAQ,KAAI,IAAI,EAAE,CAAC;QAC3D,MAAM,IAAI,SAAS,CAAC,KAAK,CAAC,UAAU,CAAC,qBAAqB,EAAE,2BAA2B,CAAC,CAAA;IAC1F,CAAC;IAED,MAAM,iBAAiB,GAAG,MAAA,SAAS,CAAC,IAAI,EAAE,0CAAE,WAAiC,CAAA;IAE7E,mEAAmE;IACnE,MAAM,GAAG,GAAG,KAAK,CAAC,SAAS,CAAC,SAAS,CAAC,GAAG,EAAE,CAAA;IAC3C,MAAM,WAAW,GAAG,KAAK,CAAC,SAAS,CAAC,SAAS,CAAC,UAAU,CAAC,GAAG,CAAC,QAAQ,EAAE,GAAG,oBAAoB,CAAC,CAAA;IAC/F,MAAM,kBAAkB,GAAG,EAAE;SAC1B,UAAU,CAAC,SAAS,CAAC;SACrB,KAAK,CAAC,eAAe,EAAE,IAAI,EAAE,QAAQ,CAAC;SACtC,KAAK,CAAC,WAAW,EAAE,IAAI,EAAE,WAAW,CAAC;SACrC,OAAO,CAAC,WAAW,EAAE,MAAM,CAAC;SAC5B,KAAK,CAAC,cAAc,GAAG,CAAC,CAAC,CAAA;IAE5B,MAAM,aAAa,GAAG,MAAM,kBAAkB,CAAC,GAAG,EAAE,CAAA;IACpD,IAAI,aAAa,CAAC,IAAI,IAAI,cAAc,EAAE,CAAC;QACzC,MAAM,IAAI,SAAS,CAAC,KAAK,CAAC,UAAU,CAAC,oBAAoB,EAAE,4CAA4C,CAAC,CAAA;IAC1G,CAAC;IAED,MAAM,UAAU,GAAG,IAAI,aAAJ,IAAI,uBAAJ,IAAI,CAAE,IAA0B,CAAA;IACnD,MAAM,gBAAgB,GAAG,IAAI,aAAJ,IAAI,uBAAJ,IAAI,CAAE,gBAAsC,CAAA;IACrE,MAAM,OAAO,GAAG,IAAI,aAAJ,IAAI,uBAAJ,IAAI,CAAE,OAA6B,CAAA;IACnD,MAAM,SAAS,GAAG,IAAI,aAAJ,IAAI,uBAAJ,IAAI,CAAE,SAA+B,CAAA;IACvD,MAAM,uBAAuB,GAAG,IAAI,aAAJ,IAAI,uBAAJ,IAAI,CAAE,uBAA6C,CAAA;IAEnF,6DAA6D;IAC7D,MAAM,UAAU,GAAG,CAAC,gBAAgB,EAAE,OAAO,EAAE,SAAS,CAAC,CAAA;IACzD,MAAM,YAAY,GAAG,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,MAAM,CAAA;IAC/D,IAAI,YAAY,GAAG,CAAC,IAAI,YAAY,GAAG,UAAU,CAAC,MAAM,EAAE,CAAC;QACzD,MAAM,IAAI,SAAS,CAAC,KAAK,CAAC,UAAU,CAClC,kBAAkB,EAClB,uGAAuG,CACxG,CAAA;IACH,CAAC;IAED,MAAM,SAAS,GAAG,KAAK,CAAC,SAAS,CAAC,SAAS,CAAC,UAAU,CAAC,GAAG,CAAC,QAAQ,EAAE,GAAG,aAAa,CAAC,CAAA;IAEtF,4FAA4F;IAC5F,iFAAiF;IACjF,0FAA0F;IAC1F,sCAAsC;IACtC,IAAI,SAAS,GAA6C,IAAI,CAAA;IAC9D,IAAI,IAAI,GAAkB,IAAI,CAAA;IAE9B,MAAM,UAAU,GAAG,UAAU,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,YAAY,CAAC,CAAA;IAEvF,KAAK,MAAM,SAAS,IAAI,UAAU,EAAE,CAAC;QACnC,MAAM,YAAY,GAAG,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAA;QAE5D,4CAA4C;QAC5C,MAAM,OAAO,GAAG,MAAM,EAAE,CAAC,cAAc,CAAC,KAAK,EAAE,EAAE,EAAE,EAAE;YACnD,MAAM,IAAI,GAAG,MAAM,EAAE,CAAC,GAAG,CAAC,YAAY,CAAC,CAAA;YACvC,IAAI,IAAI,CAAC,MAAM;gBAAE,OAAO,KAAK,CAAA;YAC7B,EAAE,CAAC,GAAG,CAAC,YAAY,EAAE;gBACnB,IAAI,EAAE,SAAS;gBACf,aAAa,EAAE,QAAQ;gBACvB,kBAAkB,EAAE,iBAAiB,aAAjB,iBAAiB,cAAjB,iBAAiB,GAAI,IAAI;gBAC7C,MAAM,EAAE,SAAS;gBACjB,SAAS,EAAE,KAAK,CAAC,SAAS,CAAC,UAAU,CAAC,eAAe,EAAE;gBACvD,SAAS;gBACT,MAAM,EAAE,IAAI;gBACZ,YAAY,EAAE,IAAI;gBAClB,gBAAgB,EAAE,gBAAgB,aAAhB,gBAAgB,cAAhB,gBAAgB,GAAI,IAAI;gBAC1C,OAAO,EAAE,OAAO,aAAP,OAAO,cAAP,OAAO,GAAI,IAAI;gBACxB,SAAS,EAAE,SAAS,aAAT,SAAS,cAAT,SAAS,GAAI,IAAI;gBAC5B,uBAAuB,EAAE,uBAAuB,aAAvB,uBAAuB,cAAvB,uBAAuB,GAAI,IAAI;aACzD,CAAC,CAAA;YACF,OAAO,IAAI,CAAA;QACb,CAAC,CAAC,CAAA;QAEF,IAAI,OAAO,EAAE,CAAC;YACZ,IAAI,GAAG,SAAS,CAAA;YAChB,SAAS,GAAG,YAAY,CAAA;YACxB,MAAK;QACP,CAAC;IACH,CAAC;IAED,IAAI,CAAC,IAAI,IAAI,CAAC,SAAS,EAAE,CAAC;QACxB,gFAAgF;QAChF,MAAM,IAAI,SAAS,CAAC,KAAK,CAAC,UAAU,CAAC,gBAAgB,EAAE,iDAAiD,CAAC,CAAA;IAC3G,CAAC;IAED,2EAA2E;IAC3E,4EAA4E;IAC5E,IAAI,CAAC;QACH,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,UAAU,CAAC,oBAAoB,CAAC,CAAC,GAAG,CAAC;YAC9E,IAAI,EAAE,gBAAgB;YACtB,UAAU,EAAE,IAAI;YAChB,SAAS,EAAE,KAAK,CAAC,SAAS,CAAC,UAAU,CAAC,eAAe,EAAE;YACvD,IAAI,EAAE,IAAI;SACX,CAAC,CAAA;IACJ,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,mEAAmE;QACnE,OAAO,CAAC,IAAI,CAAC,+CAA+C,QAAQ,GAAG,EAAE,GAAG,CAAC,CAAA;IAC/E,CAAC;IAED,OAAO,CAAC,GAAG,CAAC,0BAA0B,QAAQ,mBAAmB,IAAI,aAAa,SAAS,CAAC,MAAM,EAAE,CAAC,WAAW,EAAE,EAAE,CAAC,CAAA;IAErH,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,CAAA;AAC5B,CAAC,CAAC,CAAA"}
|
||||
|
|
@ -14,16 +14,20 @@ import * as admin from 'firebase-admin'
|
|||
* The recovery phrase is stored on the invite document by the inviter and returned
|
||||
* directly — the acceptor never needs to type it manually.
|
||||
*
|
||||
* Response: { coupleId, inviterUserId, wrappedCoupleKey, kdfSalt, kdfParams, recoveryPhrase }
|
||||
* Response: { coupleId, inviterUserId, wrappedCoupleKey, kdfSalt, kdfParams, encryptedRecoveryPhrase }
|
||||
* - encryptedRecoveryPhrase: the Argon2id+AES-GCM blob stored by the inviter. The acceptor
|
||||
* decrypts it client-side using the invite code they entered. The server never sees
|
||||
* the plaintext phrase.
|
||||
*
|
||||
* Operations (all via Admin SDK, so Firestore rules are bypassed):
|
||||
* 1. Verify caller is authenticated and not already paired.
|
||||
* 2. Look up the invite document by code.
|
||||
* 3. Validate status == 'pending' and not expired.
|
||||
* 4. Prevent self-acceptance.
|
||||
* 5. Create the couple document with the wrapped couple key from the invite.
|
||||
* 6. Update both user documents with the new coupleId.
|
||||
* 7. Mark the invite as accepted.
|
||||
* 2. Rate-limit accept attempts per UID.
|
||||
* 3. Look up the invite document by code.
|
||||
* 4. Validate status == 'pending' and not expired.
|
||||
* 5. Prevent self-acceptance.
|
||||
* 6. Create the couple document with the wrapped couple key from the invite.
|
||||
* 7. Update both user documents with the new coupleId.
|
||||
* 8. Mark the invite as accepted and wipe the encrypted phrase from the doc.
|
||||
*/
|
||||
const ACCEPT_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000 // 1 hour
|
||||
const ACCEPT_RATE_LIMIT_MAX = 10 // 10 attempts per hour per user
|
||||
|
|
@ -86,7 +90,7 @@ export const acceptInviteCallable = functions.https.onCall(async (data: any, con
|
|||
const wrappedCoupleKey = invite.wrappedCoupleKey as string | undefined
|
||||
const kdfSalt = invite.kdfSalt as string | undefined
|
||||
const kdfParams = invite.kdfParams as string | undefined
|
||||
const recoveryPhrase = invite.recoveryPhrase as string | undefined
|
||||
const encryptedRecoveryPhrase = invite.encryptedRecoveryPhrase as string | undefined
|
||||
|
||||
if (status !== 'pending') {
|
||||
throw new functions.https.HttpsError('failed-precondition', 'Invite has already been used.')
|
||||
|
|
@ -135,15 +139,14 @@ export const acceptInviteCallable = functions.https.onCall(async (data: any, con
|
|||
batch.update(db.collection('users').doc(inviterUserId), { coupleId })
|
||||
batch.update(db.collection('users').doc(callerId), { coupleId })
|
||||
|
||||
// Wipe the recovery phrase from the invite doc on acceptance.
|
||||
// It served its purpose (returned to the acceptor above); leaving key material
|
||||
// in a document that stays in Firestore for 24h is unnecessary exposure.
|
||||
// Wipe the encrypted phrase blob on acceptance — it has been returned to the acceptor
|
||||
// who will decrypt it client-side. No reason to keep it in Firestore after use.
|
||||
batch.update(inviteRef, {
|
||||
status: 'accepted',
|
||||
acceptedByUserId: callerId,
|
||||
acceptedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
coupleId,
|
||||
recoveryPhrase: admin.firestore.FieldValue.delete(),
|
||||
encryptedRecoveryPhrase: admin.firestore.FieldValue.delete(),
|
||||
})
|
||||
|
||||
await batch.commit()
|
||||
|
|
@ -156,6 +159,6 @@ export const acceptInviteCallable = functions.https.onCall(async (data: any, con
|
|||
wrappedCoupleKey: wrappedCoupleKey ?? null,
|
||||
kdfSalt: kdfSalt ?? null,
|
||||
kdfParams: kdfParams ?? null,
|
||||
recoveryPhrase: recoveryPhrase ?? null,
|
||||
encryptedRecoveryPhrase: encryptedRecoveryPhrase ?? null,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,15 +6,18 @@ import * as admin from 'firebase-admin'
|
|||
*
|
||||
* Issue #9 / review2.md Risk #1 fix: clients are no longer allowed to create
|
||||
* invites directly. 6-character document IDs are enumerable, so a direct client
|
||||
* write would expose pending invites to scanning. This function generates a
|
||||
* unique 6-character code server-side, stores the invite document, and returns
|
||||
* only the code and expiry to the inviter.
|
||||
* write would expose pending invites to scanning.
|
||||
*
|
||||
* Request body: { wrappedCoupleKey?: string, kdfSalt?: string, kdfParams?: string, recoveryPhrase?: string }
|
||||
* Request body: { code?: string, wrappedCoupleKey?: string, kdfSalt?: string, kdfParams?: string, encryptedRecoveryPhrase?: string }
|
||||
* - code: client-supplied 6-character code (Android). The server validates uniqueness and
|
||||
* returns an error if taken so the client can retry with a new code. If omitted (iOS),
|
||||
* the server generates the code.
|
||||
* - wrappedCoupleKey: base64-encoded couple key wrapped by the inviter's KDF
|
||||
* - kdfSalt: base64 KDF salt
|
||||
* - kdfParams: KDF parameter tag (e.g. argon2id;v=19;m=47104;t=3;p=1)
|
||||
* - recoveryPhrase: recovery phrase for the invite (optional, stored for acceptor)
|
||||
* - encryptedRecoveryPhrase: Argon2id+AES-GCM blob produced by the Android client using
|
||||
* the invite code as the KDF input. The server stores it opaquely and never sees the
|
||||
* plaintext phrase. Omitted by iOS until iOS implements E2EE parity.
|
||||
*
|
||||
* When E2EE fields are omitted the function writes nulls; iOS MVP creates
|
||||
* plaintext couples (encryptionVersion=0 on the resulting couple) and does not
|
||||
|
|
@ -25,7 +28,7 @@ import * as admin from 'firebase-admin'
|
|||
* Operations (all via Admin SDK, so Firestore rules are bypassed):
|
||||
* 1. Verify caller is authenticated and not already paired.
|
||||
* 2. Rate-limit the caller to 5 invite creations per rolling hour.
|
||||
* 3. Generate a unique 6-character alphanumeric code via transaction.
|
||||
* 3. Use client-supplied code or generate one server-side; validate uniqueness via transaction.
|
||||
* 4. Write the invite document with a 24-hour TTL.
|
||||
*/
|
||||
|
||||
|
|
@ -77,10 +80,11 @@ export const createInviteCallable = functions.https.onCall(async (data: any, con
|
|||
throw new functions.https.HttpsError('resource-exhausted', 'Too many invites created. Try again later.')
|
||||
}
|
||||
|
||||
const clientCode = data?.code as string | undefined
|
||||
const wrappedCoupleKey = data?.wrappedCoupleKey as string | undefined
|
||||
const kdfSalt = data?.kdfSalt as string | undefined
|
||||
const kdfParams = data?.kdfParams as string | undefined
|
||||
const recoveryPhrase = data?.recoveryPhrase as string | undefined
|
||||
const encryptedRecoveryPhrase = data?.encryptedRecoveryPhrase as string | undefined
|
||||
|
||||
// E2EE fields must be supplied together or omitted together.
|
||||
const e2eeFields = [wrappedCoupleKey, kdfSalt, kdfParams]
|
||||
|
|
@ -94,22 +98,22 @@ export const createInviteCallable = functions.https.onCall(async (data: any, con
|
|||
|
||||
const expiresAt = admin.firestore.Timestamp.fromMillis(now.toMillis() + INVITE_TTL_MS)
|
||||
|
||||
// Race-safe unique code creation via transaction. We attempt a bounded number
|
||||
// of times; each attempt verifies the candidate code is free before creating.
|
||||
const maxAttempts = 10
|
||||
// Android supplies its own code (used as the KDF input for phrase encryption, so the server
|
||||
// must use it as-is). iOS omits the code; the server generates one in that case.
|
||||
// Either way, validate uniqueness via transaction and return an error on collision so the
|
||||
// client can retry with a fresh code.
|
||||
let inviteRef: admin.firestore.DocumentReference | null = null
|
||||
let code: string | null = null
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const candidate = generateCode()
|
||||
const candidates = clientCode ? [clientCode] : Array.from({ length: 10 }, generateCode)
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const candidateRef = db.collection('invites').doc(candidate)
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const created = await db.runTransaction(async (tx) => {
|
||||
const snap = await tx.get(candidateRef)
|
||||
if (snap.exists) {
|
||||
return false
|
||||
}
|
||||
if (snap.exists) return false
|
||||
tx.set(candidateRef, {
|
||||
code: candidate,
|
||||
inviterUserId: callerId,
|
||||
|
|
@ -122,7 +126,7 @@ export const createInviteCallable = functions.https.onCall(async (data: any, con
|
|||
wrappedCoupleKey: wrappedCoupleKey ?? null,
|
||||
kdfSalt: kdfSalt ?? null,
|
||||
kdfParams: kdfParams ?? null,
|
||||
recoveryPhrase: recoveryPhrase ?? null,
|
||||
encryptedRecoveryPhrase: encryptedRecoveryPhrase ?? null,
|
||||
})
|
||||
return true
|
||||
})
|
||||
|
|
@ -135,7 +139,8 @@ export const createInviteCallable = functions.https.onCall(async (data: any, con
|
|||
}
|
||||
|
||||
if (!code || !inviteRef) {
|
||||
throw new functions.https.HttpsError('internal', 'Could not generate a unique invite code. Please try again.')
|
||||
// Client-supplied code collided; the Android client will retry with a new code.
|
||||
throw new functions.https.HttpsError('already-exists', 'Invite code is already taken. Please try again.')
|
||||
}
|
||||
|
||||
// Write a server-side audit log entry for the inviter. This is not read by
|
||||
|
|
|
|||
|
|
@ -56,13 +56,18 @@ struct EmptyStateView: View {
|
|||
let icon: String
|
||||
let title: String
|
||||
let message: String
|
||||
var action: (title: String, handler: () -> Void)?
|
||||
var illustrationName: String? = nil
|
||||
var action: (title: String, handler: () -> Void)? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: CloserSpacing.lg) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.closerPrimary.opacity(0.6))
|
||||
if let illustrationName {
|
||||
CloserIllustrationView(imageName: illustrationName, size: 132)
|
||||
} else {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.closerPrimary.opacity(0.6))
|
||||
}
|
||||
|
||||
Text(title)
|
||||
.font(CloserFont.title3)
|
||||
|
|
@ -84,6 +89,23 @@ struct EmptyStateView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Illustration View
|
||||
|
||||
struct CloserIllustrationView: View {
|
||||
let imageName: String
|
||||
var size: CGFloat = 220
|
||||
var cornerRadius: CGFloat = CloserRadius.xlarge
|
||||
|
||||
var body: some View {
|
||||
Image(imageName)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: size, height: size)
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Partner Status Row
|
||||
|
||||
struct PartnerStatusRow: View {
|
||||
|
|
|
|||
|
|
@ -5,10 +5,23 @@ struct OnboardingView: View {
|
|||
@Binding var showSignUp: Bool
|
||||
@State private var currentPage = 0
|
||||
|
||||
let pages: [(icon: String, title: String, description: String)] = [
|
||||
("heart.fill", "Connect Deeper", "Daily questions, games, and shared experiences designed to bring you closer together."),
|
||||
("lock.fill", "Private & Secure", "Your conversations are private. End-to-end encryption keeps your answers between you and your partner."),
|
||||
("sparkles", "Grow Together", "Build stronger habits, discover new things, and celebrate your journey as a couple.")
|
||||
private let pages = [
|
||||
OnboardingPage(
|
||||
icon: "heart.fill",
|
||||
illustrationName: "illustration-couple-onboarding",
|
||||
title: "Connect Deeper",
|
||||
description: "Daily questions, games, and shared experiences designed to bring you closer together."
|
||||
),
|
||||
OnboardingPage(
|
||||
icon: "lock.fill",
|
||||
title: "Private & Secure",
|
||||
description: "Your conversations are private. End-to-end encryption keeps your answers between you and your partner."
|
||||
),
|
||||
OnboardingPage(
|
||||
icon: "sparkles",
|
||||
title: "Grow Together",
|
||||
description: "Build stronger habits, discover new things, and celebrate your journey as a couple."
|
||||
)
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
|
|
@ -30,15 +43,13 @@ struct OnboardingView: View {
|
|||
TabView(selection: $currentPage) {
|
||||
ForEach(pages.indices, id: \.self) { index in
|
||||
OnboardingPageView(
|
||||
icon: pages[index].icon,
|
||||
title: pages[index].title,
|
||||
description: pages[index].description
|
||||
page: pages[index]
|
||||
)
|
||||
.tag(index)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .always))
|
||||
.frame(height: 260)
|
||||
.frame(height: 330)
|
||||
|
||||
Spacer()
|
||||
|
||||
|
|
@ -67,22 +78,31 @@ struct OnboardingView: View {
|
|||
}
|
||||
}
|
||||
|
||||
struct OnboardingPageView: View {
|
||||
private struct OnboardingPage {
|
||||
let icon: String
|
||||
var illustrationName: String? = nil
|
||||
let title: String
|
||||
let description: String
|
||||
}
|
||||
|
||||
private struct OnboardingPageView: View {
|
||||
let page: OnboardingPage
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: CloserSpacing.xl) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 44))
|
||||
.foregroundColor(.closerPrimary)
|
||||
VStack(spacing: CloserSpacing.lg) {
|
||||
if let illustrationName = page.illustrationName {
|
||||
CloserIllustrationView(imageName: illustrationName, size: 170)
|
||||
} else {
|
||||
Image(systemName: page.icon)
|
||||
.font(.system(size: 44))
|
||||
.foregroundColor(.closerPrimary)
|
||||
}
|
||||
|
||||
Text(title)
|
||||
Text(page.title)
|
||||
.font(CloserFont.title2)
|
||||
.foregroundColor(.closerText)
|
||||
|
||||
Text(description)
|
||||
Text(page.description)
|
||||
.font(CloserFont.body)
|
||||
.foregroundColor(.closerTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
|
|
|||
|
|
@ -11,9 +11,7 @@ struct PairPromptView: View {
|
|||
VStack(spacing: CloserSpacing.xxl) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "link.circle.fill")
|
||||
.font(.system(size: 72))
|
||||
.foregroundColor(.closerPrimary)
|
||||
CloserIllustrationView(imageName: "illustration-couple-invite", size: 190)
|
||||
|
||||
Text("Connect with Your Partner")
|
||||
.font(CloserFont.title1)
|
||||
|
|
|
|||
|
|
@ -321,7 +321,8 @@ struct AnswerHistoryView: View {
|
|||
EmptyStateView(
|
||||
icon: "clock.arrow.circlepath",
|
||||
title: "No Answers Yet",
|
||||
message: "Your answer history will appear here."
|
||||
message: "Your answer history will appear here.",
|
||||
illustrationName: "illustration-couple-history"
|
||||
)
|
||||
.listRowBackground(Color.clear)
|
||||
} else {
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
|
|
@ -135,25 +135,24 @@ final class FirestoreService: @unchecked Sendable {
|
|||
// MARK: - Callable Functions
|
||||
|
||||
extension FirestoreService {
|
||||
func acceptInviteCallable(code: String, recoveryPhrase: String? = nil) async throws -> String {
|
||||
var data: [String: Any] = ["code": code]
|
||||
if let phrase = recoveryPhrase {
|
||||
data["recoveryPhrase"] = phrase
|
||||
}
|
||||
let result = try await functions.httpsCallable("acceptInviteCallable").call(data)
|
||||
// TODO(iOS-E2EE): iOS does not yet generate E2EE keys or encrypt the recovery phrase,
|
||||
// so iOS-originated invites create plaintext couples (encryptionVersion=0). Cross-platform
|
||||
// couples where the Android user invites must go through acceptInviteCallable on Android.
|
||||
// When iOS implements E2EE parity (CryptoKit keyset + Argon2id phrase cipher), update
|
||||
// createInviteCallable to supply wrappedCoupleKey, kdfSalt, kdfParams, and
|
||||
// encryptedRecoveryPhrase, and update acceptInviteCallable to decrypt the phrase.
|
||||
|
||||
func acceptInviteCallable(code: String) async throws -> String {
|
||||
let result = try await functions.httpsCallable("acceptInviteCallable").call(["code": code])
|
||||
guard let coupleId = (result.data as? [String: Any])?["coupleId"] as? String else {
|
||||
throw FirestoreError.invalidResponse
|
||||
}
|
||||
return coupleId
|
||||
}
|
||||
|
||||
func createInviteCallable(inviteCode: String? = nil) async throws -> (code: String, expiresAt: Date) {
|
||||
var data: [String: Any] = [:]
|
||||
// iOS MVP skips E2EE; the server writes null for the wrapped couple key.
|
||||
// When iOS E2EE parity lands, pass wrappedCoupleKey, kdfSalt, kdfParams, recoveryPhrase here.
|
||||
if let code = inviteCode {
|
||||
data["preferredCode"] = code
|
||||
}
|
||||
func createInviteCallable() async throws -> (code: String, expiresAt: Date) {
|
||||
// iOS MVP omits all E2EE fields; server writes nulls and sets encryptionVersion=0.
|
||||
let data: [String: Any] = [:]
|
||||
let result = try await functions.httpsCallable("createInviteCallable").call(data)
|
||||
guard let payload = result.data as? [String: Any],
|
||||
let code = payload["code"] as? String,
|
||||
|
|
|
|||
|
|
@ -32,7 +32,10 @@ let package = Package(
|
|||
.product(name: "GoogleSignIn", package: "GoogleSignIn-iOS"),
|
||||
],
|
||||
path: "Closer",
|
||||
exclude: ["Info.plist", "Closer.entitlements"]
|
||||
exclude: ["Info.plist", "Closer.entitlements"],
|
||||
resources: [
|
||||
.process("Resources")
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "CloserTests",
|
||||
|
|
|
|||
Loading…
Reference in New Issue