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
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,11 @@ fun LocalQuestionContent(
|
||||||
onToggleHelp = {},
|
onToggleHelp = {},
|
||||||
showHelp = false
|
showHelp = 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(
|
QuestionAnswerInput(
|
||||||
question = question,
|
question = question,
|
||||||
pendingWrittenText = state.pendingWrittenText,
|
pendingWrittenText = state.pendingWrittenText,
|
||||||
|
|
@ -136,6 +141,7 @@ fun LocalQuestionContent(
|
||||||
canSubmit = canSubmit,
|
canSubmit = canSubmit,
|
||||||
isSubmitting = false
|
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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue