feat(pairing): CreateInviteScreen invite-code UX, MainActivity nav wiring, LocalQuestionContent question pool expansion, Future.md planning

This commit is contained in:
null 2026-06-29 21:44:26 -05:00
parent f6291e1f2e
commit 7b1443e578
5 changed files with 125 additions and 28 deletions

File diff suppressed because one or more lines are too long

View File

@ -64,13 +64,12 @@ Improvement & feature ideas surfaced while QA-testing as a consumer (each works
Check token** (raw Firestore REST) returned `200` for a member — so rules are the *sole* gate. Rules correctly deny Check token** (raw Firestore REST) returned `200` for a member — so rules are the *sole* gate. Rules correctly deny
non-members/cross-couple (all `403`), so this is not a live hole, but enabling App Check enforcement on Firestore non-members/cross-couple (all `403`), so this is not a live hole, but enabling App Check enforcement on Firestore
would block non-app clients entirely (defense-in-depth). *Prompted by:* R7 D3 raw-API angle. would block non-app clients entirely (defense-in-depth). *Prompted by:* R7 D3 raw-API angle.
- **Biometric app-lock re-locks on cold-start/process-death but maybe not plain background→resume.** R15 code review: - **✅ DONE (R21) — Biometric app-lock now re-arms on background/timeout (was: only cold-start/process-death).**
`MainActivity` gates `AppNavigation` behind `BiometricLockScreen` when `biometricLoginEnabled` and `sessionVerified` `MainActivity` observes the lifecycle: while the lock is on and the session is unlocked, it records when the app is
is false; `sessionVerified` is a `remember{}` that resets on Activity recreation (cold-start, process death) — so the backgrounded and **re-locks if it returns after >60s away** (`BIOMETRIC_RELOCK_GRACE_MS`) — so a picked-up, already-open
lock re-arms there — but a plain background→foreground without recreation keeps `sessionVerified = true`, so it may not phone re-prompts, not only on Activity recreation. The grace window avoids re-locking on quick task-switches (the
re-prompt. Architecturally sound (no compose-tree bypass; content isn't composed until unlocked), but consider biometric prompt, photo picker, share sheet). Code-complete + compiles; **live re-lock not yet driven** — emulators have
re-locking on `ON_STOP`/timeout so a picked-up unlocked phone re-prompts. *Prompted by:* R15 Pass M code audit (not no enrolled biometric/PIN, so verify on a physical device. (SECURITY.md rec #7.)
live-tested — emulator has no enrolled biometric).
> Artwork to generate (ChatGPT prompts, house-style-matched) lives in `ClaudeBrandingReview.md`, not here. > Artwork to generate (ChatGPT prompts, house-style-matched) lives in `ClaudeBrandingReview.md`, not here.

View File

@ -2,6 +2,7 @@ package app.closer
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.os.SystemClock
import android.util.Log import android.util.Log
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
@ -14,6 +15,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -26,6 +28,9 @@ import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import android.Manifest import android.Manifest
import android.content.pm.PackageManager import android.content.pm.PackageManager
@ -52,6 +57,13 @@ import dagger.hilt.android.AndroidEntryPoint
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
/**
* How long the app may sit in the background before the biometric app-lock re-arms. A short grace
* avoids re-locking on quick task-switches (the biometric prompt, a photo picker, the share sheet)
* while still re-prompting a picked-up phone that's been away for a while. SECURITY.md rec #7.
*/
private const val BIOMETRIC_RELOCK_GRACE_MS = 60_000L
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@Inject lateinit var settingsRepository: SettingsRepository @Inject lateinit var settingsRepository: SettingsRepository
@ -121,9 +133,34 @@ class MainActivity : AppCompatActivity() {
} }
var sessionVerified by remember { mutableStateOf(false) } var sessionVerified by remember { mutableStateOf(false) }
val needsBiometricLock = settings.biometricLoginEnabled val lockEnabled = settings.biometricLoginEnabled && authRepository.currentUserId != null
&& authRepository.currentUserId != null
&& !sessionVerified // Auto re-lock: while unlocked, drop the verified session once the app has been in the
// background past the grace window, so a picked-up, already-open phone re-prompts — not
// only on cold-start. (Previously `sessionVerified` survived background→resume, so an
// unlocked phone stayed open indefinitely. SECURITY.md rec #7.)
if (lockEnabled && sessionVerified) {
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
var backgroundedAt = 0L
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_STOP -> backgroundedAt = SystemClock.elapsedRealtime()
Lifecycle.Event.ON_START ->
if (backgroundedAt != 0L &&
SystemClock.elapsedRealtime() - backgroundedAt >= BIOMETRIC_RELOCK_GRACE_MS
) {
sessionVerified = false
}
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
}
val needsBiometricLock = lockEnabled && !sessionVerified
CloserTheme(darkTheme = useDarkTheme) { CloserTheme(darkTheme = useDarkTheme) {
SideEffect { SideEffect {

View File

@ -39,9 +39,13 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -290,6 +294,48 @@ fun CreateInviteScreen(
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text("Copy phrase", style = MaterialTheme.typography.labelMedium) Text("Copy phrase", style = MaterialTheme.typography.labelMedium)
} }
// Save-confirmation: prove you actually wrote it down by re-typing one
// word. The phrase is the only way to recover your history, so don't let
// pairing feel "done" until it's been saved. SECURITY.md rec #6.
val phraseWords = remember(phrase) {
phrase.trim().split(Regex("\\s+")).filter { it.isNotBlank() }
}
val challengeIndex = remember(phrase) {
if (phraseWords.isEmpty()) 0 else phraseWords.indices.random()
}
var confirmWord by remember(phrase) { mutableStateOf("") }
val confirmed = phraseWords.getOrNull(challengeIndex)
?.equals(confirmWord.trim(), ignoreCase = true) == true
Spacer(Modifier.height(4.dp))
if (confirmed) {
Text(
"✓ Saved — you're all set.",
style = MaterialTheme.typography.labelMedium,
color = SettingsPrimaryDeep,
fontWeight = FontWeight.SemiBold
)
} else {
Text(
"Confirm you saved it — type word #${challengeIndex + 1}.",
style = MaterialTheme.typography.labelMedium,
color = SettingsMuted
)
OutlinedTextField(
value = confirmWord,
onValueChange = { confirmWord = it },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
placeholder = { Text("word #${challengeIndex + 1}") },
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = SettingsPrimaryDeep,
unfocusedBorderColor = SettingsMuted.copy(alpha = 0.4f),
cursorColor = SettingsPrimaryDeep
)
)
}
} }
} }
} }

