feat(login): QR scan + watch-only mode for npub/nprofile

- QR scan button on the Nostr login sheet (eye icon first, QR second)
  scans nsec/npub/nprofile and auto-logs in; scanner rendered at
  Activity level so CameraX binds to the correct lifecycle owner

- nprofile1 login support: decodes TLV, extracts pubkey, saves as
  READ_ONLY alongside existing npub and hex-pubkey paths

- Watch-only onboarding: READ_ONLY logins skip the profile-setup flow
  and go to a two-step WatchOnlyOnboardingScreen (info + relay-wait
  spinner) before landing on the feed

- LocalCanSign CompositionLocal propagates signing capability through
  the entire nav tree; consumed by:
    - PostCard: hides full ActionBar (reply/react/repost/zap)
    - FeedScreen: hides compose FAB; reply callback is no-op
    - WispBottomBar: filters out Messages and Wallet tabs
    - WispDrawerContent: hides Messages and Wallet rows
    - UserProfileScreen: hides edit-profile, follow, zap, DM buttons

- Sidebar account picker shows eye icon next to watch-only accounts

- Keys screen branches: READ_ONLY shows "No private key is stored on
  this device" instead of the reveal button; LOCAL shows QR and copy
  buttons for both npub and nsec (nsec QR requires biometric reveal
  first; user avatar overlaid on both QR codes when available)

- Logout dialog swaps private-key backup warning for
  "Sign back in with your npub anytime" on watch-only accounts
