feat(auth): collapse splash buttons into Continue with Nostr with key autofill

Replaces the separate Create Account / Log In buttons on the splash with a
single purple-ostrich Continue with Nostr button. Tapping opens a bottom
sheet to either paste an existing nsec/npub or generate a new account.

New accounts trigger a CredentialManager save-password prompt so the device
password manager can store the nsec under the npub. Tapping the input field
fires a one-shot getCredential request, letting the password manager fill
the field from previously saved keys.
This commit is contained in:
Barry Deen
2026-05-15 16:15:07 -04:00
parent aa552fc3a8
commit 237efec2f4
6 changed files with 342 additions and 30 deletions
+19 -7
View File
@@ -613,15 +613,27 @@ fun WispNavHost(
composable(Routes.SPLASH) {
SplashScreen(
viewModel = splashViewModel,
onSignUp = {
if (authViewModel.signUp()) {
navController.navigate(Routes.ONBOARDING_PROFILE) {
popUpTo(Routes.SPLASH) { inclusive = true }
}
authViewModel = authViewModel,
onAccountCreated = {
navController.navigate(Routes.ONBOARDING_PROFILE) {
popUpTo(Routes.SPLASH) { inclusive = true }
}
},
onLogIn = {
navController.navigate(Routes.AUTH)
onLoggedIn = {
feedViewModel.reloadForNewAccount()
relayViewModel.reload()
blossomServersViewModel.reload()
composeViewModel.reloadBlossomRepo()
feedViewModel.initRelays()
walletViewModel.refreshState()
authViewModel.keyRepo.markOnboardingComplete()
val target = if (authViewModel.keyRepo.isReadOnly())
Routes.LOADING
else
Routes.EXISTING_USER_ONBOARDING
navController.navigate(target) {
popUpTo(Routes.SPLASH) { inclusive = true }
}
},
onContinueWithGoogle = {
navController.navigate(Routes.GOOGLE_AUTH)
@@ -0,0 +1,63 @@
package com.wisp.app.auth
import android.content.Context
import android.util.Log
import androidx.credentials.CreatePasswordRequest
import androidx.credentials.CredentialManager
import androidx.credentials.GetCredentialRequest
import androidx.credentials.GetPasswordOption
import androidx.credentials.PasswordCredential
import androidx.credentials.exceptions.CreateCredentialCancellationException
import androidx.credentials.exceptions.CreateCredentialException
import androidx.credentials.exceptions.GetCredentialCancellationException
import androidx.credentials.exceptions.GetCredentialException
import androidx.credentials.exceptions.NoCredentialException
object NostrCredentialSaver {
private const val TAG = "NostrCredentialSaver"
suspend fun saveNsec(context: Context, npub: String, nsec: String): Boolean {
return try {
val cm = CredentialManager.create(context)
val request = CreatePasswordRequest(id = npub, password = nsec)
cm.createCredential(context, request)
true
} catch (e: CreateCredentialCancellationException) {
Log.d(TAG, "User dismissed the save-credential prompt")
false
} catch (e: CreateCredentialException) {
Log.w(TAG, "Credential Manager could not save the nsec: ${e.type} ${e.message}")
false
} catch (e: Exception) {
Log.w(TAG, "Unexpected error saving nsec to password manager", e)
false
}
}
/**
* Asks Credential Manager for a saved password (nsec). Shows the system
* picker if any saved credentials match this app, otherwise returns null
* silently. The returned string is the password field of the chosen
* PasswordCredential for accounts created through Wisp this is the nsec.
*/
suspend fun loadSavedNsec(context: Context): String? {
return try {
val cm = CredentialManager.create(context)
val request = GetCredentialRequest(listOf(GetPasswordOption()))
val response = cm.getCredential(context, request)
(response.credential as? PasswordCredential)?.password
} catch (e: GetCredentialCancellationException) {
Log.d(TAG, "User dismissed the credential picker")
null
} catch (e: NoCredentialException) {
Log.d(TAG, "No saved credentials available")
null
} catch (e: GetCredentialException) {
Log.w(TAG, "Credential Manager could not load credentials: ${e.type} ${e.message}")
null
} catch (e: Exception) {
Log.w(TAG, "Unexpected error loading credentials", e)
null
}
}
}
@@ -1,6 +1,6 @@
package com.wisp.app.ui.screen
import androidx.compose.foundation.Image
import androidx.activity.ComponentActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
@@ -15,23 +15,38 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.BorderStroke
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
@@ -40,16 +55,23 @@ import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.res.stringResource
import coil3.compose.AsyncImage
import com.wisp.app.R
import com.wisp.app.auth.NostrCredentialSaver
import com.wisp.app.viewmodel.AuthViewModel
import com.wisp.app.viewmodel.LiveMetrics
import com.wisp.app.viewmodel.SplashViewModel
import kotlinx.coroutines.launch
private val AVATAR_SIZE = 44.dp
private val AVATAR_GAP = 4.dp
@@ -57,8 +79,9 @@ private val AVATAR_GAP = 4.dp
@Composable
fun SplashScreen(
viewModel: SplashViewModel,
onSignUp: () -> Unit,
onLogIn: () -> Unit,
authViewModel: AuthViewModel,
onAccountCreated: () -> Unit,
onLoggedIn: () -> Unit,
onContinueWithGoogle: () -> Unit
) {
val profilePictures by viewModel.profilePictures.collectAsState()
@@ -66,11 +89,12 @@ fun SplashScreen(
val backgroundColor = MaterialTheme.colorScheme.background
val surfaceVariant = MaterialTheme.colorScheme.surfaceVariant
var showNostrSheet by remember { mutableStateOf(false) }
BoxWithConstraints(modifier = Modifier.fillMaxSize().background(backgroundColor)) {
val cols = ((maxWidth + AVATAR_GAP) / (AVATAR_SIZE + AVATAR_GAP)).toInt().coerceAtLeast(1)
val screenHeightPx = constraints.maxHeight.toFloat()
// Use real pictures, or placeholder circles while loading
val pics = profilePictures.ifEmpty {
val placeholderRows = ((maxHeight + AVATAR_GAP) / (AVATAR_SIZE + AVATAR_GAP)).toInt() + 1
List(placeholderRows * cols) { "" }
@@ -78,7 +102,6 @@ fun SplashScreen(
val rows = (pics.size + cols - 1) / cols
// Background collage — each picture shown at most once, no cycling
Column(modifier = Modifier.align(Alignment.TopCenter)) {
for (row in 0 until rows) {
Row {
@@ -86,8 +109,6 @@ fun SplashScreen(
val idx = row * cols + col
if (idx >= pics.size) break
val url = pics[idx]
// Background circle always visible; image loads on top.
// Slow or failed loads show the filled circle instead of a gap.
Box(
modifier = Modifier
.size(AVATAR_SIZE)
@@ -110,7 +131,6 @@ fun SplashScreen(
}
}
// Gradient fades the collage into the background toward the bottom
Box(
modifier = Modifier
.fillMaxSize()
@@ -123,7 +143,6 @@ fun SplashScreen(
)
)
// Logo, tagline, and action buttons pinned to bottom
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
@@ -217,20 +236,195 @@ fun SplashScreen(
Spacer(Modifier.height(8.dp))
OutlinedButton(
onClick = onSignUp,
modifier = Modifier.fillMaxWidth()
Button(
onClick = { showNostrSheet = true },
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
shape = RoundedCornerShape(24.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF1A0E2E),
contentColor = Color(0xFFE9DDFF)
),
border = BorderStroke(1.dp, Color(0xFF8E30EB))
) {
Text(stringResource(R.string.splash_create_account))
Icon(
painter = painterResource(R.drawable.ic_nostr_ostrich),
contentDescription = null,
tint = Color.Unspecified,
modifier = Modifier.size(22.dp)
)
Spacer(Modifier.width(12.dp))
Text(
text = stringResource(R.string.splash_continue_with_nostr),
style = MaterialTheme.typography.labelLarge.copy(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Medium,
fontSize = 15.sp
)
)
}
}
}
if (showNostrSheet) {
NostrLoginSheet(
authViewModel = authViewModel,
onDismiss = { showNostrSheet = false },
onAccountCreated = {
showNostrSheet = false
onAccountCreated()
},
onLoggedIn = {
showNostrSheet = false
onLoggedIn()
}
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun NostrLoginSheet(
authViewModel: AuthViewModel,
onDismiss: () -> Unit,
onAccountCreated: () -> Unit,
onLoggedIn: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val context = LocalContext.current
val scope = rememberCoroutineScope()
val nsecInput by authViewModel.nsecInput.collectAsState()
val error by authViewModel.error.collectAsState()
var nsecVisible by remember { mutableStateOf(false) }
var isCreating by remember { mutableStateOf(false) }
var autofillRequested by remember { mutableStateOf(false) }
ModalBottomSheet(
onDismissRequest = { if (!isCreating) onDismiss() },
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
painter = painterResource(R.drawable.ic_nostr_ostrich),
contentDescription = stringResource(R.string.cd_nostr_logo),
tint = Color.Unspecified,
modifier = Modifier.size(48.dp)
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.nostr_sheet_title),
style = MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.W600
),
color = MaterialTheme.colorScheme.onSurface
)
Spacer(Modifier.height(6.dp))
Text(
text = stringResource(R.string.nostr_sheet_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(20.dp))
OutlinedTextField(
value = nsecInput,
onValueChange = { authViewModel.updateNsecInput(it) },
label = { Text(stringResource(R.string.auth_nsec_or_npub)) },
singleLine = true,
visualTransformation = if (nsecVisible) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
trailingIcon = {
IconButton(onClick = { nsecVisible = !nsecVisible }) {
Icon(
imageVector = if (nsecVisible) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
contentDescription = if (nsecVisible) stringResource(R.string.auth_hide_key) else stringResource(R.string.auth_show_key)
)
}
},
enabled = !isCreating,
modifier = Modifier
.fillMaxWidth()
.onFocusChanged { focusState ->
if (focusState.isFocused && !autofillRequested && nsecInput.isBlank()) {
autofillRequested = true
val activity = context as? ComponentActivity
?: return@onFocusChanged
scope.launch {
val saved = NostrCredentialSaver.loadSavedNsec(activity)
if (!saved.isNullOrBlank() && authViewModel.nsecInput.value.isBlank()) {
authViewModel.updateNsecInput(saved)
}
}
}
}
)
Spacer(Modifier.height(12.dp))
Button(
onClick = {
if (authViewModel.logIn()) onLoggedIn()
},
enabled = nsecInput.isNotBlank() && !isCreating,
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
shape = RoundedCornerShape(24.dp)
) {
Text(stringResource(R.string.auth_log_in))
}
Spacer(Modifier.height(8.dp))
Spacer(Modifier.height(20.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f))
Spacer(Modifier.height(20.dp))
OutlinedButton(
onClick = onLogIn,
modifier = Modifier.fillMaxWidth()
onClick = {
if (isCreating) return@OutlinedButton
scope.launch {
isCreating = true
try {
if (authViewModel.signUp()) {
val nsec = authViewModel.getCurrentNsec()
val npub = authViewModel.npub.value
val activity = context as? ComponentActivity
if (activity != null && nsec != null && npub != null) {
NostrCredentialSaver.saveNsec(activity, npub, nsec)
}
onAccountCreated()
}
} finally {
isCreating = false
}
}
},
enabled = !isCreating,
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
shape = RoundedCornerShape(24.dp)
) {
Text(stringResource(R.string.splash_log_in))
Text(
if (isCreating) stringResource(R.string.nostr_sheet_creating)
else stringResource(R.string.nostr_sheet_create)
)
}
error?.let {
Spacer(Modifier.height(12.dp))
Text(
text = it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
}
}
}
@@ -259,9 +453,3 @@ private fun OnlineCard(metrics: LiveMetrics) {
}
}
}
private fun formatCount(n: Int): String = when {
n >= 1_000_000 -> "${"%.1f".format(n / 1_000_000f)}M"
n >= 1_000 -> "${"%.1f".format(n / 1_000f)}k"
else -> n.toString()
}
@@ -34,6 +34,11 @@ class AuthViewModel(app: Application) : AndroidViewModel(app) {
val isLoggedIn: Boolean get() = keyRepo.isLoggedIn()
fun getCurrentNsec(): String? {
val keypair = keyRepo.getKeypair() ?: return null
return Nip19.nsecEncode(keypair.privkey)
}
fun updateNsecInput(value: String) {
_nsecInput.value = value
_error.value = null
@@ -0,0 +1,36 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="64"
android:viewportHeight="64">
<path
android:fillColor="#8E30EB"
android:pathData="M34,52 L37,52 L38,60 L40,60 L40,62 L30,62 L30,60 L33,60 Z M46,52 L49,52 L50,60 L52,60 L52,62 L42,62 L42,60 L45,60 Z" />
<path
android:fillColor="#8E30EB"
android:pathData="M40,42 m-18,0 a18,14 0 1,0 36,0 a18,14 0 1,0 -36,0 Z" />
<path
android:fillColor="#8E30EB"
android:pathData="M55,28 C58,22 62,22 60,32 L55,34 Z" />
<path
android:strokeColor="#8E30EB"
android:strokeWidth="7"
android:strokeLineCap="round"
android:pathData="M28,34 C20,28 16,20 18,12" />
<path
android:fillColor="#8E30EB"
android:pathData="M18,12 m-6.5,0 a6.5,6.5 0 1,0 13,0 a6.5,6.5 0 1,0 -13,0 Z" />
<path
android:fillColor="#FFB940"
android:pathData="M12,12 L3,10 L3,14 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M15,10 m-1.2,0 a1.2,1.2 0 1,0 2.4,0 a1.2,1.2 0 1,0 -2.4,0 Z" />
</vector>
+8
View File
@@ -46,6 +46,14 @@
<string name="splash_create_account">Create Account</string>
<string name="splash_log_in">Log In</string>
<string name="splash_continue_with_google">Continue with Google</string>
<string name="splash_continue_with_nostr">Continue with Nostr</string>
<string name="cd_nostr_logo">Nostr logo</string>
<!-- Continue-with-Nostr bottom sheet -->/
<string name="nostr_sheet_title">Continue with Nostr</string>
<string name="nostr_sheet_body">Enter your existing key, or create a new account. Your key never leaves the device.</string>
<string name="nostr_sheet_create">Create new account</string>
<string name="nostr_sheet_creating">Creating…</string>
<!-- Google Sign-In / Drive Backup -->
<!-- Replace this empty string with your OAuth 2.0 Web Client ID from Google Cloud Console. -->