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
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.
- **Biometric app-lock re-locks on cold-start/process-death but maybe not plain background→resume.** R15 code review:
`MainActivity` gates `AppNavigation` behind `BiometricLockScreen` when `biometricLoginEnabled` and `sessionVerified`
is false; `sessionVerified` is a `remember{}` that resets on Activity recreation (cold-start, process death) — so the
lock re-arms there — but a plain background→foreground without recreation keeps `sessionVerified = true`, so it may not
re-prompt. Architecturally sound (no compose-tree bypass; content isn't composed until unlocked), but consider
re-locking on `ON_STOP`/timeout so a picked-up unlocked phone re-prompts. *Prompted by:* R15 Pass M code audit (not
live-tested — emulator has no enrolled biometric).
- **✅ DONE (R21) — Biometric app-lock now re-arms on background/timeout (was: only cold-start/process-death).**
`MainActivity` observes the lifecycle: while the lock is on and the session is unlocked, it records when the app is
backgrounded and **re-locks if it returns after >60s away** (`BIOMETRIC_RELOCK_GRACE_MS`) — so a picked-up, already-open
phone re-prompts, not only on Activity recreation. The grace window avoids re-locking on quick task-switches (the
biometric prompt, photo picker, share sheet). Code-complete + compiles; **live re-lock not yet driven** — emulators have
no enrolled biometric/PIN, so verify on a physical device. (SECURITY.md rec #7.)
> 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.os.Bundle
import android.os.SystemClock
import android.util.Log
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
@ -14,6 +15,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
@ -26,6 +28,9 @@ import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import androidx.activity.result.contract.ActivityResultContracts
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.lifecycleScope
import android.Manifest
import android.content.pm.PackageManager
@ -52,6 +57,13 @@ import dagger.hilt.android.AndroidEntryPoint
import java.io.File
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
class MainActivity : AppCompatActivity() {
@Inject lateinit var settingsRepository: SettingsRepository
@ -121,9 +133,34 @@ class MainActivity : AppCompatActivity() {
}
var sessionVerified by remember { mutableStateOf(false) }
val needsBiometricLock = settings.biometricLoginEnabled
&& authRepository.currentUserId != null
&& !sessionVerified
val lockEnabled = settings.biometricLoginEnabled && authRepository.currentUserId != null
// 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) {
SideEffect {

View File

@ -39,9 +39,13 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -290,6 +294,48 @@ fun CreateInviteScreen(
Spacer(Modifier.width(6.dp))
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 = {},
showHelp = false
)
QuestionAnswerInput(
question = question,
pendingWrittenText = state.pendingWrittenText,
pendingSelectedOptionIds = state.pendingSelectedOptionIds,
pendingScaleValue = state.pendingScaleValue,
onWrittenTextChanged = onWrittenTextChanged,
onOptionToggled = onOptionToggled,
onScaleChanged = onScaleChanged,
onSubmit = onSubmit,
canSubmit = canSubmit,
isSubmitting = false
)
// Show the editable answer form only until it's saved. Once submitted, the
// SubmittedAnswerCard below replaces it — leaving the form (with its "Save
// privately" button) on screen after answering made it look like you still
// needed to answer / could re-answer.
if (!state.submitted) {
QuestionAnswerInput(
question = question,
pendingWrittenText = state.pendingWrittenText,
pendingSelectedOptionIds = state.pendingSelectedOptionIds,
pendingScaleValue = state.pendingScaleValue,
onWrittenTextChanged = onWrittenTextChanged,
onOptionToggled = onOptionToggled,
onScaleChanged = onScaleChanged,
onSubmit = onSubmit,
canSubmit = canSubmit,
isSubmitting = false
)
}
AnimatedVisibility(
visible = state.submitted,
@ -250,10 +256,13 @@ private fun SubmittedAnswerCard(
question: Question,
state: LocalQuestionUiState
) {
val label = when {
state.isRevealed -> "Answer revealed"
!state.partnerHasAnswered -> "Private answer saved — waiting for partner"
else -> "Private answer saved"
// This card shows the user's OWN answer, so title it as such. The status line carries the
// shared state — the old "Answer revealed" title read as if both answers were shown here, when
// the mutual reveal actually lives behind the "View reveal" button.
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(
modifier = Modifier.fillMaxWidth(),
@ -282,7 +291,7 @@ private fun SubmittedAnswerCard(
}
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(
text = label,
text = "Your answer",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold
@ -290,10 +299,15 @@ private fun SubmittedAnswerCard(
Text(
text = answerSummary(question, state),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
text = status,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}