This commit is contained in:
The Daniel
2026-05-18 19:01:33 -04:00
parent 572aad73b6
commit 36c2d014f6
12 changed files with 646 additions and 115 deletions
+32 -9
View File
@@ -80,6 +80,7 @@ import com.wisp.app.ui.screen.KeysScreen
import com.wisp.app.ui.screen.ListScreen
import com.wisp.app.ui.screen.LiveStreamScreen
import com.wisp.app.ui.screen.ExistingUserOnboardingScreen
import com.wisp.app.ui.screen.WatchOnlyOnboardingScreen
import com.wisp.app.ui.screen.LoadingScreen
import com.wisp.app.ui.screen.ListsHubScreen
import com.wisp.app.ui.screen.InterfaceScreen
@@ -125,6 +126,8 @@ import com.wisp.app.viewmodel.LiveStreamViewModel
import com.wisp.app.viewmodel.WalletViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import androidx.compose.runtime.CompositionLocalProvider
import com.wisp.app.ui.util.LocalCanSign
object Routes {
const val SPLASH = "splash"
@@ -163,6 +166,7 @@ object Routes {
const val HASHTAG_FEED = "hashtag/{tag}"
const val HASHTAG_SET_FEED = "hashtag_set/{name}/{tags}"
const val EXISTING_USER_ONBOARDING = "onboarding/existing"
const val WATCH_ONLY_ONBOARDING = "onboarding/watch-only"
const val DRAFTS = "drafts"
const val SOCIAL_GRAPH = "social_graph"
const val POW_SETTINGS = "pow_settings"
@@ -380,7 +384,7 @@ fun WispNavHost(
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val nonAppRoutes = setOf(Routes.SPLASH, Routes.AUTH, Routes.GOOGLE_AUTH, Routes.LOADING, Routes.ONBOARDING_PROFILE, Routes.ONBOARDING_SUGGESTIONS, Routes.ONBOARDING_TOPICS, Routes.ONBOARDING_FIRST_POST, Routes.EXISTING_USER_ONBOARDING)
val nonAppRoutes = setOf(Routes.SPLASH, Routes.AUTH, Routes.GOOGLE_AUTH, Routes.LOADING, Routes.ONBOARDING_PROFILE, Routes.ONBOARDING_SUGGESTIONS, Routes.ONBOARDING_TOPICS, Routes.ONBOARDING_FIRST_POST, Routes.EXISTING_USER_ONBOARDING, Routes.WATCH_ONLY_ONBOARDING)
val hideBottomBarRoutes = nonAppRoutes + Routes.DM_CONVERSATION + Routes.DM_CONVERSATION_GROUP + Routes.CONTACT_PICKER + Routes.GROUP_ROOM + Routes.GROUP_DETAIL + Routes.LIVE_STREAM
val socialGraphDiscoveryState by feedViewModel.extendedNetworkRepo.discoveryState.collectAsState()
val socialGraphComputing = currentRoute == Routes.SOCIAL_GRAPH && (
@@ -589,6 +593,7 @@ fun WispNavHost(
isZapAnimating = isZapAnimating,
isReplyAnimating = isReplyAnimating,
notifSoundEnabled = notifSoundEnabled,
isReadOnly = signingMode == SigningMode.READ_ONLY,
onTabSelected = { tab ->
if (currentRoute == tab.route) {
scrollToTopTrigger++
@@ -614,6 +619,7 @@ fun WispNavHost(
var pipFullScreenAspectRatio by remember { mutableStateOf(16f / 9f) }
Box(modifier = Modifier.padding(innerPadding)) {
CompositionLocalProvider(LocalCanSign provides (signingMode != SigningMode.READ_ONLY)) {
NavHost(
navController = navController,
startDestination = startDestination
@@ -719,7 +725,7 @@ fun WispNavHost(
relayViewModel.reload()
feedViewModel.initRelays()
authViewModel.keyRepo.markOnboardingComplete()
navController.navigate(Routes.LOADING) {
navController.navigate(Routes.WATCH_ONLY_ONBOARDING) {
popUpTo(Routes.AUTH) { inclusive = true }
}
} else {
@@ -778,19 +784,32 @@ fun WispNavHost(
)
}
composable(Routes.WATCH_ONLY_ONBOARDING) {
WatchOnlyOnboardingScreen(
feedViewModel = feedViewModel,
onReady = {
navController.navigate(Routes.FEED) {
popUpTo(Routes.WATCH_ONLY_ONBOARDING) { inclusive = true }
}
}
)
}
composable(Routes.FEED) {
FeedScreen(
viewModel = feedViewModel,
isDarkTheme = isDarkTheme,
onToggleTheme = onToggleTheme,
scrollToTopTrigger = scrollToTopTrigger,
onCompose = {
replyTarget = null
quoteTarget = null
composeViewModel.clear()
navController.navigate(Routes.COMPOSE)
onCompose = if (signingMode == SigningMode.READ_ONLY) null else {
{
replyTarget = null
quoteTarget = null
composeViewModel.clear()
navController.navigate(Routes.COMPOSE)
}
},
onReply = { event ->
onReply = if (signingMode == SigningMode.READ_ONLY) { _ -> } else { event ->
replyTarget = event
quoteTarget = null
composeViewModel.clear()
@@ -2790,9 +2809,12 @@ fun WispNavHost(
}
composable(Routes.KEYS) {
val pubkeyHex = authViewModel.keyRepo.getPubkeyHex()
val avatarUrl = pubkeyHex?.let { feedViewModel.eventRepo.getProfileData(it)?.picture }
KeysScreen(
keyRepository = authViewModel.keyRepo,
onBack = { navController.popBackStack() }
onBack = { navController.popBackStack() },
avatarUrl = avatarUrl
)
}
@@ -3405,6 +3427,7 @@ fun WispNavHost(
.align(Alignment.BottomCenter)
.padding(bottom = 16.dp)
)
} // CompositionLocalProvider
} // Box
} // Scaffold
@@ -59,8 +59,14 @@ fun WispBottomBar(
isZapAnimating: Boolean = false,
isReplyAnimating: Boolean = false,
notifSoundEnabled: Boolean = true,
isReadOnly: Boolean = false,
onTabSelected: (BottomTab) -> Unit
) {
val visibleTabs = if (isReadOnly)
BottomTab.entries.filter { it != BottomTab.WALLET && it != BottomTab.MESSAGES }
else
BottomTab.entries
Column {
HorizontalDivider(
thickness = 0.5.dp,
@@ -70,7 +76,7 @@ fun WispBottomBar(
containerColor = MaterialTheme.colorScheme.surface,
windowInsets = NavigationBarDefaults.windowInsets
) {
BottomTab.entries.forEach { tab ->
visibleTabs.forEach { tab ->
val selected = currentRoute == tab.route
val hasUnread = when (tab) {
BottomTab.HOME -> hasUnreadHome
@@ -90,6 +90,7 @@ import com.wisp.app.repo.Nip05Status
import com.wisp.app.repo.TranslationState
import com.wisp.app.repo.TranslationStatus
import com.wisp.app.ui.theme.WispThemeColors
import com.wisp.app.ui.util.LocalCanSign
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -788,42 +789,45 @@ fun PostCard(
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
ActionBar(
onReply = onReply,
onReact = onReact,
userReactionEmojis = userReactionEmojis,
onRepost = onRepost,
onQuote = onQuote,
hasUserReposted = hasUserReposted,
repostCount = repostCount,
onZap = onZap,
hasUserZapped = hasUserZapped,
onAddToList = onAddToList,
isInList = isInList,
likeCount = likeCount,
replyCount = replyCount,
zapSats = zapSats,
isZapAnimating = isZapAnimating,
isZapInProgress = isZapInProgress,
reactionEmojiUrls = reactionEmojiUrls,
resolvedEmojis = resolvedEmojis,
unicodeEmojis = unicodeEmojis,
onOpenEmojiLibrary = onOpenEmojiLibrary,
isPrivate = isPrivate,
zapEnabled = zapEnabled,
onZapDisabledTap = onZapDisabledTap,
modifier = Modifier.weight(1f)
)
Icon(
imageVector = if (expandedDetails) Icons.Filled.KeyboardArrowUp
else Icons.Filled.KeyboardArrowDown,
contentDescription = if (expandedDetails) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.size(20.dp)
.clickable { expandedDetails = !expandedDetails }
)
val canSign = LocalCanSign.current
if (canSign) {
Row(verticalAlignment = Alignment.CenterVertically) {
ActionBar(
onReply = onReply,
onReact = onReact,
userReactionEmojis = userReactionEmojis,
onRepost = onRepost,
onQuote = onQuote,
hasUserReposted = hasUserReposted,
repostCount = repostCount,
onZap = onZap,
hasUserZapped = hasUserZapped,
onAddToList = onAddToList,
isInList = isInList,
likeCount = likeCount,
replyCount = replyCount,
zapSats = zapSats,
isZapAnimating = isZapAnimating,
isZapInProgress = isZapInProgress,
reactionEmojiUrls = reactionEmojiUrls,
resolvedEmojis = resolvedEmojis,
unicodeEmojis = unicodeEmojis,
onOpenEmojiLibrary = onOpenEmojiLibrary,
isPrivate = isPrivate,
zapEnabled = zapEnabled,
onZapDisabledTap = onZapDisabledTap,
modifier = Modifier.weight(1f)
)
Icon(
imageVector = if (expandedDetails) Icons.Filled.KeyboardArrowUp
else Icons.Filled.KeyboardArrowDown,
contentDescription = if (expandedDetails) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.size(20.dp)
.clickable { expandedDetails = !expandedDetails }
)
}
}
AnimatedVisibility(
visible = expandedDetails,
@@ -45,6 +45,8 @@ import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Shield
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.outlined.Visibility
import com.wisp.app.repo.SigningMode
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -74,6 +76,7 @@ import androidx.compose.ui.unit.dp
import com.wisp.app.nostr.Nip05
import com.wisp.app.nostr.ProfileData
import com.wisp.app.repo.AccountInfo
import com.wisp.app.ui.util.LocalCanSign
@Composable
@@ -114,6 +117,7 @@ fun WispDrawerContent(
) {
val scrollState = rememberScrollState()
val scope = rememberCoroutineScope()
val canSign = LocalCanSign.current
Column(modifier = Modifier
.fillMaxHeight()
.statusBarsPadding()
@@ -310,6 +314,15 @@ fun WispDrawerContent(
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
if (account.signingMode == SigningMode.READ_ONLY) {
Icon(
Icons.Outlined.Visibility,
contentDescription = "Watch-only",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(4.dp))
}
if (isActive) {
Icon(
Icons.Filled.Check,
@@ -389,34 +402,38 @@ fun WispDrawerContent(
onClick = onSearch,
modifier = Modifier.height(48.dp).padding(horizontal = 12.dp)
)
NavigationDrawerItem(
icon = { Icon(Icons.Outlined.Email, contentDescription = null) },
label = { Text(stringResource(R.string.nav_messages)) },
selected = false,
onClick = onMessages,
modifier = Modifier.height(48.dp).padding(horizontal = 12.dp)
)
if (canSign) {
NavigationDrawerItem(
icon = { Icon(Icons.Outlined.Email, contentDescription = null) },
label = { Text(stringResource(R.string.nav_messages)) },
selected = false,
onClick = onMessages,
modifier = Modifier.height(48.dp).padding(horizontal = 12.dp)
)
}
val useZapBolt = com.wisp.app.ui.util.useBoltIcon()
val fiatMode = com.wisp.app.ui.util.isFiatMode()
NavigationDrawerItem(
icon = {
if (fiatMode) {
Icon(Icons.Outlined.AccountBalanceWallet, contentDescription = null)
} else if (useZapBolt) {
Icon(
painter = painterResource(R.drawable.ic_bolt),
contentDescription = null,
modifier = Modifier.size(24.dp)
)
} else {
Icon(Icons.Outlined.CurrencyBitcoin, contentDescription = null)
}
},
label = { Text(stringResource(R.string.nav_wallet)) },
selected = false,
onClick = onWallet,
modifier = Modifier.height(48.dp).padding(horizontal = 12.dp)
)
if (canSign) {
NavigationDrawerItem(
icon = {
if (fiatMode) {
Icon(Icons.Outlined.AccountBalanceWallet, contentDescription = null)
} else if (useZapBolt) {
Icon(
painter = painterResource(R.drawable.ic_bolt),
contentDescription = null,
modifier = Modifier.size(24.dp)
)
} else {
Icon(Icons.Outlined.CurrencyBitcoin, contentDescription = null)
}
},
label = { Text(stringResource(R.string.nav_wallet)) },
selected = false,
onClick = onWallet,
modifier = Modifier.height(48.dp).padding(horizontal = 12.dp)
)
}
NavigationDrawerItem(
icon = { Icon(Icons.Outlined.FormatListBulleted, contentDescription = null) },
label = { Text(stringResource(R.string.drawer_lists)) },
@@ -555,19 +572,23 @@ fun WispDrawerContent(
Column {
Row(verticalAlignment = Alignment.Top) {
Icon(
Icons.Outlined.Key,
if (canSign) Icons.Outlined.Key else Icons.Outlined.Visibility,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.error
tint = if (canSign) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.width(10.dp))
Text(
"Back up your private key before logging out. Without it, your Nostr account cannot be recovered.",
if (canSign)
"Back up your private key before logging out. Without it, your Nostr account cannot be recovered."
else
"Sign back in with your npub anytime.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
if (hasEmbeddedWallet) {
if (canSign && hasEmbeddedWallet) {
Spacer(Modifier.height(14.dp))
Row(verticalAlignment = Alignment.Top) {
Icon(
@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.QrCodeScanner
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.Button
@@ -31,6 +32,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.wisp.app.ui.component.QrScanner
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -68,6 +70,20 @@ fun AuthScreen(
val nsecInput by viewModel.nsecInput.collectAsState()
val error by viewModel.error.collectAsState()
var nsecVisible by remember { mutableStateOf(false) }
var showQrScanner by remember { mutableStateOf(false) }
if (showQrScanner) {
QrScanner(
onResult = { raw ->
showQrScanner = false
viewModel.updateNsecInput(raw.trim())
if (viewModel.logIn()) onAuthenticated(false)
},
modifier = androidx.compose.ui.Modifier.fillMaxSize(),
promptText = "Scan nsec, npub, or nprofile QR"
)
return
}
Column(
modifier = Modifier
@@ -147,11 +163,19 @@ fun AuthScreen(
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)
)
Row {
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)
)
}
IconButton(onClick = { showQrScanner = true }) {
Icon(
imageVector = Icons.Outlined.QrCodeScanner,
contentDescription = "Scan QR code"
)
}
}
},
modifier = Modifier.fillMaxWidth()
@@ -149,7 +149,7 @@ fun FeedScreen(
viewModel: FeedViewModel,
isDarkTheme: Boolean = true,
onToggleTheme: () -> Unit = {},
onCompose: () -> Unit,
onCompose: (() -> Unit)? = null,
onReply: (NostrEvent) -> Unit,
onRelays: () -> Unit,
onProfileEdit: () -> Unit = {},
@@ -993,20 +993,22 @@ fun FeedScreen(
)
},
floatingActionButton = {
val isScrolling = listState.isScrollInProgress
val fabAlpha by animateFloatAsState(
targetValue = if (isScrolling) 0.3f else 1f,
animationSpec = tween(
durationMillis = if (isScrolling) 150 else 400
),
label = "fabAlpha"
)
FloatingActionButton(
onClick = onCompose,
containerColor = MaterialTheme.colorScheme.primary,
modifier = Modifier.graphicsLayer { alpha = fabAlpha }
) {
Icon(Icons.Default.Add, contentDescription = "New post")
if (onCompose != null) {
val isScrolling = listState.isScrollInProgress
val fabAlpha by animateFloatAsState(
targetValue = if (isScrolling) 0.3f else 1f,
animationSpec = tween(
durationMillis = if (isScrolling) 150 else 400
),
label = "fabAlpha"
)
FloatingActionButton(
onClick = onCompose,
containerColor = MaterialTheme.colorScheme.primary,
modifier = Modifier.graphicsLayer { alpha = fabAlpha }
) {
Icon(Icons.Default.Add, contentDescription = "New post")
}
}
}
) { padding ->
@@ -15,10 +15,21 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import android.graphics.Bitmap
import android.graphics.Color as AndroidColor
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import coil3.compose.AsyncImage
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.QrCode
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -27,7 +38,17 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
@@ -48,6 +69,7 @@ import com.wisp.app.R
import com.wisp.app.nostr.Nip19
import com.wisp.app.nostr.hexToByteArray
import com.wisp.app.repo.KeyRepository
import com.wisp.app.ui.component.QrCodeDialog
private fun android.content.Context.findFragmentActivity(): FragmentActivity? {
var ctx = this
@@ -62,7 +84,8 @@ private fun android.content.Context.findFragmentActivity(): FragmentActivity? {
@Composable
fun KeysScreen(
keyRepository: KeyRepository,
onBack: () -> Unit
onBack: () -> Unit,
avatarUrl: String? = null
) {
val pubkeyHex = remember { keyRepository.getPubkeyHex() }
val keypair = remember { keyRepository.getKeypair() }
@@ -71,6 +94,7 @@ fun KeysScreen(
?: keypair?.let { Nip19.npubEncode(it.pubkey) }
}
var nsec by remember { mutableStateOf<String?>(null) }
var showNpubQr by remember { mutableStateOf(false) }
val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current
@@ -127,7 +151,14 @@ fun KeysScreen(
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.width(8.dp))
Spacer(modifier = Modifier.width(4.dp))
IconButton(onClick = { if (pubkeyHex != null) showNpubQr = true }) {
Icon(
Icons.Outlined.QrCode,
contentDescription = "Show QR code",
tint = MaterialTheme.colorScheme.primary
)
}
IconButton(onClick = {
npub?.let {
clipboardManager.setText(AnnotatedString(it))
@@ -143,15 +174,38 @@ fun KeysScreen(
}
}
if (showNpubQr && pubkeyHex != null) {
QrCodeDialog(
pubkeyHex = pubkeyHex,
avatarUrl = avatarUrl,
onDismiss = { showNpubQr = false }
)
}
Spacer(modifier = Modifier.height(24.dp))
PrivateKeySection(
nsec = nsec,
onReveal = { nsec = it },
keypair = keypair,
revealPrivateKeyTitle = revealPrivateKeyTitle,
revealPrivateKeyDescription = revealPrivateKeyDescription
)
if (keyRepository.isReadOnly()) {
Text(
text = stringResource(R.string.settings_private_key),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "No private key is stored on this device.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
PrivateKeySection(
nsec = nsec,
onReveal = { nsec = it },
keypair = keypair,
revealPrivateKeyTitle = revealPrivateKeyTitle,
revealPrivateKeyDescription = revealPrivateKeyDescription,
avatarUrl = avatarUrl
)
}
}
}
}
@@ -162,7 +216,8 @@ private fun PrivateKeySection(
onReveal: (String) -> Unit,
keypair: com.wisp.app.nostr.Keys.Keypair?,
revealPrivateKeyTitle: String,
revealPrivateKeyDescription: String
revealPrivateKeyDescription: String,
avatarUrl: String? = null
) {
val context = LocalContext.current
@@ -173,6 +228,8 @@ private fun PrivateKeySection(
)
Spacer(modifier = Modifier.height(4.dp))
var showNsecQr by remember { mutableStateOf(false) }
if (nsec != null) {
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Row(
@@ -188,7 +245,14 @@ private fun PrivateKeySection(
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.width(8.dp))
Spacer(modifier = Modifier.width(4.dp))
IconButton(onClick = { showNsecQr = true }) {
Icon(
Icons.Outlined.QrCode,
contentDescription = "Show QR code",
tint = MaterialTheme.colorScheme.primary
)
}
IconButton(onClick = {
val clip = ClipData.newPlainText("", nsec)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -208,6 +272,10 @@ private fun PrivateKeySection(
}
}
}
if (showNsecQr) {
NsecQrDialog(nsec = nsec, avatarUrl = avatarUrl, onDismiss = { showNsecQr = false })
}
} else {
Button(
onClick = {
@@ -262,3 +330,72 @@ private fun PrivateKeySection(
color = MaterialTheme.colorScheme.error
)
}
@Composable
private fun NsecQrDialog(nsec: String, avatarUrl: String? = null, onDismiss: () -> Unit) {
val qrBitmap = remember(nsec) {
val hints = mapOf(EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M)
val writer = QRCodeWriter()
val size = 512
val matrix = writer.encode(nsec, BarcodeFormat.QR_CODE, size, size, hints)
val bmp = Bitmap.createBitmap(matrix.width, matrix.height, Bitmap.Config.RGB_565)
for (x in 0 until matrix.width)
for (y in 0 until matrix.height)
bmp.setPixel(x, y, if (matrix[x, y]) AndroidColor.BLACK else AndroidColor.WHITE)
bmp
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Private key QR") },
text = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Only show this to devices you trust. Anyone who scans it gains full control of your account.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
Spacer(Modifier.height(16.dp))
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(240.dp)
.clip(RoundedCornerShape(12.dp))
.background(androidx.compose.ui.graphics.Color.White)
.padding(8.dp)
) {
Image(
bitmap = qrBitmap.asImageBitmap(),
contentDescription = "nsec QR code",
contentScale = ContentScale.Fit,
modifier = Modifier.matchParentSize()
)
if (avatarUrl != null) {
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(androidx.compose.ui.graphics.Color.White)
.padding(3.dp)
) {
AsyncImage(
model = avatarUrl,
contentDescription = "Avatar",
contentScale = ContentScale.Crop,
modifier = Modifier
.matchParentSize()
.clip(CircleShape)
)
}
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text("Done") }
}
)
}
@@ -18,8 +18,10 @@ 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.QrCodeScanner
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import com.wisp.app.ui.component.QrScanner
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
@@ -90,6 +92,7 @@ fun SplashScreen(
val surfaceVariant = MaterialTheme.colorScheme.surfaceVariant
var showNostrSheet by remember { mutableStateOf(false) }
var showQrScanner by remember { mutableStateOf(false) }
BoxWithConstraints(modifier = Modifier.fillMaxSize().background(backgroundColor)) {
val cols = ((maxWidth + AVATAR_GAP) / (AVATAR_SIZE + AVATAR_GAP)).toInt().coerceAtLeast(1)
@@ -278,9 +281,40 @@ fun SplashScreen(
onLoggedIn = {
showNostrSheet = false
onLoggedIn()
},
onScanQr = {
showNostrSheet = false
showQrScanner = true
}
)
}
if (showQrScanner) {
androidx.compose.ui.window.Dialog(
onDismissRequest = { showQrScanner = false },
properties = androidx.compose.ui.window.DialogProperties(
usePlatformDefaultWidth = false,
dismissOnBackPress = true,
dismissOnClickOutside = false
)
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(androidx.compose.ui.graphics.Color.Black)
) {
QrScanner(
onResult = { raw ->
showQrScanner = false
authViewModel.updateNsecInput(raw.trim())
if (authViewModel.logIn()) onLoggedIn()
},
modifier = Modifier.fillMaxSize(),
promptText = "Scan nsec, npub, or nprofile QR"
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -289,7 +323,8 @@ private fun NostrLoginSheet(
authViewModel: AuthViewModel,
onDismiss: () -> Unit,
onAccountCreated: () -> Unit,
onLoggedIn: () -> Unit
onLoggedIn: () -> Unit,
onScanQr: () -> Unit = {}
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val context = LocalContext.current
@@ -342,11 +377,19 @@ private fun NostrLoginSheet(
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)
)
Row {
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)
)
}
IconButton(onClick = onScanQr) {
Icon(
imageVector = Icons.Outlined.QrCodeScanner,
contentDescription = "Scan QR code"
)
}
}
},
enabled = !isCreating,
@@ -108,6 +108,7 @@ import com.wisp.app.ui.component.ProfileQrSheet
import com.wisp.app.ui.component.ProfilePicture
import com.wisp.app.ui.component.RichContent
import com.wisp.app.ui.component.ZapDialog
import com.wisp.app.ui.util.LocalCanSign
import com.wisp.app.viewmodel.ProfileSortMode
import com.wisp.app.viewmodel.UserProfileViewModel
import android.content.ClipData
@@ -1137,6 +1138,7 @@ private fun ProfileHeader(
isBlocked: Boolean = false
) {
var fullScreenImageUrl by remember { mutableStateOf<String?>(null) }
val canSign = LocalCanSign.current
if (fullScreenImageUrl != null) {
FullScreenImageViewer(
@@ -1181,11 +1183,11 @@ private fun ProfileHeader(
onClick = profile?.picture?.let { url -> { fullScreenImageUrl = url } }
)
Spacer(Modifier.weight(1f))
if (isOwnProfile) {
if (canSign && isOwnProfile) {
OutlinedButton(onClick = onEditProfile) {
Text(stringResource(R.string.profile_edit))
}
} else {
} else if (canSign) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (onSendDm != null) {
IconButton(onClick = onSendDm) {
@@ -0,0 +1,246 @@
package com.wisp.app.ui.screen
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.wisp.app.viewmodel.FeedViewModel
import kotlinx.coroutines.delay
private enum class WatchOnlyStep { INFO, WAITING }
@Composable
fun WatchOnlyOnboardingScreen(
feedViewModel: FeedViewModel,
onReady: () -> Unit
) {
BackHandler { /* disable back during onboarding */ }
val feed by feedViewModel.feed.collectAsState()
val backgroundReady = feed.size >= 5
var stepIndex by rememberSaveable { mutableIntStateOf(0) }
val currentStep = WatchOnlyStep.entries[stepIndex]
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
AnimatedContent(
targetState = currentStep,
transitionSpec = {
fadeIn(animationSpec = tween(300)) togetherWith fadeOut(animationSpec = tween(200))
},
label = "watch-only-step"
) { step ->
when (step) {
WatchOnlyStep.INFO -> WatchOnlyInfoStep(
onContinue = { stepIndex = WatchOnlyStep.WAITING.ordinal }
)
WatchOnlyStep.WAITING -> WatchOnlyWaitingStep(
backgroundReady = backgroundReady,
onReady = {
feedViewModel.markLoadingComplete()
onReady()
}
)
}
}
}
}
@Composable
private fun WatchOnlyInfoStep(onContinue: () -> Unit) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Outlined.Visibility,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.height(24.dp))
Text(
text = "Watch-only mode",
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(16.dp))
Text(
text = "You're signed in with a public key. You can read posts, follow accounts, and browse the network — but posting, reacting, and zapping require a private key.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
lineHeight = 24.sp
)
Spacer(Modifier.height(40.dp))
Button(
onClick = onContinue,
modifier = Modifier.fillMaxWidth()
) {
Text("Start watching")
}
}
}
}
@Composable
private fun WatchOnlyWaitingStep(
backgroundReady: Boolean,
onReady: () -> Unit
) {
var minTimeElapsed by remember { mutableStateOf(false) }
var hasNavigated by remember { mutableStateOf(false) }
val messages = listOf(
"Finding relays…",
"Mapping the network…",
"Loading your feed…",
"Almost there…"
)
var messageIndex by remember { mutableIntStateOf(0) }
LaunchedEffect(Unit) {
delay(1500)
minTimeElapsed = true
}
LaunchedEffect(Unit) {
while (true) {
delay(2500)
messageIndex = (messageIndex + 1) % messages.size
}
}
LaunchedEffect(backgroundReady, minTimeElapsed) {
if (hasNavigated) return@LaunchedEffect
if (backgroundReady && minTimeElapsed) {
hasNavigated = true
onReady()
}
}
LaunchedEffect(Unit) {
delay(30_000)
if (!hasNavigated) {
hasNavigated = true
onReady()
}
}
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
if (backgroundReady) {
Text(
text = "You're all set",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(24.dp))
Button(onClick = {
if (!hasNavigated) {
hasNavigated = true
onReady()
}
}) {
Text("Let's go")
}
} else {
CircularProgressIndicator(
modifier = Modifier.size(48.dp),
color = MaterialTheme.colorScheme.primary,
strokeWidth = 3.dp
)
Spacer(Modifier.height(24.dp))
Text(
text = "Setting things up…",
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(12.dp))
AnimatedContent(
targetState = messages[messageIndex],
transitionSpec = { fadeIn() togetherWith fadeOut() },
label = "watch-waiting-msg"
) { text ->
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
}
}
}
@@ -0,0 +1,6 @@
package com.wisp.app.ui.util
import androidx.compose.runtime.compositionLocalOf
/** True when the active account has a private key and can sign events. */
val LocalCanSign = compositionLocalOf { true }
@@ -74,6 +74,7 @@ class AuthViewModel(app: Application) : AndroidViewModel(app) {
return when {
input.startsWith("nsec1") -> loginWithNsec(input)
input.startsWith("npub1") -> loginWithNpub(input)
input.startsWith("nprofile1") -> loginWithNprofile(input)
input.length == 64 && input.all { it in '0'..'9' || it in 'a'..'f' } -> loginWithPubkeyHex(input)
else -> {
_error.value = "Invalid key format — enter an nsec or npub"
@@ -116,6 +117,22 @@ class AuthViewModel(app: Application) : AndroidViewModel(app) {
}
}
private fun loginWithNprofile(nprofile: String): Boolean {
return try {
val profile = Nip19.nprofileDecode(nprofile)
keyRepo.savePubkeyReadOnly(profile.pubkey)
keyRepo.reloadPrefs(profile.pubkey)
_npub.value = Nip19.npubEncode(profile.pubkey.hexToByteArray())
_signingMode.value = SigningMode.READ_ONLY
_nsecInput.value = ""
_error.value = null
true
} catch (e: Exception) {
_error.value = "Invalid nprofile: ${e.message}"
false
}
}
private fun loginWithPubkeyHex(hex: String): Boolean {
return try {
keyRepo.savePubkeyReadOnly(hex)