feat(pairing): CreateInviteScreen invite-code UX, MainActivity nav wiring, LocalQuestionContent question pool expansion, Future.md planning
This commit is contained in:
parent
f6291e1f2e
commit
7b1443e578
File diff suppressed because one or more lines are too long
13
Future.md
13
Future.md
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue