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:
@@ -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>
|
||||
@@ -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. -->
|
||||
|
||||
Reference in New Issue
Block a user