View File

@ -124,18 +124,24 @@ fun LocalQuestionContent(
onToggleHelp = {}, onToggleHelp = {},
showHelp = false showHelp = false
) )
QuestionAnswerInput( // Show the editable answer form only until it's saved. Once submitted, the
question = question, // SubmittedAnswerCard below replaces it — leaving the form (with its "Save
pendingWrittenText = state.pendingWrittenText, // privately" button) on screen after answering made it look like you still
pendingSelectedOptionIds = state.pendingSelectedOptionIds, // needed to answer / could re-answer.
pendingScaleValue = state.pendingScaleValue, if (!state.submitted) {
onWrittenTextChanged = onWrittenTextChanged, QuestionAnswerInput(
onOptionToggled = onOptionToggled, question = question,
onScaleChanged = onScaleChanged, pendingWrittenText = state.pendingWrittenText,
onSubmit = onSubmit, pendingSelectedOptionIds = state.pendingSelectedOptionIds,
canSubmit = canSubmit, pendingScaleValue = state.pendingScaleValue,
isSubmitting = false onWrittenTextChanged = onWrittenTextChanged,
) onOptionToggled = onOptionToggled,
onScaleChanged = onScaleChanged,
onSubmit = onSubmit,
canSubmit = canSubmit,
isSubmitting = false
)
}
AnimatedVisibility( AnimatedVisibility(
visible = state.submitted, visible = state.submitted,
@ -250,10 +256,13 @@ private fun SubmittedAnswerCard(
question: Question, question: Question,
state: LocalQuestionUiState state: LocalQuestionUiState
) { ) {
val label = when { // This card shows the user's OWN answer, so title it as such. The status line carries the
state.isRevealed -> "Answer revealed" // shared state — the old "Answer revealed" title read as if both answers were shown here, when
!state.partnerHasAnswered -> "Private answer saved — waiting for partner" // the mutual reveal actually lives behind the "View reveal" button.
else -> "Private answer saved" val status = when {
state.isRevealed -> "Revealed together — open View reveal to compare"
!state.partnerHasAnswered -> "Saved privately — waiting for your partner"
else -> "Saved privately — ready to reveal together"
} }
Surface( Surface(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -282,7 +291,7 @@ private fun SubmittedAnswerCard(
} }
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text( Text(
text = label, text = "Your answer",
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold
@ -290,10 +299,15 @@ private fun SubmittedAnswerCard(
Text( Text(
text = answerSummary(question, state), text = answerSummary(question, state),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurface,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Text(
text = status,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} }
} }
} }