mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
Merge pull request #3909 from nrobi144/feat/key-backup-nsec-exposure
Discoverable key backup: guided first-run save-your-keys + Settings backup (Desktop) + Android nudge
This commit is contained in:
@@ -217,6 +217,12 @@ private object PrefKeys {
|
||||
const val VIEWED_POLL_RESULT_NOTE_IDS = "viewed_poll_result_note_ids"
|
||||
const val PENDING_ATTESTATIONS = "pending_attestations"
|
||||
|
||||
// Per-account one-shot flag: false only for freshly-GENERATED accounts that
|
||||
// haven't yet backed up their secret key. Absent (defaults to true) for every
|
||||
// account logged in via an existing nsec/bunker/external signer — those already
|
||||
// hold their key elsewhere and must not be nudged.
|
||||
const val HAS_BACKED_UP_KEYS = "has_backed_up_keys"
|
||||
|
||||
const val ALL_ACCOUNT_INFO = "all_saved_accounts_info"
|
||||
const val SHARED_SETTINGS = "shared_settings"
|
||||
const val LATEST_PAYMENT_TARGETS = "latestPaymentTargets"
|
||||
@@ -690,6 +696,36 @@ object LocalPreferences {
|
||||
}
|
||||
}
|
||||
|
||||
// Reactive, per-account cache of the "has backed up keys" flag so the home-screen
|
||||
// nudge updates the instant the user backs up or dismisses it, without a full
|
||||
// account reload. Keyed by npub. Seeded lazily from encrypted storage.
|
||||
private val hasBackedUpKeysFlows: MutableMap<String, MutableStateFlow<Boolean>> = mutableMapOf()
|
||||
private val hasBackedUpKeysMutex = Mutex()
|
||||
|
||||
private suspend fun hasBackedUpKeysFlow(npub: String): MutableStateFlow<Boolean> =
|
||||
hasBackedUpKeysMutex.withLock {
|
||||
hasBackedUpKeysFlows.getOrPut(npub) {
|
||||
val stored =
|
||||
withContext(Dispatchers.IO) {
|
||||
encryptedPreferences(npub).getBoolean(PrefKeys.HAS_BACKED_UP_KEYS, true)
|
||||
}
|
||||
MutableStateFlow(stored)
|
||||
}
|
||||
}
|
||||
|
||||
/** Reactive flag: true (default) unless a freshly-generated account still needs to back up its key. */
|
||||
suspend fun hasBackedUpKeys(npub: String): MutableStateFlow<Boolean> = hasBackedUpKeysFlow(npub)
|
||||
|
||||
suspend fun setHasBackedUpKeys(
|
||||
value: Boolean,
|
||||
npub: String,
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
encryptedPreferences(npub).edit { putBoolean(PrefKeys.HAS_BACKED_UP_KEYS, value) }
|
||||
}
|
||||
hasBackedUpKeysFlow(npub).value = value
|
||||
}
|
||||
|
||||
val mutex = Mutex()
|
||||
|
||||
suspend fun loadAccountConfigFromEncryptedStorage(npub: String): AccountSettings? {
|
||||
|
||||
@@ -279,6 +279,12 @@ class AccountSessionManager(
|
||||
|
||||
localPreferences.setDefaultAccount(accountSettings)
|
||||
|
||||
// Freshly-generated key: mark it as not-yet-backed-up so the home screen
|
||||
// nudges the user to save their secret key. Accounts logged in via an
|
||||
// existing nsec/bunker/external signer never get this false flag (the
|
||||
// pref defaults to true), so only brand-new accounts are nudged.
|
||||
localPreferences.setHasBackedUpKeys(false, accountSettings.keyPair.pubKey.toNpub())
|
||||
|
||||
startUI(accountSettings, routeBuilder = { Route.ImportFollowsSelectUser })
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
|
||||
+13
-4
@@ -89,6 +89,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterA
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.live.RenderEphemeralBubble
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.live.RenderGeohashBubble
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.live.RenderLiveActivityBubble
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup.BackupKeysNudge
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
@@ -290,14 +291,22 @@ private fun HomePages(
|
||||
)
|
||||
}
|
||||
|
||||
HomeAlgoFeedStatusBanner(
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = paddingValues.calculateTopPadding()),
|
||||
)
|
||||
) {
|
||||
BackupKeysNudge(
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
HomeAlgoFeedStatusBanner(
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+31
-1
@@ -21,8 +21,10 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ClipData
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.view.WindowManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.ActivityResult
|
||||
@@ -50,6 +52,7 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -59,6 +62,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.autofill.ContentType
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.Clipboard
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -84,6 +88,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.components.util.getText
|
||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
@@ -104,8 +109,12 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNsec
|
||||
import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Best-effort delay before the plaintext nsec is wiped from the clipboard. */
|
||||
private const val CLIPBOARD_CLEAR_DELAY_MS = 60_000L
|
||||
|
||||
@Composable
|
||||
fun AccountBackupScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
@@ -131,6 +140,18 @@ private fun AccountBackupScreenContent(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
// Redact the secret key from screenshots and the app switcher while this
|
||||
// screen is on-screen. Cleared on dispose so the flag never leaks to other
|
||||
// screens. This is the only FLAG_SECURE usage in the app — scoped on purpose.
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(context) {
|
||||
val window = context.getFragmentActivity()?.window
|
||||
window?.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
|
||||
onDispose {
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBarWithBackButton(
|
||||
@@ -368,14 +389,23 @@ private fun copyNSec(
|
||||
clipboardManager: Clipboard,
|
||||
) {
|
||||
account.settings.keyPair.privKey?.let {
|
||||
val nsec = it.toNsec()
|
||||
scope.launch {
|
||||
clipboardManager.setText(it.toNsec())
|
||||
clipboardManager.setText(nsec)
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
stringRes(context, R.string.secret_key_copied_to_clipboard),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
|
||||
// Best-effort auto-clear: after a delay, wipe the clipboard only if it
|
||||
// still holds this exact nsec (don't clobber anything copied since).
|
||||
// On Android 13+ the OS also shows its own sensitive-content UI.
|
||||
delay(CLIPBOARD_CLEAR_DELAY_MS)
|
||||
if (clipboardManager.getText() == nsec) {
|
||||
clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("", "")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Soft, dismissible "back up your keys" nudge shown on the home feed for freshly
|
||||
* generated accounts that haven't saved their secret key yet. It never blocks
|
||||
* navigation: the user either backs up (navigates to [Route.AccountBackup]) or
|
||||
* confirms they already saved the key. Both actions flip the per-account
|
||||
* [LocalPreferences.setHasBackedUpKeys] flag so the nudge stops appearing.
|
||||
*/
|
||||
@Composable
|
||||
fun BackupKeysNudge(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val npub =
|
||||
accountViewModel.account.signer.pubKey
|
||||
.hexToByteArray()
|
||||
.toNpub()
|
||||
|
||||
// Seed the reactive flag off a background read. Rendering only proceeds once the
|
||||
// flow resolves, so the observing composable never conditionally calls hooks.
|
||||
val flow by produceState<MutableStateFlow<Boolean>?>(initialValue = null, key1 = npub) {
|
||||
value = LocalPreferences.hasBackedUpKeys(npub)
|
||||
}
|
||||
|
||||
flow?.let { stateFlow ->
|
||||
WatchBackupKeysNudge(stateFlow, npub, nav, modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WatchBackupKeysNudge(
|
||||
stateFlow: MutableStateFlow<Boolean>,
|
||||
npub: String,
|
||||
nav: INav,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val hasBackedUp by stateFlow.collectAsStateWithLifecycle()
|
||||
if (hasBackedUp) return
|
||||
|
||||
BackupKeysNudgeCard(
|
||||
modifier = modifier,
|
||||
onBackupNow = {
|
||||
// Best-effort: opening the backup screen counts as backing up so the
|
||||
// nudge doesn't linger after the user follows through.
|
||||
scope.launch { LocalPreferences.setHasBackedUpKeys(true, npub) }
|
||||
nav.nav(Route.AccountBackup)
|
||||
},
|
||||
onAlreadySaved = {
|
||||
scope.launch { LocalPreferences.setHasBackedUpKeys(true, npub) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BackupKeysNudgeCard(
|
||||
modifier: Modifier = Modifier,
|
||||
onBackupNow: () -> Unit,
|
||||
onAlreadySaved: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
tonalElevation = 4.dp,
|
||||
shadowElevation = 4.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Key,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
Text(
|
||||
text = stringRes(R.string.backup_keys_nudge_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = onAlreadySaved) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Close,
|
||||
contentDescription = stringRes(R.string.backup_keys_nudge_dismiss),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
|
||||
Text(
|
||||
text = stringRes(R.string.backup_keys_nudge_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(onClick = onAlreadySaved) {
|
||||
Text(stringRes(R.string.backup_keys_nudge_already_saved))
|
||||
}
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
Button(onClick = onBackupNow) {
|
||||
Text(stringRes(R.string.backup_keys_nudge_backup_now))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -532,6 +532,12 @@
|
||||
\n\nIf you lose your password, you will not be able to recover your key.
|
||||
</string>
|
||||
|
||||
<string name="backup_keys_nudge_title">Back up your keys</string>
|
||||
<string name="backup_keys_nudge_body">Your secret key is the only way to access this account. If you lose it, it can never be recovered. Save it somewhere safe now.</string>
|
||||
<string name="backup_keys_nudge_backup_now">Back up now</string>
|
||||
<string name="backup_keys_nudge_already_saved">I saved them</string>
|
||||
<string name="backup_keys_nudge_dismiss">Dismiss</string>
|
||||
|
||||
<string name="failed_to_encrypt_key">Failed to encrypt your private key</string>
|
||||
<string name="secret_key_copied_to_clipboard">Secret key (nsec) copied to clipboard</string>
|
||||
<string name="copy_my_secret_key">Copy my secret key</string>
|
||||
|
||||
@@ -18,10 +18,46 @@
|
||||
|
||||
<!-- New Key Warning -->
|
||||
<string name="new_key_warning_title">IMPORTANT: Save your keys!</string>
|
||||
<string name="new_key_warning_message">Your secret key (nsec) is the ONLY way to access your account. If you lose it, your account is gone forever. Save it somewhere safe!</string>
|
||||
<string name="new_key_warning_message">Your secret key (nsec) is like a password that can NEVER be reset or recovered. It is the ONLY way to access your account, and anyone who has it controls your account forever. If you lose it, your account is gone for good. Never share it — store it in a password manager.</string>
|
||||
<string name="new_key_public_label">Public Key (shareable):</string>
|
||||
<string name="new_key_secret_label">Secret Key (NEVER share this!):</string>
|
||||
<string name="new_key_continue_button">I've saved my keys, continue</string>
|
||||
<string name="new_key_saved_checkbox">I have saved my keys somewhere safe</string>
|
||||
<string name="new_key_copy_encrypted_button">Copy encrypted (recommended)</string>
|
||||
<string name="new_key_encrypt_password_label">Password for encrypted backup</string>
|
||||
<string name="new_key_step_indicator">Step %1$d of %2$d</string>
|
||||
<string name="new_key_next">Next</string>
|
||||
<string name="new_key_back">Back</string>
|
||||
<string name="new_key_cancel">Cancel</string>
|
||||
<string name="new_key_step_intro_title">Save your keys before you continue</string>
|
||||
<string name="new_key_step_intro_npub">Your public key (npub) is your shareable address. Give it out freely so people can find and follow you.</string>
|
||||
<string name="new_key_step_intro_nsec">Your secret key (nsec) is a password that can NEVER be reset. This is the only time it is shown — save it now, or you will lose access to this account forever.</string>
|
||||
<string name="new_key_keys_title">Your keys</string>
|
||||
<string name="new_key_confirm_title">One last thing</string>
|
||||
<string name="new_key_confirm_recap">Store your secret key in a password manager. If you lose it, no one can recover it for you.</string>
|
||||
<string name="new_key_readonly_info">This is a read-only account. No secret key was generated, so there is nothing to back up.</string>
|
||||
|
||||
<!-- Key Backup (Desktop settings) -->
|
||||
<string name="backup_keys_title">Backup Keys</string>
|
||||
<string name="backup_keys_public_label">Public key (npub)</string>
|
||||
<string name="backup_keys_public_help">Your public key is safe to share. Give it to people so they can find and follow you.</string>
|
||||
<string name="backup_keys_show_qr">Show QR</string>
|
||||
<string name="backup_keys_hide_qr">Hide QR</string>
|
||||
<string name="backup_keys_secret_label">Secret key (nsec)</string>
|
||||
<string name="backup_keys_secret_warning">Your secret key is like a password that can NEVER be reset or recovered. Anyone who has it controls your account forever. Never share it; store it in a password manager.</string>
|
||||
<string name="backup_keys_secret_hidden">Secret key is hidden</string>
|
||||
<string name="backup_keys_reveal">Reveal secret key</string>
|
||||
<string name="backup_keys_hide">Hide secret key</string>
|
||||
<string name="backup_keys_copy_plain">Copy secret key</string>
|
||||
<string name="backup_keys_copy_plain_warning">Copies the plaintext nsec. Only paste it into a trusted password manager.</string>
|
||||
<string name="backup_keys_copy_encrypted">Copy encrypted (recommended)</string>
|
||||
<string name="backup_keys_encrypt_password_label">Password for encrypted backup</string>
|
||||
<string name="backup_keys_encrypt_failed">Could not encrypt the key. Please try again.</string>
|
||||
<string name="backup_keys_external_signer">This account uses an external signer — no secret key is stored here.</string>
|
||||
<string name="backup_keys_copied">Copied!</string>
|
||||
<string name="backup_keys_copy">Copy</string>
|
||||
<string name="backup_keys_unlock_title">Reveal secret key</string>
|
||||
<string name="backup_keys_unlock_subtitle">Enter your privacy-lock password to reveal your secret key.</string>
|
||||
|
||||
<!-- Common Actions -->
|
||||
<string name="action_copy">Copy</string>
|
||||
|
||||
+1
-1
@@ -27,4 +27,4 @@ package com.vitorpamplona.amethyst.commons.privacylock
|
||||
* together, but each scope keeps its own [PrivacyLockState] so that unlock,
|
||||
* idle-timer, and leave-route transitions apply independently per route.
|
||||
*/
|
||||
enum class LockScope { Messages, Wallet }
|
||||
enum class LockScope { Messages, Wallet, KeyBackup }
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# Key Backup & nsec Exposure — Manual Testing Sheet
|
||||
|
||||
Branch: `feat/key-backup-nsec-exposure` (worktree `.claude/worktrees/feat-key-backup`)
|
||||
Date: 2026-08-11
|
||||
Build: Desktop `./gradlew :desktopApp:run` · Android `./gradlew :amethyst:installPlayDebug`
|
||||
|
||||
Legend: ✅ pass · ❌ fail (note what happened) · ⏭️ skipped
|
||||
|
||||
Design invariants to keep verifying throughout:
|
||||
- **npub** = shareable → plain, copyable, QR OK.
|
||||
- **nsec** = unrecoverable password → masked by default, gated reveal + gated copy,
|
||||
encrypted (NIP-49) option, **NEVER shown as a QR code**.
|
||||
|
||||
---
|
||||
|
||||
## A. Desktop — New-account warning card (`NewKeyWarningCard`)
|
||||
|
||||
Precondition: launch Desktop, log out / add account, choose **Generate New Account**.
|
||||
|
||||
- [ ] A1. Card shows title + strengthened warning wording ("can never be reset/recovered").
|
||||
- [ ] A2. **npub** shown; its **Copy** button copies → paste elsewhere matches the npub.
|
||||
- [ ] A3. **nsec** shown; its **Copy** button copies → paste matches the nsec (`nsec1…`).
|
||||
- [ ] A4. **Copy encrypted (recommended)**: type a password → button copies an `ncryptsec1…`
|
||||
string (paste to verify prefix). Empty password → button disabled / no-op.
|
||||
- [ ] A5. **"I have saved my keys"** checkbox: Continue is disabled until checked (soft gate);
|
||||
checking it enables Continue.
|
||||
- [ ] A6. Continue proceeds into the app with the generated account logged in.
|
||||
- [ ] A7. **No QR code** anywhere on this card.
|
||||
|
||||
## B. Desktop — Backup Keys card in Settings/Profile (`BackupKeysCard`)
|
||||
|
||||
Precondition: logged in with an **internal-key** account (has nsec). Open Settings → Profile.
|
||||
|
||||
- [ ] B1. "Backup Keys" card is visible in the profile/settings screen (discoverable — the
|
||||
original complaint was "couldn't find it in settings").
|
||||
- [ ] B2. **npub** row: monospace value + **Copy** works; **Show QR** renders a QR; **Hide QR** hides it.
|
||||
- [ ] B3. QR scans/points to the npub (optional: scan with a phone).
|
||||
- [ ] B4. **nsec** section: warning banner shown; key is **masked** ("hidden" placeholder),
|
||||
not revealed on load.
|
||||
|
||||
### B-lock. Reveal gating via PrivacyLock
|
||||
|
||||
Case 1 — PrivacyLock **NOT** set up (no master password configured):
|
||||
- [ ] B5. Click **Reveal secret key** → nsec reveals immediately (baseline: masked + explicit
|
||||
toggle, no password). Acceptable per design.
|
||||
|
||||
Case 2 — PrivacyLock **enabled** (set a master password in Messages/Wallet privacy-lock settings first):
|
||||
- [ ] B6. Click **Reveal secret key** → a **modal Dialog** appears asking to unlock (does NOT
|
||||
take over / expand the whole settings pane).
|
||||
- [ ] B7. Wrong password → stays locked; **Cancel/dismiss** the dialog → nsec stays masked.
|
||||
- [ ] B8. Correct password → dialog closes, nsec reveals.
|
||||
|
||||
### B-copy. Revealed secret-key actions
|
||||
- [ ] B9. **Copy secret key** (plaintext) copies the `nsec1…`; a red plaintext warning is visible.
|
||||
- [ ] B10. **Clipboard auto-clear**: after copying plaintext nsec, wait ~60s without copying
|
||||
anything else → paste → clipboard is **empty**. If you copy something else within 60s,
|
||||
that value is **preserved** (auto-clear only wipes if clipboard still holds the nsec).
|
||||
- [ ] B11. **Copy encrypted (recommended)**: enter password → copies `ncryptsec1…`; toggle the
|
||||
password visibility eye works; wrong/blank handled (button disabled while blank; failure
|
||||
shows the error supporting text). Encrypted copy is **not** auto-cleared (it's password-safe).
|
||||
- [ ] B12. **Hide** returns the section to masked state; leaving the screen and returning re-hides.
|
||||
- [ ] B13. **No QR** is ever offered for the nsec.
|
||||
|
||||
## C. Desktop — External-signer / read-only account
|
||||
|
||||
Precondition: log in with an **external signer / bunker (NIP-46)** or a **read-only npub**.
|
||||
|
||||
- [ ] C1. Backup Keys card shows the npub section normally.
|
||||
- [ ] C2. nsec section is replaced by the "This account uses an external signer — no secret key
|
||||
is stored here" note. No reveal/copy controls, no masked field.
|
||||
|
||||
---
|
||||
|
||||
## D. Android — Post-signup backup nudge
|
||||
|
||||
Precondition: fresh install or logged out. **Create a NEW account** (generate).
|
||||
|
||||
- [ ] D1. After signup lands on the home feed, a dismissible **"Back up your keys"** nudge/banner
|
||||
appears (top of feed, above the algo-feed status banner; does not block navigation).
|
||||
- [ ] D2. **Back up now** → opens the existing Account Backup screen; returning home, the nudge
|
||||
is gone (flag flipped).
|
||||
- [ ] D3. Re-create another new account → **I saved them** (or the X) dismisses the nudge.
|
||||
- [ ] D4. Kill & relaunch the app → the dismissed nudge does **not** reappear for that account
|
||||
(per-account `hasBackedUpKeys` persisted in encrypted prefs).
|
||||
|
||||
## E. Android — Which accounts get nudged
|
||||
|
||||
- [ ] E1. Log in with an **existing nsec** (paste key) → **no** nudge (treated as already backed up).
|
||||
- [ ] E2. Log in with **bunker / external signer** → **no** nudge.
|
||||
- [ ] E3. Read-only **npub** login → **no** nudge (no private key).
|
||||
- [ ] E4. Multiple accounts: a freshly-generated account is nudged; switching to a
|
||||
pre-existing account shows no nudge (flag is per-account).
|
||||
|
||||
## F. Android — Backup screen hardening (`AccountBackupScreen`)
|
||||
|
||||
- [ ] F1. **FLAG_SECURE**: on the Account Backup screen, attempt a screenshot → blocked by the OS
|
||||
("can't take screenshots due to security policy") and the app-switcher/recents preview shows
|
||||
a blank/black thumbnail for this screen.
|
||||
- [ ] F2. Navigating away from the backup screen → screenshots work again elsewhere (flag cleared,
|
||||
no leak to other screens).
|
||||
- [ ] F3. Existing **biometric gate** on copy/QR still prompts and works.
|
||||
- [ ] F4. **Copy secret key** (plaintext) → toast shown; **clipboard auto-clear** after ~60s
|
||||
empties the clipboard if unchanged; a value copied in the meantime is preserved.
|
||||
- [ ] F5. **Encrypted (ncryptsec1) copy** and the **plaintext / encrypted QR codes** still work
|
||||
as before (regression check — these are pre-existing).
|
||||
|
||||
---
|
||||
|
||||
## G. Cross-cutting — NIP-49 round trip (correctness)
|
||||
|
||||
- [ ] G1. Desktop: encrypted-copy the nsec with password `P` → you have an `ncryptsec1…`.
|
||||
- [ ] G2. Log in (Desktop or Android) using that `ncryptsec1…` + password `P` → succeeds and
|
||||
resolves to the **same** account (same npub). Confirms the nsec→hex decode + Nip49 encrypt
|
||||
are correct end-to-end.
|
||||
- [ ] G3. Wrong password on login with the ncryptsec → rejected (no crash).
|
||||
|
||||
## H. Regression / smoke
|
||||
|
||||
- [ ] H1. Desktop **Developer Settings** key rows still copy (shared `copyToClipboard` refactor
|
||||
didn't break them).
|
||||
- [ ] H2. Privacy lock still gates **Messages** and **Wallet** as before (adding `KeyBackup`
|
||||
scope didn't disturb existing scopes).
|
||||
- [ ] H3. Normal posting on Android still works (paste-guard was **deferred** — a note containing
|
||||
an `nsec1…` currently posts without a warning; confirm posting itself is unaffected).
|
||||
|
||||
---
|
||||
|
||||
## Known limitations / deferred (expected, not bugs)
|
||||
- **Compose paste-guard** (warn before posting a note that contains an `nsec1…`) is **deferred** —
|
||||
the send path is reimplemented across ~17 `*PostViewModel`s with no shared choke point.
|
||||
- **Desktop reveal without PrivacyLock** is protected only by masked + explicit toggle (no
|
||||
password), by design — the master-password gate only engages if the user set one up.
|
||||
- **Clipboard auto-clear** is best-effort (equality-guarded, 60s); the OS may surface its own
|
||||
sensitive-clipboard UI on Android 13+.
|
||||
|
||||
## Sign-off
|
||||
- Tester: __________ Date: __________
|
||||
- Desktop OS: __________ Android version/device: __________
|
||||
- Overall: ☐ ready for PR ☐ needs fixes (list): __________
|
||||
@@ -0,0 +1,138 @@
|
||||
# Key Backup & nsec Exposure — Plan
|
||||
|
||||
Date: 2026-08-11
|
||||
Scope: Desktop (`desktopApp`) primary, Android (`amethyst`) secondary, shared strings/logic in `commons`/`quartz`.
|
||||
|
||||
## Problem
|
||||
|
||||
User report (Nostr): new account creation gives no discoverable way to
|
||||
find/save the keypair. "Couldn't find it in settings, no option to save when
|
||||
generated." Keys are crucial → must reinforce, not obfuscate.
|
||||
|
||||
Ask: make nsec discoverable + backupable; npub sharable (copy/QR); check both
|
||||
Android and Desktop.
|
||||
|
||||
## Key asymmetry (design principle — non-negotiable)
|
||||
|
||||
Every surveyed client agrees:
|
||||
- **npub** = public identity → plain by default, copy + QR, share freely.
|
||||
- **nsec** = password that can NEVER be reset → masked by default, gated
|
||||
reveal + gated copy, **never rendered as a QR**, prefer encrypted (NIP-49).
|
||||
|
||||
So "show nsec with npub for sharing" splits: npub is for sharing; nsec is for
|
||||
**private backup only**. The plan treats them differently.
|
||||
|
||||
## Current state
|
||||
|
||||
### Android (`amethyst`) — mostly done, one gap
|
||||
- `ui/.../keyBackup/AccountBackupScreen.kt` — full backup screen: biometric-gated
|
||||
copy nsec, NIP-49 encrypted (ncryptsec1) copy, plaintext + encrypted QR,
|
||||
strong warnings (`account_backup_tips2_md`/`tips3_md`). Route `AccountBackup`.
|
||||
- **Gap**: signup (`SignUpViewModel.signup` → `AccountSessionManager.createNewAccount`,
|
||||
`val keyPair = KeyPair()`) generates silently. **No post-signup nudge** to back
|
||||
up. Screen exists but discoverability relies on the user hunting the drawer.
|
||||
|
||||
### Desktop (`desktopApp`) — large gaps
|
||||
- `ui/auth/NewKeyWarningCard.kt` — shows npub+nsec once at creation as plain
|
||||
`SelectableKeyText` (manual-select). No copy button, no QR, no encrypted
|
||||
option, weak warning.
|
||||
- `ui/DevSettingsSection.kt` — full copy UI but **debug-mode only**; normal
|
||||
users can't re-reach nsec.
|
||||
- `ui/profile/ProfileInfoCard.kt` — npub + hex only (no copy? verify), no QR.
|
||||
- **No user-facing Backup Keys screen. No settings path to nsec. No NIP-49
|
||||
export. No QR for npub.**
|
||||
- `AccountState.LoggedIn` already exposes `.npub`, `.nsec`, `.pubKeyHex` →
|
||||
wiring a backup screen is trivial.
|
||||
- `commons/jvmMain/.../keystorage/SecureKeyStorage.kt` — OS keychain + encrypted
|
||||
fallback; can retrieve raw key.
|
||||
|
||||
### Shared / quartz (reuse — don't rebuild)
|
||||
- `nip19Bech32/ByteArrayExt.kt`: `toNsec()`, `toNpub()`.
|
||||
- `nip49PrivKeyEnc/Nip49.kt`: `encrypt()/decrypt()` ncryptsec1.
|
||||
- Android QR: `ui/.../qrcode` (`QrCodeDrawer`). Desktop QR: `QrCodeCanvas.kt`
|
||||
(currently only NIP-46/NIP-47 URIs) — reuse for npub.
|
||||
|
||||
## Recommended design
|
||||
|
||||
Adopt the **hidden-password camp** (Amethyst Android's existing model) +
|
||||
**persistent post-signup backup nudge** (Snort pattern). Rationale: matches
|
||||
Android, keeps signup fast, avoids a hard gate, but nags until backed up.
|
||||
|
||||
Firm rules across both platforms:
|
||||
1. nsec masked by default; reveal is an explicit gated action.
|
||||
2. Copy nsec is itself gated (copy is the real leak vector) + shows warning.
|
||||
3. Offer **NIP-49 encrypted (ncryptsec1)** copy/export alongside plaintext.
|
||||
4. npub always plain, copyable, QR. **nsec never QR.**
|
||||
5. Hide the entire private-key section for **external-signer / bunker / read-only**
|
||||
sessions (`nsec == null`) — show "This account uses an external signer" note.
|
||||
6. Explicit **"cannot be recovered"** warning verbatim-strong (reuse Android's
|
||||
`account_backup_tips2_md`).
|
||||
|
||||
Platform reveal-gate:
|
||||
- **Android**: biometric (already wired in `AccountBackupScreen`).
|
||||
- **Desktop**: no biometric → gate reveal/copy behind a confirm dialog
|
||||
("Show secret key?" YES/CANCEL) + `showKeys` toggle, matching DevSettings but
|
||||
user-facing and with the encrypted option. (Optional later: OS auth via
|
||||
existing PrivacyLock/master-password machinery if present — verify.)
|
||||
|
||||
## Implementation
|
||||
|
||||
### Phase 1 — Desktop Backup Keys screen (biggest gap)
|
||||
- New `desktopApp/.../ui/settings/BackupKeysScreen.kt` (or `.../ui/keyBackup/`):
|
||||
- npub row: plain, Copy, "Show QR" (reuse `QrCodeCanvas`).
|
||||
- nsec section: masked → confirm-dialog reveal → Copy (plaintext) + "Copy
|
||||
encrypted" (password field → `Nip49().encrypt`). Strong warning banner.
|
||||
- Hidden when `account.nsec == null` (external/read-only) → info note.
|
||||
- Add entry point in Settings sidebar/account area (near `ProfileInfoCard` in
|
||||
`Main.kt` ~2191). Label "Backup Keys" / "Account Keys".
|
||||
- Reuse `AccountState.LoggedIn.{npub,nsec,pubKeyHex}`; clipboard via existing
|
||||
`copyToClipboard` (AWT) — factor out of `DevSettingsSection`.
|
||||
|
||||
### Phase 2 — Desktop NewKeyWarningCard upgrade
|
||||
- Add Copy button per key (not just selection).
|
||||
- Add "Copy encrypted (recommended)" + password field.
|
||||
- Strengthen warning copy to match Android "no recovery" strength.
|
||||
- Add "I've saved my keys" acknowledgement affordance before `onContinue`
|
||||
(soft; not a hard checkbox gate — see open Q).
|
||||
- Do NOT add nsec QR.
|
||||
|
||||
### Phase 3 — Android post-signup backup nudge
|
||||
- After `signup()`, route to / surface a dismissible "Back up your keys" prompt
|
||||
with "Back up now" (→ `AccountBackupScreen`) / "I already saved them".
|
||||
- Persist "backed up" flag per account; keep nudging (home banner or settings
|
||||
badge) until acknowledged. No silent dismissal.
|
||||
|
||||
### Phase 4 — Shared polish (both platforms)
|
||||
- Copy-warning string on every nsec copy ("like a password, cannot be reset").
|
||||
- `FLAG_SECURE` (Android) on reveal to redact screenshots/recents. Desktop:
|
||||
no direct equivalent — skip.
|
||||
- (Stretch) timed clipboard auto-clear after nsec copy — genuinely novel, no
|
||||
client does it. Verify feasibility (Android `ClipboardManager`, Desktop AWT).
|
||||
- (Stretch) Coracle-style paste-guard on compose: warn if a note body starts
|
||||
with `nsec1`.
|
||||
|
||||
## Testing
|
||||
- Desktop: generate account → reveal/copy plaintext + encrypted → decrypt
|
||||
round-trips (`Nip49`) → QR shows npub not nsec → external-signer account hides
|
||||
nsec section. Manual sheet.
|
||||
- Android: signup → nudge appears → backup screen reachable → encrypted copy
|
||||
round-trips. Existing `AccountBackupScreen` unit coverage if any.
|
||||
- Reuse quartz `Nip49` tests; add commons test for any extracted helper.
|
||||
|
||||
## Non-goals / deferred
|
||||
- iOS (no mature target yet).
|
||||
- BIP-39 / NIP-06 mnemonic backup (Snort) — separate feature.
|
||||
- Full OS-biometric gate on Desktop (no primitive) — confirm dialog instead.
|
||||
|
||||
## Unanswered questions
|
||||
- Design camp: confirm hidden-password + nudge (recommended) vs. keep Desktop's
|
||||
show-at-creation as the primary backup moment?
|
||||
- Hard "I saved it" checkbox gate at signup, or soft dismissible nudge? (survey:
|
||||
soft wins; hard gate largely unclaimed.)
|
||||
- Desktop reveal gate: confirm-dialog only, or wire existing PrivacyLock/master
|
||||
password if one exists? (verify what Desktop already has.)
|
||||
- Extract a shared `commons` backup composable, or keep Android + Desktop
|
||||
screens separate (Android biometric vs Desktop dialog diverge)?
|
||||
- Timed clipboard auto-clear: in scope now or stretch?
|
||||
- Paste-guard on compose (nsec self-doxx prevention): this feature or separate?
|
||||
- Does Desktop `ProfileInfoCard` already copy npub / need a QR button there too?
|
||||
@@ -0,0 +1,103 @@
|
||||
# First-Run "Save Your Keys" Onboarding — Plan (Desktop)
|
||||
|
||||
Date: 2026-08-11
|
||||
Branch: `feat/key-backup-nsec-exposure`
|
||||
Supersedes: the single `NewKeyWarningCard` for freshly-generated accounts.
|
||||
|
||||
## Why
|
||||
|
||||
New-account key backup is the highest-stakes moment in a Nostr client: the
|
||||
`nsec` is shown once and can never be reset. Current impl crams warning + npub +
|
||||
nsec + encrypted-copy + checkbox + continue into one `NewKeyWarningCard`. Inside
|
||||
the 480px add-account dialog it has no scroll, so it clips — labels above and the
|
||||
Continue button below are cut off → reads as "no label / no way forward". Even
|
||||
un-clipped, one dense card is confusing.
|
||||
|
||||
Decision (user): **guided multi-step full-screen flow**, **soft checkbox gate**.
|
||||
|
||||
## What already exists (reuse — don't rebuild)
|
||||
|
||||
- **Split generation** (just built): `AccountManager.buildNewAccount()` creates the
|
||||
keypair WITHOUT activating it; `activateAccount(state)` flips account state.
|
||||
This is what lets a backup step render before the account switch tears the
|
||||
screen down. Keep.
|
||||
- **Entry points** that generate a key:
|
||||
- Cold start: `LoginScreen` (`onGenerateNew` → `buildNewAccount()`).
|
||||
- Logged-in: `AddAccountDialog` (`onGenerateNew` → `buildNewAccount()`), confirmed
|
||||
via `onNewAccountConfirmed` on the App scope.
|
||||
- **Pieces to lift out of `NewKeyWarningCard`**: `CopyKeyButton`, `EncryptedCopyRow`
|
||||
(NIP-49 password → `ncryptsec1`), `SelectableKeyText`, the warning strings
|
||||
(`new_key_*`). The `nsec→hex` decode (`decodePrivateKeyAsHexOrNull`) + `Nip49`.
|
||||
- **QR**: `QrCodeCanvas(data)` — npub only, never nsec.
|
||||
- **Settings** backup card (`BackupKeysCard`) already covers "view my keys later".
|
||||
|
||||
## Design — `NewKeyOnboardingScreen` (stepper, 3 steps)
|
||||
|
||||
A full-window composable (NOT a dialog). Rendered while `buildNewAccount()` result
|
||||
is held and before `activateAccount`. Fixed max content width (~560dp), centered,
|
||||
each step vertically scrollable so nothing clips.
|
||||
|
||||
**Step 1 — Why this matters (education)**
|
||||
- Headline "Save your keys" + plain-language explainer: npub = your public
|
||||
identity (shareable); nsec = a password that can never be reset or recovered —
|
||||
lose it and the account is gone. No inputs. [Next].
|
||||
|
||||
**Step 2 — Your keys (the actual backup)**
|
||||
- **Public key (npub)**: monospace, Copy, optional Show QR.
|
||||
- **Secret key (nsec)**: monospace (shown — this is the one moment we intentionally
|
||||
reveal it), **Copy** (primary, prominent) + best-effort clipboard auto-clear.
|
||||
- **Copy encrypted (recommended)**: collapsible/secondary — password field →
|
||||
`ncryptsec1`. Keep out of the way so the primary Copy is obvious.
|
||||
- Strong "never share the nsec" inline warning. No nsec QR. [Back] [Next].
|
||||
|
||||
**Step 3 — Confirm & continue (soft gate)**
|
||||
- Recap one line ("Stored somewhere safe? A password manager is ideal.").
|
||||
- **Soft checkbox** "I have saved my keys somewhere safe" → enables **Continue**.
|
||||
(Honor-system, matches the Android nudge decision. No copy/verify enforcement.)
|
||||
- Continue → `activateAccount(state)` + persist + proceed into the app.
|
||||
|
||||
Progress indicator (e.g. "Step 2 of 3" or dots). [Back] on 2/3.
|
||||
|
||||
## Integration
|
||||
|
||||
- **New file**: `desktopApp/.../ui/auth/NewKeyOnboardingScreen.kt` (stepper +
|
||||
step composables; reuse the extracted Copy/Encrypt pieces).
|
||||
- **Cold start**: in `LoginScreen`, when `generatedAccount != null` render the
|
||||
onboarding screen full-bleed instead of the inline card. `onFinish` =
|
||||
`activateAccount` + `onLoginSuccess`.
|
||||
- **Add-account**: when a key is generated, DON'T keep it inside the small dialog.
|
||||
Either (a) dismiss the dialog and show the onboarding screen at the App level
|
||||
(preferred — full space, survives the later account switch), or (b) let the
|
||||
onboarding screen be the dialog's content at a larger, scrollable size. Pick (a)
|
||||
for consistency with the cold-start path: hoist a top-level
|
||||
`pendingNewAccount: AccountState.LoggedIn?` in the App composable; both entry
|
||||
points set it; one `NewKeyOnboardingScreen` renders when non-null; `onFinish`
|
||||
activates + persists on the App scope. This unifies both flows through one
|
||||
screen and removes the dialog-clipping problem entirely.
|
||||
- **Retire** `NewKeyWarningCard` (and its Preview) once both paths use the stepper.
|
||||
|
||||
## Testing (add to the manual sheet)
|
||||
- Cold start: log out → Generate → 3-step screen, no clipping, Copy works,
|
||||
encrypted copy → `ncryptsec1`, checkbox gates Continue, Continue lands in app.
|
||||
- Add account (already logged in): Add → Generate → same screen at App level (not a
|
||||
cramped dialog), Continue switches to the new account + persists (survives
|
||||
restart).
|
||||
- Back/Next preserve state; window resize keeps everything reachable (scroll).
|
||||
- NIP-49 round trip (encrypted copy → login elsewhere → same npub).
|
||||
|
||||
## Non-goals / follow-ups
|
||||
- Android first-run guided screen (Android already has `AccountBackupScreen` +
|
||||
post-signup nudge; a matching stepper is a separate follow-up).
|
||||
- Hard copy/verify enforcement (explicitly declined — soft gate).
|
||||
- Mnemonic (NIP-06) backup.
|
||||
|
||||
## Unanswered questions
|
||||
- Add-account path: hoist to App-level full-screen (recommended) vs enlarge the
|
||||
dialog to host the stepper? (Plan assumes hoist.)
|
||||
- Does generating from the logged-in state and cancelling mid-onboarding need an
|
||||
explicit "discard this new key" confirm, or is silent discard fine?
|
||||
- Show the nsec revealed by default on Step 2 (it's first-run, user must save it) —
|
||||
or masked-with-reveal like the Settings card? (Plan assumes shown, since the
|
||||
whole point is to save it now.)
|
||||
- Progress UI: numbered "Step X of 3" vs dots vs a top wizard bar?
|
||||
- Keep the `NewKeyWarningCard` as a fallback anywhere, or fully delete?
|
||||
@@ -121,6 +121,7 @@ import com.vitorpamplona.amethyst.desktop.ui.LocalBlossomServers
|
||||
import com.vitorpamplona.amethyst.desktop.ui.LoginScreen
|
||||
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
|
||||
import com.vitorpamplona.amethyst.desktop.ui.auth.ForceLogoutDialog
|
||||
import com.vitorpamplona.amethyst.desktop.ui.auth.NewKeyOnboardingScreen
|
||||
import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopDmRoute
|
||||
import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker
|
||||
import com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawer
|
||||
@@ -134,6 +135,7 @@ import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneState
|
||||
import com.vitorpamplona.amethyst.desktop.ui.deck.Workspace
|
||||
import com.vitorpamplona.amethyst.desktop.ui.deck.WorkspaceManager
|
||||
import com.vitorpamplona.amethyst.desktop.ui.deck.param
|
||||
import com.vitorpamplona.amethyst.desktop.ui.keyBackup.BackupKeysCard
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalAwtWindow
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState
|
||||
@@ -1336,300 +1338,322 @@ private fun AppInner(
|
||||
LocalNotificationSettings provides notifSettings,
|
||||
LocalNotificationReadState provides notifReadState,
|
||||
) {
|
||||
when (accountState) {
|
||||
is AccountState.Loading -> {
|
||||
// Branded loading screen while accounts load from storage
|
||||
val loadingIcon = com.vitorpamplona.amethyst.desktop.platform.IconResources.rawBitmapPainter
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
androidx.compose.material3.CircularProgressIndicator(
|
||||
modifier = Modifier.size(32.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
strokeWidth = 3.dp,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
"Amethyst",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
androidx.compose.material3.Icon(
|
||||
painter = loadingIcon,
|
||||
contentDescription = "Amethyst",
|
||||
modifier = Modifier.size(96.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
val pendingNewAccount by accountManager.pendingNewAccount.collectAsState()
|
||||
val pendingNew = pendingNewAccount
|
||||
if (pendingNew != null) {
|
||||
// First-run "save your keys" onboarding for a freshly generated
|
||||
// account. Rendered above the account-state switch so it survives
|
||||
// until the user finishes (which activates + persists the account).
|
||||
NewKeyOnboardingScreen(
|
||||
npub = pendingNew.npub,
|
||||
nsec = pendingNew.nsec,
|
||||
onFinish = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountManager.ensureCurrentAccountInStorage()
|
||||
accountManager.finishNewAccountOnboarding()
|
||||
accountManager.saveCurrentAccount()
|
||||
accountManager.ensureCurrentAccountInStorage()
|
||||
accountManager.refreshAccountList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is AccountState.LoggedOut -> {
|
||||
LoginScreen(
|
||||
accountManager = accountManager,
|
||||
onLoginSuccess = {
|
||||
// Start heartbeat if bunker account
|
||||
val current = accountManager.currentAccount()
|
||||
if (current?.signerType is com.vitorpamplona.amethyst.commons.model.account.SignerType.Remote) {
|
||||
accountManager.startHeartbeat(scope)
|
||||
}
|
||||
// Save account (privkey to keychain + metadata to disk)
|
||||
// then ensure multi-account storage is up to date.
|
||||
// Uses App-level scope so it survives LoginScreen leaving composition.
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountManager.saveCurrentAccount()
|
||||
accountManager.ensureCurrentAccountInStorage()
|
||||
accountManager.refreshAccountList()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
is AccountState.ConnectingRelays -> {
|
||||
val relays by relayManager.relayStatuses.collectAsState()
|
||||
ConnectingRelaysScreen(
|
||||
subtitle = "Restoring remote signer session",
|
||||
relayStatuses = relays,
|
||||
)
|
||||
}
|
||||
|
||||
is AccountState.LoggedIn -> {
|
||||
val account = accountState as AccountState.LoggedIn
|
||||
val nwcConnection by accountManager.nwcConnection.collectAsState()
|
||||
|
||||
// Account state holders (relay lists, blossom servers, DMs, WoT).
|
||||
// Hoisted above MainContent so the top-level compose dialog can also
|
||||
// read the account's blossom server list from iAccount directly.
|
||||
val dmSendTracker = remember(relayManager) { DmSendTracker(relayManager.client) }
|
||||
// Created before iAccount so NIP-65 backup can be loaded.
|
||||
val accountRelays =
|
||||
remember(account, relayManager, scope) {
|
||||
DesktopAccountRelays(account.pubKeyHex, relayManager, scope)
|
||||
}
|
||||
// Cold-boot AUTH race fix: when the account's own kind:10050 DM-inbox
|
||||
// set loads (often AFTER an inbox relay has already challenged for
|
||||
// AUTH), retroactively auto-approve any pending tier-2 prompt for a
|
||||
// relay that is actually tier-1, instead of leaving a spurious banner.
|
||||
LaunchedEffect(authCoordinator, accountRelays) {
|
||||
accountRelays.dmRelayList.collect { dmInbox ->
|
||||
authCoordinator.onSelfApprovedRelaysChanged(dmInbox)
|
||||
}
|
||||
}
|
||||
val iAccount =
|
||||
remember(account, localCache, relayManager, dmSendTracker, accountRelays, dmInboxResolver) {
|
||||
DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope, accountRelays, dmInboxResolver)
|
||||
}
|
||||
// When iAccount is replaced (account switch), close the previous
|
||||
// WoTService so its writer coroutine + ops Channel don't leak.
|
||||
DisposableEffect(iAccount) {
|
||||
onDispose { iAccount.wotService.close() }
|
||||
}
|
||||
|
||||
// Lazy-load Namecoin services. The Core RPC HTTP
|
||||
// client is sourced from the Tor-aware DesktopHttpClient
|
||||
// singleton so .onion RPC URLs route through the
|
||||
// user's Tor settings without extra plumbing.
|
||||
val namecoinPreferences = remember { DesktopNamecoinPreferences() }
|
||||
val namecoinService =
|
||||
remember {
|
||||
DesktopNamecoinNameService(
|
||||
preferencesProvider = { namecoinPreferences.current },
|
||||
pinnedCertsProvider = { namecoinPreferences.loadPinnedCerts() },
|
||||
coreRpcHttpClientProvider = { _ ->
|
||||
com.vitorpamplona.amethyst.desktop.network
|
||||
.DesktopHttpClient
|
||||
.currentClient()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// NWC loaded during startup in loadSavedAccount flow
|
||||
|
||||
val currentTorStatus = torManager.status.collectAsState().value
|
||||
val followedUsers by localCache.followedUsers.collectAsState()
|
||||
val spamExemptKeys =
|
||||
remember(followedUsers, account.pubKeyHex) {
|
||||
followedUsers + account.pubKeyHex
|
||||
}
|
||||
androidx.compose.runtime.CompositionLocalProvider(
|
||||
com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState provides
|
||||
com.vitorpamplona.amethyst.desktop.ui.tor.TorState(
|
||||
status = currentTorStatus,
|
||||
settings = torSettings,
|
||||
onSettingsChanged = { newSettings ->
|
||||
torSettings = newSettings
|
||||
com.vitorpamplona.amethyst.desktop.tor.DesktopTorPreferences
|
||||
.save(newSettings)
|
||||
torTypeFlow.value = newSettings.torType
|
||||
externalPortFlow.value = newSettings.externalSocksPort
|
||||
// Rebuild app to apply Tor changes
|
||||
onRestartApp()
|
||||
},
|
||||
),
|
||||
LocalNamecoinPreferences provides namecoinPreferences,
|
||||
LocalNamecoinService provides namecoinService,
|
||||
LocalSpamExemptKeys provides spamExemptKeys,
|
||||
com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount provides iAccount,
|
||||
LocalUserFinder provides subscriptionsCoordinator.userFinder,
|
||||
LocalUserFinderAccount provides iAccount,
|
||||
LocalEventFinder provides subscriptionsCoordinator.eventFinder,
|
||||
) {
|
||||
val pendingAuthApprovals by authCoordinator.pendingApprovals.collectAsState()
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// On macOS the window uses `apple.awt.fullWindowContent`
|
||||
// (see [applyNativeWindowChrome]), so the traffic-light
|
||||
// buttons sit over the top-left corner of content. Clear
|
||||
// that zone so the banner text/icon aren't occluded.
|
||||
val bannerModifier =
|
||||
if (PlatformInfo.isMacOS) {
|
||||
Modifier.padding(start = 80.dp, top = 8.dp, end = 8.dp, bottom = 4.dp)
|
||||
} else {
|
||||
Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
}
|
||||
AuthApprovalBanner(
|
||||
pending = pendingAuthApprovals.values.toList(),
|
||||
onResolve = { url, scope -> authCoordinator.resolve(url, scope) },
|
||||
modifier = bannerModifier,
|
||||
)
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
// Force a Compose subtree teardown when the active
|
||||
// account changes. Without this, the currently-open
|
||||
// column keeps its account-A `remember { ... }`
|
||||
// state (LazyListState scroll position, expanded
|
||||
// rows, filter-tab selection, in-flight metadata
|
||||
// observers, per-column view-models) even though the
|
||||
// outer `iAccount` / `accountRelays` swap correctly.
|
||||
// Users saw account A's notifications / profile /
|
||||
// messages page rendered under account B's identity
|
||||
// until they navigated away and back. `key(pubKeyHex)`
|
||||
// is the idiomatic Compose way to reset an entire
|
||||
// subtree on identity change while keeping the outer
|
||||
// deck layout / workspace state (declared above) alive.
|
||||
androidx.compose.runtime.key(account.pubKeyHex) {
|
||||
MainContent(
|
||||
layoutMode = layoutMode,
|
||||
deckState = deckState,
|
||||
workspaceManager = workspaceManager,
|
||||
singlePaneState = singlePaneState,
|
||||
pinnedNavBarState = pinnedNavBarState,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
accountManager = accountManager,
|
||||
account = account,
|
||||
iAccount = iAccount,
|
||||
accountRelays = accountRelays,
|
||||
dmSendTracker = dmSendTracker,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
indexRelaysStore = indexRelaysStore,
|
||||
nip11Fetcher = nip11Fetcher,
|
||||
dmInboxResolver = dmInboxResolver,
|
||||
appScope = scope,
|
||||
torStatus = currentTorStatus,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onEditInComposer = onEditInComposer,
|
||||
onShowAppDrawer = onShowAppDrawer,
|
||||
onOpenFeedsDrawer = {
|
||||
appDrawerInitialTab =
|
||||
com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab.FEEDS
|
||||
onShowAppDrawer()
|
||||
},
|
||||
onShowImportFollowListDialog = onShowImportFollowListDialog,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import Follow List dialog (triggered from File menu /
|
||||
// Cmd+Shift+I). Rendered inside this CompositionLocalProvider
|
||||
// so LocalNamecoinService is available for .bit / d/ / id/
|
||||
// identifier resolution.
|
||||
if (showImportFollowListDialog) {
|
||||
ImportFollowListDialog(
|
||||
onDismiss = onDismissImportFollowListDialog,
|
||||
relayManager = relayManager,
|
||||
account = account,
|
||||
localCache = localCache,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Compose dialog. Hosted outside MainContent's provider,
|
||||
// so provide the account's blossom list here too.
|
||||
if (showComposeDialog) {
|
||||
CompositionLocalProvider(
|
||||
LocalBlossomServers provides iAccount.blossomServerList.flow,
|
||||
},
|
||||
onCancel = { accountManager.cancelNewAccountOnboarding() },
|
||||
)
|
||||
} else {
|
||||
when (accountState) {
|
||||
is AccountState.Loading -> {
|
||||
// Branded loading screen while accounts load from storage
|
||||
val loadingIcon = com.vitorpamplona.amethyst.desktop.platform.IconResources.rawBitmapPainter
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
ComposeNoteDialog(
|
||||
onDismiss = onDismissComposeDialog,
|
||||
relayManager = relayManager,
|
||||
account = account,
|
||||
localCache = localCache,
|
||||
replyTo = replyToNote,
|
||||
draftDTag = composeEditDraftTag,
|
||||
draftInitialContent = composeEditContent,
|
||||
initialScheduledForSec = composeEditScheduledForSec,
|
||||
)
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
androidx.compose.material3.CircularProgressIndicator(
|
||||
modifier = Modifier.size(32.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
strokeWidth = 3.dp,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
"Amethyst",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
androidx.compose.material3.Icon(
|
||||
painter = loadingIcon,
|
||||
contentDescription = "Amethyst",
|
||||
modifier = Modifier.size(96.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// App Drawer overlay
|
||||
if (showAppDrawer) {
|
||||
val openColumns by deckState.columns.collectAsState()
|
||||
AppDrawer(
|
||||
initialTab = appDrawerInitialTab,
|
||||
openColumnTypes =
|
||||
if (layoutMode == LayoutMode.DECK) {
|
||||
openColumns.map { it.type.typeKey() }.toSet()
|
||||
} else {
|
||||
emptySet()
|
||||
},
|
||||
pinnedNavBarState = pinnedNavBarState,
|
||||
workspaceManager = workspaceManager,
|
||||
onSwitchWorkspace = { ws ->
|
||||
// Switch layout mode to match workspace
|
||||
onLayoutModeChange(ws.layoutMode)
|
||||
// Load columns or single pane screen
|
||||
when (ws.layoutMode) {
|
||||
LayoutMode.DECK -> {
|
||||
deckState.loadFromWorkspace(ws.columns)
|
||||
}
|
||||
|
||||
LayoutMode.SINGLE_PANE -> {
|
||||
// Load nav bar from workspace + navigate to first screen
|
||||
pinnedNavBarState.loadFromWorkspace()
|
||||
val firstKey =
|
||||
ws.singlePaneScreens.firstOrNull() ?: "home"
|
||||
val type = DeckState.parseColumnTypeFromKey(firstKey)
|
||||
if (type != null) singlePaneState.navigate(type)
|
||||
}
|
||||
is AccountState.LoggedOut -> {
|
||||
LoginScreen(
|
||||
accountManager = accountManager,
|
||||
onLoginSuccess = {
|
||||
// Start heartbeat if bunker account
|
||||
val current = accountManager.currentAccount()
|
||||
if (current?.signerType is com.vitorpamplona.amethyst.commons.model.account.SignerType.Remote) {
|
||||
accountManager.startHeartbeat(scope)
|
||||
}
|
||||
},
|
||||
onSelectScreen = { type ->
|
||||
when (layoutMode) {
|
||||
LayoutMode.DECK -> {
|
||||
if (deckState.hasColumnOfType(type)) {
|
||||
deckState.focusExistingColumn(type)
|
||||
} else {
|
||||
deckState.addColumn(type)
|
||||
}
|
||||
}
|
||||
|
||||
LayoutMode.SINGLE_PANE -> {
|
||||
singlePaneState.navigate(type)
|
||||
}
|
||||
// Save account (privkey to keychain + metadata to disk)
|
||||
// then ensure multi-account storage is up to date.
|
||||
// Uses App-level scope so it survives LoginScreen leaving composition.
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountManager.saveCurrentAccount()
|
||||
accountManager.ensureCurrentAccountInStorage()
|
||||
accountManager.refreshAccountList()
|
||||
}
|
||||
},
|
||||
onDismiss = {
|
||||
appDrawerInitialTab = null
|
||||
onDismissAppDrawer()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
is AccountState.ConnectingRelays -> {
|
||||
val relays by relayManager.relayStatuses.collectAsState()
|
||||
ConnectingRelaysScreen(
|
||||
subtitle = "Restoring remote signer session",
|
||||
relayStatuses = relays,
|
||||
)
|
||||
}
|
||||
|
||||
is AccountState.LoggedIn -> {
|
||||
val account = accountState as AccountState.LoggedIn
|
||||
val nwcConnection by accountManager.nwcConnection.collectAsState()
|
||||
|
||||
// Account state holders (relay lists, blossom servers, DMs, WoT).
|
||||
// Hoisted above MainContent so the top-level compose dialog can also
|
||||
// read the account's blossom server list from iAccount directly.
|
||||
val dmSendTracker = remember(relayManager) { DmSendTracker(relayManager.client) }
|
||||
// Created before iAccount so NIP-65 backup can be loaded.
|
||||
val accountRelays =
|
||||
remember(account, relayManager, scope) {
|
||||
DesktopAccountRelays(account.pubKeyHex, relayManager, scope)
|
||||
}
|
||||
// Cold-boot AUTH race fix: when the account's own kind:10050 DM-inbox
|
||||
// set loads (often AFTER an inbox relay has already challenged for
|
||||
// AUTH), retroactively auto-approve any pending tier-2 prompt for a
|
||||
// relay that is actually tier-1, instead of leaving a spurious banner.
|
||||
LaunchedEffect(authCoordinator, accountRelays) {
|
||||
accountRelays.dmRelayList.collect { dmInbox ->
|
||||
authCoordinator.onSelfApprovedRelaysChanged(dmInbox)
|
||||
}
|
||||
}
|
||||
val iAccount =
|
||||
remember(account, localCache, relayManager, dmSendTracker, accountRelays, dmInboxResolver) {
|
||||
DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope, accountRelays, dmInboxResolver)
|
||||
}
|
||||
// When iAccount is replaced (account switch), close the previous
|
||||
// WoTService so its writer coroutine + ops Channel don't leak.
|
||||
DisposableEffect(iAccount) {
|
||||
onDispose { iAccount.wotService.close() }
|
||||
}
|
||||
|
||||
// Lazy-load Namecoin services. The Core RPC HTTP
|
||||
// client is sourced from the Tor-aware DesktopHttpClient
|
||||
// singleton so .onion RPC URLs route through the
|
||||
// user's Tor settings without extra plumbing.
|
||||
val namecoinPreferences = remember { DesktopNamecoinPreferences() }
|
||||
val namecoinService =
|
||||
remember {
|
||||
DesktopNamecoinNameService(
|
||||
preferencesProvider = { namecoinPreferences.current },
|
||||
pinnedCertsProvider = { namecoinPreferences.loadPinnedCerts() },
|
||||
coreRpcHttpClientProvider = { _ ->
|
||||
com.vitorpamplona.amethyst.desktop.network
|
||||
.DesktopHttpClient
|
||||
.currentClient()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// NWC loaded during startup in loadSavedAccount flow
|
||||
|
||||
val currentTorStatus = torManager.status.collectAsState().value
|
||||
val followedUsers by localCache.followedUsers.collectAsState()
|
||||
val spamExemptKeys =
|
||||
remember(followedUsers, account.pubKeyHex) {
|
||||
followedUsers + account.pubKeyHex
|
||||
}
|
||||
androidx.compose.runtime.CompositionLocalProvider(
|
||||
com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState provides
|
||||
com.vitorpamplona.amethyst.desktop.ui.tor.TorState(
|
||||
status = currentTorStatus,
|
||||
settings = torSettings,
|
||||
onSettingsChanged = { newSettings ->
|
||||
torSettings = newSettings
|
||||
com.vitorpamplona.amethyst.desktop.tor.DesktopTorPreferences
|
||||
.save(newSettings)
|
||||
torTypeFlow.value = newSettings.torType
|
||||
externalPortFlow.value = newSettings.externalSocksPort
|
||||
// Rebuild app to apply Tor changes
|
||||
onRestartApp()
|
||||
},
|
||||
),
|
||||
LocalNamecoinPreferences provides namecoinPreferences,
|
||||
LocalNamecoinService provides namecoinService,
|
||||
LocalSpamExemptKeys provides spamExemptKeys,
|
||||
com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount provides iAccount,
|
||||
LocalUserFinder provides subscriptionsCoordinator.userFinder,
|
||||
LocalUserFinderAccount provides iAccount,
|
||||
LocalEventFinder provides subscriptionsCoordinator.eventFinder,
|
||||
) {
|
||||
val pendingAuthApprovals by authCoordinator.pendingApprovals.collectAsState()
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// On macOS the window uses `apple.awt.fullWindowContent`
|
||||
// (see [applyNativeWindowChrome]), so the traffic-light
|
||||
// buttons sit over the top-left corner of content. Clear
|
||||
// that zone so the banner text/icon aren't occluded.
|
||||
val bannerModifier =
|
||||
if (PlatformInfo.isMacOS) {
|
||||
Modifier.padding(start = 80.dp, top = 8.dp, end = 8.dp, bottom = 4.dp)
|
||||
} else {
|
||||
Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
}
|
||||
AuthApprovalBanner(
|
||||
pending = pendingAuthApprovals.values.toList(),
|
||||
onResolve = { url, scope -> authCoordinator.resolve(url, scope) },
|
||||
modifier = bannerModifier,
|
||||
)
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
// Force a Compose subtree teardown when the active
|
||||
// account changes. Without this, the currently-open
|
||||
// column keeps its account-A `remember { ... }`
|
||||
// state (LazyListState scroll position, expanded
|
||||
// rows, filter-tab selection, in-flight metadata
|
||||
// observers, per-column view-models) even though the
|
||||
// outer `iAccount` / `accountRelays` swap correctly.
|
||||
// Users saw account A's notifications / profile /
|
||||
// messages page rendered under account B's identity
|
||||
// until they navigated away and back. `key(pubKeyHex)`
|
||||
// is the idiomatic Compose way to reset an entire
|
||||
// subtree on identity change while keeping the outer
|
||||
// deck layout / workspace state (declared above) alive.
|
||||
androidx.compose.runtime.key(account.pubKeyHex) {
|
||||
MainContent(
|
||||
layoutMode = layoutMode,
|
||||
deckState = deckState,
|
||||
workspaceManager = workspaceManager,
|
||||
singlePaneState = singlePaneState,
|
||||
pinnedNavBarState = pinnedNavBarState,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
accountManager = accountManager,
|
||||
account = account,
|
||||
iAccount = iAccount,
|
||||
accountRelays = accountRelays,
|
||||
dmSendTracker = dmSendTracker,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
indexRelaysStore = indexRelaysStore,
|
||||
nip11Fetcher = nip11Fetcher,
|
||||
dmInboxResolver = dmInboxResolver,
|
||||
appScope = scope,
|
||||
torStatus = currentTorStatus,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onEditInComposer = onEditInComposer,
|
||||
onShowAppDrawer = onShowAppDrawer,
|
||||
onOpenFeedsDrawer = {
|
||||
appDrawerInitialTab =
|
||||
com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab.FEEDS
|
||||
onShowAppDrawer()
|
||||
},
|
||||
onShowImportFollowListDialog = onShowImportFollowListDialog,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import Follow List dialog (triggered from File menu /
|
||||
// Cmd+Shift+I). Rendered inside this CompositionLocalProvider
|
||||
// so LocalNamecoinService is available for .bit / d/ / id/
|
||||
// identifier resolution.
|
||||
if (showImportFollowListDialog) {
|
||||
ImportFollowListDialog(
|
||||
onDismiss = onDismissImportFollowListDialog,
|
||||
relayManager = relayManager,
|
||||
account = account,
|
||||
localCache = localCache,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Compose dialog. Hosted outside MainContent's provider,
|
||||
// so provide the account's blossom list here too.
|
||||
if (showComposeDialog) {
|
||||
CompositionLocalProvider(
|
||||
LocalBlossomServers provides iAccount.blossomServerList.flow,
|
||||
) {
|
||||
ComposeNoteDialog(
|
||||
onDismiss = onDismissComposeDialog,
|
||||
relayManager = relayManager,
|
||||
account = account,
|
||||
localCache = localCache,
|
||||
replyTo = replyToNote,
|
||||
draftDTag = composeEditDraftTag,
|
||||
draftInitialContent = composeEditContent,
|
||||
initialScheduledForSec = composeEditScheduledForSec,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// App Drawer overlay
|
||||
if (showAppDrawer) {
|
||||
val openColumns by deckState.columns.collectAsState()
|
||||
AppDrawer(
|
||||
initialTab = appDrawerInitialTab,
|
||||
openColumnTypes =
|
||||
if (layoutMode == LayoutMode.DECK) {
|
||||
openColumns.map { it.type.typeKey() }.toSet()
|
||||
} else {
|
||||
emptySet()
|
||||
},
|
||||
pinnedNavBarState = pinnedNavBarState,
|
||||
workspaceManager = workspaceManager,
|
||||
onSwitchWorkspace = { ws ->
|
||||
// Switch layout mode to match workspace
|
||||
onLayoutModeChange(ws.layoutMode)
|
||||
// Load columns or single pane screen
|
||||
when (ws.layoutMode) {
|
||||
LayoutMode.DECK -> {
|
||||
deckState.loadFromWorkspace(ws.columns)
|
||||
}
|
||||
|
||||
LayoutMode.SINGLE_PANE -> {
|
||||
// Load nav bar from workspace + navigate to first screen
|
||||
pinnedNavBarState.loadFromWorkspace()
|
||||
val firstKey =
|
||||
ws.singlePaneScreens.firstOrNull() ?: "home"
|
||||
val type = DeckState.parseColumnTypeFromKey(firstKey)
|
||||
if (type != null) singlePaneState.navigate(type)
|
||||
}
|
||||
}
|
||||
},
|
||||
onSelectScreen = { type ->
|
||||
when (layoutMode) {
|
||||
LayoutMode.DECK -> {
|
||||
if (deckState.hasColumnOfType(type)) {
|
||||
deckState.focusExistingColumn(type)
|
||||
} else {
|
||||
deckState.addColumn(type)
|
||||
}
|
||||
}
|
||||
|
||||
LayoutMode.SINGLE_PANE -> {
|
||||
singlePaneState.navigate(type)
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismiss = {
|
||||
appDrawerInitialTab = null
|
||||
onDismissAppDrawer()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2417,6 +2441,11 @@ fun RelaySettingsScreen(
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Account Keys / Backup Section
|
||||
BackupKeysCard(account = account)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
// Wallet Connect Section
|
||||
Text(
|
||||
"Wallet Connect (NWC)",
|
||||
|
||||
+56
-10
@@ -554,20 +554,66 @@ class AccountManager internal constructor(
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
fun generateNewAccount(): AccountState.LoggedIn {
|
||||
/**
|
||||
* Builds a fresh keypair account WITHOUT activating it — leaves [_accountState]
|
||||
* untouched so the caller can show a "save your keys" backup step first. Call
|
||||
* [activateAccount] once the user has acknowledged the backup.
|
||||
*
|
||||
* Flipping the account state immediately (as [generateNewAccount] does) tears
|
||||
* down whatever screen triggered generation — the login screen or the add-account
|
||||
* dialog — before the backup card can render, which is why generation and
|
||||
* activation are split here.
|
||||
*/
|
||||
fun buildNewAccount(): AccountState.LoggedIn {
|
||||
val keyPair = KeyPair()
|
||||
val signer = NostrSignerInternal(keyPair)
|
||||
|
||||
val state =
|
||||
AccountState.LoggedIn(
|
||||
signer = signer,
|
||||
pubKeyHex = keyPair.pubKey.toHexKey(),
|
||||
npub = keyPair.pubKey.toNpub(),
|
||||
nsec = keyPair.privKey?.toNsec(),
|
||||
isReadOnly = false,
|
||||
)
|
||||
return AccountState.LoggedIn(
|
||||
signer = signer,
|
||||
pubKeyHex = keyPair.pubKey.toHexKey(),
|
||||
npub = keyPair.pubKey.toNpub(),
|
||||
nsec = keyPair.privKey?.toNsec(),
|
||||
isReadOnly = false,
|
||||
)
|
||||
}
|
||||
|
||||
/** Activates a previously-[buildNewAccount]-ed (or any) state as the current account. */
|
||||
fun activateAccount(state: AccountState.LoggedIn) {
|
||||
_accountState.value = state
|
||||
return state
|
||||
}
|
||||
|
||||
fun generateNewAccount(): AccountState.LoggedIn = buildNewAccount().also { _accountState.value = it }
|
||||
|
||||
// --- First-run key-backup onboarding ---
|
||||
//
|
||||
// A freshly-generated account is held here (NOT activated) while the user is
|
||||
// walked through the "save your keys" onboarding screen. Activation is deferred
|
||||
// to [finishNewAccountOnboarding] so the onboarding UI can render before the
|
||||
// account-state flip swaps the current screen out.
|
||||
|
||||
private val _pendingNewAccount = MutableStateFlow<AccountState.LoggedIn?>(null)
|
||||
val pendingNewAccount: StateFlow<AccountState.LoggedIn?> = _pendingNewAccount.asStateFlow()
|
||||
|
||||
/** Begins onboarding: builds a fresh key WITHOUT activating it. */
|
||||
fun beginNewAccountOnboarding() {
|
||||
_pendingNewAccount.value = buildNewAccount()
|
||||
}
|
||||
|
||||
/** User backed out of onboarding — discard the un-activated key. */
|
||||
fun cancelNewAccountOnboarding() {
|
||||
_pendingNewAccount.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* User finished onboarding — activate the pending account and clear it.
|
||||
* Returns the now-active account, or null if there was none pending.
|
||||
* The caller is responsible for persistence ([saveCurrentAccount] etc.).
|
||||
*/
|
||||
fun finishNewAccountOnboarding(): AccountState.LoggedIn? {
|
||||
val pending = _pendingNewAccount.value ?: return null
|
||||
_accountState.value = pending
|
||||
_pendingNewAccount.value = null
|
||||
return pending
|
||||
}
|
||||
|
||||
fun loginWithKey(keyInput: String): Result<AccountState.LoggedIn> {
|
||||
|
||||
+1
-15
@@ -52,8 +52,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.StatusAmber
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.StatusGreen
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.StatusRed
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import java.awt.Toolkit
|
||||
import java.awt.datatransfer.StringSelection
|
||||
import com.vitorpamplona.amethyst.desktop.util.copyToClipboard
|
||||
|
||||
/**
|
||||
* Developer settings section - shows sensitive keys for debugging.
|
||||
@@ -246,16 +245,3 @@ private fun KeyRow(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy text to system clipboard using AWT Toolkit.
|
||||
*/
|
||||
private fun copyToClipboard(text: String) {
|
||||
try {
|
||||
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
|
||||
val selection = StringSelection(text)
|
||||
clipboard.setContents(selection, selection)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,9 +39,6 @@ import androidx.compose.material3.Text
|
||||
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.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -52,10 +49,8 @@ import com.vitorpamplona.amethyst.commons.resources.login_subtitle_desktop
|
||||
import com.vitorpamplona.amethyst.commons.resources.login_title
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.StatusGreen
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountManager
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.network.RelayStatus
|
||||
import com.vitorpamplona.amethyst.desktop.ui.auth.LoginCard
|
||||
import com.vitorpamplona.amethyst.desktop.ui.auth.NewKeyWarningCard
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
@@ -63,9 +58,6 @@ fun LoginScreen(
|
||||
accountManager: AccountManager,
|
||||
onLoginSuccess: () -> Unit,
|
||||
) {
|
||||
var showNewKeyDialog by remember { mutableStateOf(false) }
|
||||
var generatedAccount by remember { mutableStateOf<AccountState.LoggedIn?>(null) }
|
||||
|
||||
val loginProgress by accountManager.loginProgress.collectAsState()
|
||||
val keychainUnavailable by accountManager.keychainUnavailable.collectAsState()
|
||||
|
||||
@@ -108,8 +100,10 @@ fun LoginScreen(
|
||||
}
|
||||
},
|
||||
onGenerateNew = {
|
||||
generatedAccount = accountManager.generateNewAccount()
|
||||
showNewKeyDialog = true
|
||||
// Hand off to the first-run onboarding screen (hoisted above the
|
||||
// account-state switch in Main): builds the key, walks the user
|
||||
// through backup, then activates + persists on finish.
|
||||
accountManager.beginNewAccountOnboarding()
|
||||
},
|
||||
onLoginBunker = { bunkerUri ->
|
||||
accountManager.loginWithBunker(bunkerUri).map {
|
||||
@@ -125,19 +119,6 @@ fun LoginScreen(
|
||||
},
|
||||
loginProgress = loginProgress,
|
||||
)
|
||||
|
||||
val account = generatedAccount
|
||||
if (showNewKeyDialog && account != null) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
NewKeyWarningCard(
|
||||
npub = account.npub,
|
||||
nsec = account.nsec,
|
||||
onContinue = {
|
||||
showNewKeyDialog = false
|
||||
onLoginSuccess()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-10
@@ -90,16 +90,11 @@ fun AddAccountDialog(
|
||||
Result.success(Unit)
|
||||
},
|
||||
onGenerateNew = {
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
accountManager.ensureCurrentAccountInStorage()
|
||||
}
|
||||
accountManager.generateNewAccount()
|
||||
withContext(Dispatchers.IO) {
|
||||
accountManager.saveCurrentAccount()
|
||||
}
|
||||
onAccountAdded()
|
||||
}
|
||||
// Hand off to the first-run onboarding screen (hoisted above the
|
||||
// account-state switch in Main). It builds the key, walks the user
|
||||
// through backup, then activates + persists on finish.
|
||||
accountManager.beginNewAccountOnboarding()
|
||||
onDismiss()
|
||||
},
|
||||
onLoginBunker = { bunkerUri ->
|
||||
accountManager.ensureCurrentAccountInStorage()
|
||||
|
||||
+647
@@ -0,0 +1,647 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui.auth
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.Color
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.action_copy
|
||||
import com.vitorpamplona.amethyst.commons.resources.backup_keys_copied
|
||||
import com.vitorpamplona.amethyst.commons.resources.backup_keys_copy_plain_warning
|
||||
import com.vitorpamplona.amethyst.commons.resources.backup_keys_encrypt_failed
|
||||
import com.vitorpamplona.amethyst.commons.resources.backup_keys_hide_qr
|
||||
import com.vitorpamplona.amethyst.commons.resources.backup_keys_show_qr
|
||||
import com.vitorpamplona.amethyst.commons.resources.new_key_back
|
||||
import com.vitorpamplona.amethyst.commons.resources.new_key_cancel
|
||||
import com.vitorpamplona.amethyst.commons.resources.new_key_confirm_recap
|
||||
import com.vitorpamplona.amethyst.commons.resources.new_key_confirm_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.new_key_continue_button
|
||||
import com.vitorpamplona.amethyst.commons.resources.new_key_copy_encrypted_button
|
||||
import com.vitorpamplona.amethyst.commons.resources.new_key_encrypt_password_label
|
||||
import com.vitorpamplona.amethyst.commons.resources.new_key_keys_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.new_key_next
|
||||
import com.vitorpamplona.amethyst.commons.resources.new_key_public_label
|
||||
import com.vitorpamplona.amethyst.commons.resources.new_key_readonly_info
|
||||
import com.vitorpamplona.amethyst.commons.resources.new_key_saved_checkbox
|
||||
import com.vitorpamplona.amethyst.commons.resources.new_key_secret_label
|
||||
import com.vitorpamplona.amethyst.commons.resources.new_key_step_indicator
|
||||
import com.vitorpamplona.amethyst.commons.resources.new_key_step_intro_npub
|
||||
import com.vitorpamplona.amethyst.commons.resources.new_key_step_intro_nsec
|
||||
import com.vitorpamplona.amethyst.commons.resources.new_key_step_intro_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.new_key_warning_message
|
||||
import com.vitorpamplona.amethyst.desktop.util.copyToClipboard
|
||||
import com.vitorpamplona.amethyst.desktop.util.copyToClipboardThenClear
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
private const val TOTAL_STEPS = 3
|
||||
|
||||
/**
|
||||
* Full-window, three-step onboarding shown ONCE right after a new Nostr keypair
|
||||
* is generated. This is the only moment the plaintext nsec is displayed.
|
||||
*
|
||||
* - Step 0 explains why the keys matter (npub is shareable, nsec can never be reset).
|
||||
* - Step 1 shows both keys: the npub with a plain copy + QR toggle, and the nsec
|
||||
* with a prominent auto-clearing plaintext copy plus a de-emphasised NIP-49
|
||||
* encrypted copy. The nsec is NEVER rendered as a QR code.
|
||||
* - Step 2 asks the user to confirm they saved the keys before proceeding.
|
||||
*
|
||||
* The whole thing is scrollable so nothing clips regardless of window size.
|
||||
*
|
||||
* @param npub The public key in npub format (shareable)
|
||||
* @param nsec The secret key in nsec format, or null for a read-only account
|
||||
* @param onFinish Called when the user acknowledged and wants to enter the app
|
||||
* @param onCancel Called when the user backs out; the new key should be discarded
|
||||
* @param modifier Modifier applied to the root container
|
||||
*/
|
||||
@Composable
|
||||
fun NewKeyOnboardingScreen(
|
||||
npub: String,
|
||||
nsec: String?,
|
||||
onFinish: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var step by remember { mutableStateOf(0) }
|
||||
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.widthIn(max = 560.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
StepIndicator(step = step)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
when (step) {
|
||||
0 ->
|
||||
IntroStep(
|
||||
onCancel = onCancel,
|
||||
onNext = { step = 1 },
|
||||
)
|
||||
1 ->
|
||||
KeysStep(
|
||||
npub = npub,
|
||||
nsec = nsec,
|
||||
onBack = { step = 0 },
|
||||
onNext = { step = 2 },
|
||||
)
|
||||
else ->
|
||||
ConfirmStep(
|
||||
onBack = { step = 1 },
|
||||
onFinish = onFinish,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StepIndicator(step: Int) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
repeat(TOTAL_STEPS) { index ->
|
||||
val color =
|
||||
if (index <= step) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(10.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
stringResource(Res.string.new_key_step_indicator, step + 1, TOTAL_STEPS),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IntroStep(
|
||||
onCancel: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
) {
|
||||
StepScaffold {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Warning,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
stringResource(Res.string.new_key_step_intro_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
stringResource(Res.string.new_key_warning_message),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
FramingRow(
|
||||
symbol = MaterialSymbols.Info,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
text = stringResource(Res.string.new_key_step_intro_npub),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
FramingRow(
|
||||
symbol = MaterialSymbols.Key,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
text = stringResource(Res.string.new_key_step_intro_nsec),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onCancel,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(stringResource(Res.string.new_key_cancel))
|
||||
}
|
||||
Button(
|
||||
onClick = onNext,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(stringResource(Res.string.new_key_next))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KeysStep(
|
||||
npub: String,
|
||||
nsec: String?,
|
||||
onBack: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
) {
|
||||
StepScaffold {
|
||||
Text(
|
||||
stringResource(Res.string.new_key_keys_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
PublicKeySection(npub = npub)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
if (nsec != null) {
|
||||
SecretKeySection(nsec = nsec)
|
||||
} else {
|
||||
FramingRow(
|
||||
symbol = MaterialSymbols.Info,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
text = stringResource(Res.string.new_key_readonly_info),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onBack,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(stringResource(Res.string.new_key_back))
|
||||
}
|
||||
Button(
|
||||
onClick = onNext,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(stringResource(Res.string.new_key_next))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PublicKeySection(npub: String) {
|
||||
var showQr by remember { mutableStateOf(false) }
|
||||
|
||||
Text(
|
||||
stringResource(Res.string.new_key_public_label),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
SelectableKeyText(npub)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
CopyButton(
|
||||
symbol = MaterialSymbols.ContentCopy,
|
||||
idleLabel = stringResource(Res.string.action_copy),
|
||||
onCopy = { copyToClipboard(npub) },
|
||||
)
|
||||
OutlinedButton(onClick = { showQr = !showQr }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.QrCode2,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
Text(
|
||||
if (showQr) {
|
||||
stringResource(Res.string.backup_keys_hide_qr)
|
||||
} else {
|
||||
stringResource(Res.string.backup_keys_show_qr)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showQr) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
QrCodeCanvas(data = npub)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SecretKeySection(nsec: String) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Text(
|
||||
stringResource(Res.string.new_key_secret_label),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
SelectableKeyText(nsec)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
CopyButton(
|
||||
symbol = MaterialSymbols.Key,
|
||||
idleLabel = stringResource(Res.string.action_copy),
|
||||
primary = true,
|
||||
onCopy = { copyToClipboardThenClear(nsec, scope, delayMs = 60_000L) },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
FramingRow(
|
||||
symbol = MaterialSymbols.Warning,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
text = stringResource(Res.string.backup_keys_copy_plain_warning),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
EncryptedCopySection(nsec = nsec)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EncryptedCopySection(nsec: String) {
|
||||
var password by remember { mutableStateOf("") }
|
||||
var showChars by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf(false) }
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
var working by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = {
|
||||
password = it
|
||||
error = false
|
||||
},
|
||||
label = { Text(stringResource(Res.string.new_key_encrypt_password_label)) },
|
||||
singleLine = true,
|
||||
isError = error,
|
||||
supportingText =
|
||||
if (error) {
|
||||
{ Text(stringResource(Res.string.backup_keys_encrypt_failed)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
visualTransformation =
|
||||
if (showChars) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showChars = !showChars }) {
|
||||
Icon(
|
||||
symbol = if (showChars) MaterialSymbols.VisibilityOff else MaterialSymbols.Visibility,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
error = false
|
||||
working = true
|
||||
scope.launch {
|
||||
val encrypted =
|
||||
withContext(Dispatchers.Default) {
|
||||
decodePrivateKeyAsHexOrNull(nsec)?.let {
|
||||
runCatching { Nip49().encrypt(it, password) }.getOrNull()
|
||||
}
|
||||
}
|
||||
working = false
|
||||
if (encrypted != null) {
|
||||
copyToClipboard(encrypted)
|
||||
copied = true
|
||||
} else {
|
||||
error = true
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = password.isNotBlank() && !working,
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.ContentCopy,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
Text(
|
||||
if (copied) {
|
||||
stringResource(Res.string.backup_keys_copied)
|
||||
} else {
|
||||
stringResource(Res.string.new_key_copy_encrypted_button)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ResetCopiedAfterDelay(copied) { copied = false }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConfirmStep(
|
||||
onBack: () -> Unit,
|
||||
onFinish: () -> Unit,
|
||||
) {
|
||||
var acknowledged by remember { mutableStateOf(false) }
|
||||
|
||||
StepScaffold {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
stringResource(Res.string.new_key_confirm_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
stringResource(Res.string.new_key_confirm_recap),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Checkbox(
|
||||
checked = acknowledged,
|
||||
onCheckedChange = { acknowledged = it },
|
||||
)
|
||||
Text(
|
||||
stringResource(Res.string.new_key_saved_checkbox),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onBack,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(stringResource(Res.string.new_key_back))
|
||||
}
|
||||
Button(
|
||||
onClick = onFinish,
|
||||
enabled = acknowledged,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(stringResource(Res.string.new_key_continue_button))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared per-step body: a card whose content scrolls so it never clips. */
|
||||
@Composable
|
||||
private fun StepScaffold(content: @Composable ColumnScope.() -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A small icon + explainer text row used to frame npub/nsec and warnings. */
|
||||
@Composable
|
||||
private fun FramingRow(
|
||||
symbol: MaterialSymbol,
|
||||
tint: Color,
|
||||
text: String,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = symbol,
|
||||
contentDescription = null,
|
||||
tint = tint,
|
||||
)
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy button that flashes a transient "Copied!" for ~2s after activation. */
|
||||
@Composable
|
||||
private fun CopyButton(
|
||||
symbol: MaterialSymbol,
|
||||
idleLabel: String,
|
||||
primary: Boolean = false,
|
||||
onCopy: () -> Unit,
|
||||
) {
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
val label =
|
||||
if (copied) stringResource(Res.string.backup_keys_copied) else idleLabel
|
||||
val onClick: () -> Unit = {
|
||||
onCopy()
|
||||
copied = true
|
||||
}
|
||||
|
||||
if (primary) {
|
||||
Button(onClick = onClick) {
|
||||
Icon(symbol = symbol, contentDescription = null, modifier = Modifier.padding(end = 4.dp))
|
||||
Text(label)
|
||||
}
|
||||
} else {
|
||||
OutlinedButton(onClick = onClick) {
|
||||
Icon(symbol = symbol, contentDescription = null, modifier = Modifier.padding(end = 4.dp))
|
||||
Text(label)
|
||||
}
|
||||
}
|
||||
|
||||
ResetCopiedAfterDelay(copied) { copied = false }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResetCopiedAfterDelay(
|
||||
copied: Boolean,
|
||||
onReset: () -> Unit,
|
||||
) {
|
||||
if (copied) {
|
||||
LaunchedEffect(Unit) {
|
||||
delay(2000)
|
||||
onReset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun NewKeyOnboardingScreenPreview() {
|
||||
NewKeyOnboardingScreen(
|
||||
npub = "npub1example1234567890abcdefghijklmnopqrstuvwxyz1234567890",
|
||||
nsec = "nsec1example1234567890abcdefghijklmnopqrstuvwxyz1234567890",
|
||||
onFinish = {},
|
||||
onCancel = {},
|
||||
)
|
||||
}
|
||||
-129
@@ -1,129 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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 androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.new_key_continue_button
|
||||
import com.vitorpamplona.amethyst.commons.resources.new_key_public_label
|
||||
import com.vitorpamplona.amethyst.commons.resources.new_key_secret_label
|
||||
import com.vitorpamplona.amethyst.commons.resources.new_key_warning_message
|
||||
import com.vitorpamplona.amethyst.commons.resources.new_key_warning_title
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
/**
|
||||
* Warning card displayed after generating a new Nostr key pair.
|
||||
* Reminds users to save their keys and shows both public and secret keys.
|
||||
*
|
||||
* @param npub The public key in npub format
|
||||
* @param nsec The secret key in nsec format (nullable for read-only accounts)
|
||||
* @param onContinue Callback when user acknowledges they've saved their keys
|
||||
* @param modifier Modifier for the card
|
||||
* @param cardWidth Width of the card (default 500.dp)
|
||||
*/
|
||||
@Composable
|
||||
fun NewKeyWarningCard(
|
||||
npub: String,
|
||||
nsec: String?,
|
||||
onContinue: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
cardWidth: Dp = 500.dp,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.width(cardWidth),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f),
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(Res.string.new_key_warning_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
stringResource(Res.string.new_key_warning_message),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
stringResource(Res.string.new_key_public_label),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
SelectableKeyText(npub)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
nsec?.let { secretKey ->
|
||||
Text(
|
||||
stringResource(Res.string.new_key_secret_label),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
SelectableKeyText(secretKey)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = onContinue,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(Res.string.new_key_continue_button))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun NewKeyWarningCardPreview() {
|
||||
NewKeyWarningCard(
|
||||
npub = "npub1example1234567890abcdefghijklmnopqrstuvwxyz",
|
||||
nsec = "nsec1example1234567890abcdefghijklmnopqrstuvwxyz",
|
||||
onContinue = {},
|
||||
)
|
||||
}
|
||||
+490
@@ -0,0 +1,490 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui.keyBackup
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.LockScope
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.LockState
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.backup_keys_copied
|
||||
import com.vitorpamplona.amethyst.commons.resources.backup_keys_copy
|
||||
import com.vitorpamplona.amethyst.commons.resources.backup_keys_copy_encrypted
|
||||
import com.vitorpamplona.amethyst.commons.resources.backup_keys_copy_plain
|
||||
import com.vitorpamplona.amethyst.commons.resources.backup_keys_copy_plain_warning
|
||||
import com.vitorpamplona.amethyst.commons.resources.backup_keys_encrypt_failed
|
||||
import com.vitorpamplona.amethyst.commons.resources.backup_keys_encrypt_password_label
|
||||
import com.vitorpamplona.amethyst.commons.resources.backup_keys_external_signer
|
||||
import com.vitorpamplona.amethyst.commons.resources.backup_keys_hide
|
||||
import com.vitorpamplona.amethyst.commons.resources.backup_keys_hide_qr
|
||||
import com.vitorpamplona.amethyst.commons.resources.backup_keys_public_help
|
||||
import com.vitorpamplona.amethyst.commons.resources.backup_keys_public_label
|
||||
import com.vitorpamplona.amethyst.commons.resources.backup_keys_reveal
|
||||
import com.vitorpamplona.amethyst.commons.resources.backup_keys_secret_hidden
|
||||
import com.vitorpamplona.amethyst.commons.resources.backup_keys_secret_label
|
||||
import com.vitorpamplona.amethyst.commons.resources.backup_keys_secret_warning
|
||||
import com.vitorpamplona.amethyst.commons.resources.backup_keys_show_qr
|
||||
import com.vitorpamplona.amethyst.commons.resources.backup_keys_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.backup_keys_unlock_subtitle
|
||||
import com.vitorpamplona.amethyst.commons.resources.backup_keys_unlock_title
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.security.DesktopLockScreen
|
||||
import com.vitorpamplona.amethyst.desktop.ui.auth.QrCodeCanvas
|
||||
import com.vitorpamplona.amethyst.desktop.util.copyToClipboard
|
||||
import com.vitorpamplona.amethyst.desktop.util.copyToClipboardThenClear
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
/**
|
||||
* Account key backup card shown in the Desktop settings/profile screen.
|
||||
*
|
||||
* The public key (npub) is treated as shareable: plain, copyable, QR is fine.
|
||||
* The secret key (nsec) is treated as an unrecoverable password: masked by
|
||||
* default, its reveal AND copy are gated behind the existing PrivacyLock
|
||||
* ([LockScope.KeyBackup]), and it is NEVER rendered as a QR code. Encrypted
|
||||
* (NIP-49 `ncryptsec1…`) copy is offered as the recommended path.
|
||||
*/
|
||||
@Composable
|
||||
fun BackupKeysCard(
|
||||
account: AccountState.LoggedIn,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
Text(
|
||||
stringResource(Res.string.backup_keys_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
PublicKeySection(npub = account.npub)
|
||||
|
||||
val nsec = account.nsec
|
||||
if (nsec != null) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SecretKeySection(nsec = nsec)
|
||||
} else {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Info,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
stringResource(Res.string.backup_keys_external_signer),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PublicKeySection(npub: String) {
|
||||
var showQr by remember { mutableStateOf(false) }
|
||||
|
||||
Text(
|
||||
stringResource(Res.string.backup_keys_public_label),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
stringResource(Res.string.backup_keys_public_help),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
MonospaceKeyValue(value = npub)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
CopyButton(value = npub)
|
||||
OutlinedButton(onClick = { showQr = !showQr }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.QrCode2,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
Text(
|
||||
if (showQr) {
|
||||
stringResource(Res.string.backup_keys_hide_qr)
|
||||
} else {
|
||||
stringResource(Res.string.backup_keys_show_qr)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showQr) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
QrCodeCanvas(data = npub)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SecretKeySection(nsec: String) {
|
||||
val lockState = lockStateFor(LockScope.KeyBackup)
|
||||
val current by lockState.state.collectAsState()
|
||||
|
||||
var revealed by remember { mutableStateOf(false) }
|
||||
var awaitingUnlock by remember { mutableStateOf(false) }
|
||||
|
||||
// Re-hide whenever we leave this route/composable.
|
||||
DisposableEffect(lockState) {
|
||||
onDispose {
|
||||
lockState.onLeaveRoute()
|
||||
revealed = false
|
||||
awaitingUnlock = false
|
||||
}
|
||||
}
|
||||
|
||||
// When the user asked to reveal and the gate becomes usable, show the key.
|
||||
LaunchedEffect(current, awaitingUnlock) {
|
||||
if (awaitingUnlock && current !is LockState.Locked) {
|
||||
revealed = true
|
||||
awaitingUnlock = false
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
stringResource(Res.string.backup_keys_secret_label),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Warning banner
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
).padding(12.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Text(
|
||||
stringResource(Res.string.backup_keys_secret_warning),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
if (awaitingUnlock && current is LockState.Locked) {
|
||||
// Force an unlock before revealing. DesktopLockScreen is a fillMaxSize
|
||||
// Surface, so present it inside a modal Dialog with a bounded box rather
|
||||
// than letting it take over the whole settings pane.
|
||||
Dialog(onDismissRequest = { awaitingUnlock = false }) {
|
||||
Surface(
|
||||
modifier = Modifier.size(width = 420.dp, height = 380.dp),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
tonalElevation = 6.dp,
|
||||
) {
|
||||
DesktopLockScreen(
|
||||
scope = LockScope.KeyBackup,
|
||||
title = stringResource(Res.string.backup_keys_unlock_title),
|
||||
subtitle = stringResource(Res.string.backup_keys_unlock_subtitle),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (!revealed) {
|
||||
Text(
|
||||
stringResource(Res.string.backup_keys_secret_hidden),
|
||||
style =
|
||||
MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
if (current is LockState.Locked) {
|
||||
awaitingUnlock = true
|
||||
} else {
|
||||
revealed = true
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Visibility,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
Text(stringResource(Res.string.backup_keys_reveal))
|
||||
}
|
||||
} else {
|
||||
RevealedSecret(nsec = nsec, onHide = { revealed = false })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RevealedSecret(
|
||||
nsec: String,
|
||||
onHide: () -> Unit,
|
||||
) {
|
||||
MonospaceKeyValue(value = nsec, isSensitive = true)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
CopyButton(
|
||||
value = nsec,
|
||||
label = stringResource(Res.string.backup_keys_copy_plain),
|
||||
autoClearSensitive = true,
|
||||
)
|
||||
OutlinedButton(onClick = onHide) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.VisibilityOff,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
Text(stringResource(Res.string.backup_keys_hide))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
stringResource(Res.string.backup_keys_copy_plain_warning),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
EncryptedCopy(nsec = nsec)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EncryptedCopy(nsec: String) {
|
||||
var password by remember { mutableStateOf("") }
|
||||
var showChars by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf(false) }
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
var working by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = {
|
||||
password = it
|
||||
error = false
|
||||
},
|
||||
label = { Text(stringResource(Res.string.backup_keys_encrypt_password_label)) },
|
||||
singleLine = true,
|
||||
isError = error,
|
||||
supportingText =
|
||||
if (error) {
|
||||
{ Text(stringResource(Res.string.backup_keys_encrypt_failed)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
visualTransformation =
|
||||
if (showChars) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showChars = !showChars }) {
|
||||
Icon(
|
||||
symbol = if (showChars) MaterialSymbols.VisibilityOff else MaterialSymbols.Visibility,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Same treatment as the plain Copy button. Encryption (scrypt) runs off the
|
||||
// UI thread so the button stays responsive and reliably flips to "Copied!".
|
||||
Button(
|
||||
onClick = {
|
||||
error = false
|
||||
working = true
|
||||
scope.launch {
|
||||
val encrypted =
|
||||
withContext(Dispatchers.Default) {
|
||||
decodePrivateKeyAsHexOrNull(nsec)?.let {
|
||||
runCatching { Nip49().encrypt(it, password) }.getOrNull()
|
||||
}
|
||||
}
|
||||
working = false
|
||||
if (encrypted != null) {
|
||||
copyToClipboard(encrypted)
|
||||
copied = true
|
||||
} else {
|
||||
error = true
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = password.isNotBlank() && !working,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.ContentCopy,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
Text(
|
||||
if (copied) {
|
||||
stringResource(Res.string.backup_keys_copied)
|
||||
} else {
|
||||
stringResource(Res.string.backup_keys_copy_encrypted)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (copied) {
|
||||
LaunchedEffect(Unit) {
|
||||
delay(2000)
|
||||
copied = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonospaceKeyValue(
|
||||
value: String,
|
||||
isSensitive: Boolean = false,
|
||||
) {
|
||||
Text(
|
||||
value,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
color =
|
||||
if (isSensitive) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
).padding(8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CopyButton(
|
||||
value: String,
|
||||
label: String = stringResource(Res.string.backup_keys_copy),
|
||||
autoClearSensitive: Boolean = false,
|
||||
) {
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (autoClearSensitive) {
|
||||
copyToClipboardThenClear(value, scope)
|
||||
} else {
|
||||
copyToClipboard(value)
|
||||
}
|
||||
copied = true
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.ContentCopy,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
Text(if (copied) stringResource(Res.string.backup_keys_copied) else label)
|
||||
}
|
||||
|
||||
if (copied) {
|
||||
LaunchedEffect(Unit) {
|
||||
delay(2000)
|
||||
copied = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.util
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.awt.Toolkit
|
||||
import java.awt.datatransfer.DataFlavor
|
||||
import java.awt.datatransfer.StringSelection
|
||||
|
||||
/**
|
||||
* Copy [text] to the system clipboard using the AWT Toolkit.
|
||||
*
|
||||
* Shared by every Desktop call site that needs plain clipboard access
|
||||
* (developer settings, key-backup, new-key warning card, …).
|
||||
*/
|
||||
fun copyToClipboard(text: String) {
|
||||
try {
|
||||
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
|
||||
val selection = StringSelection(text)
|
||||
clipboard.setContents(selection, selection)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads the current clipboard as a String, or null if it isn't text / unavailable. */
|
||||
private fun clipboardString(): String? =
|
||||
try {
|
||||
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
|
||||
if (clipboard.isDataFlavorAvailable(DataFlavor.stringFlavor)) {
|
||||
clipboard.getData(DataFlavor.stringFlavor) as? String
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy sensitive [text] to the clipboard, then best-effort wipe it after
|
||||
* [delayMs]. The clipboard is only cleared if it still holds this exact value,
|
||||
* so anything the user copied in the meantime is left untouched.
|
||||
*
|
||||
* Intended for the plaintext nsec copy in key backup — not for shareable values.
|
||||
*/
|
||||
fun copyToClipboardThenClear(
|
||||
text: String,
|
||||
scope: CoroutineScope,
|
||||
delayMs: Long = 60_000L,
|
||||
) {
|
||||
copyToClipboard(text)
|
||||
scope.launch {
|
||||
delay(delayMs)
|
||||
if (clipboardString() == text) {
|
||||
copyToClipboard("")
|
||||
}
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.account
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNsec
|
||||
import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Verifies the exact key-backup glue the UI uses: encode a generated key to an
|
||||
* `nsec`, decode it back to hex, NIP-49-encrypt it to `ncryptsec1…`, and prove
|
||||
* the encrypted blob decrypts back to the original key with the right password
|
||||
* (and fails with the wrong one). This is the "how do I restore my encrypted
|
||||
* backup" contract — the encrypt path is dead weight if it can't round-trip.
|
||||
*/
|
||||
class EncryptedKeyBackupTest {
|
||||
@Test
|
||||
fun encryptedBackupRoundTrips() {
|
||||
val keyPair = KeyPair()
|
||||
val nsec = keyPair.privKey!!.toNsec()
|
||||
val password = "correct horse battery staple"
|
||||
|
||||
// Same calls the UI makes: nsec -> hex -> Nip49 encrypt.
|
||||
val hex = decodePrivateKeyAsHexOrNull(nsec)
|
||||
assertNotNull(hex, "nsec should decode to hex")
|
||||
|
||||
val ncryptsec = Nip49().encrypt(hex, password)
|
||||
assertTrue(ncryptsec.startsWith("ncryptsec1"), "expected ncryptsec1 prefix, got: $ncryptsec")
|
||||
|
||||
// Restore path: decrypt with the correct password recovers the original key.
|
||||
val decryptedHex = Nip49().decrypt(ncryptsec, password)
|
||||
assertEquals(hex, decryptedHex, "decrypt must recover the original private key")
|
||||
assertEquals(keyPair.pubKey.toHexKey(), KeyPair(privKey = decryptedHex.hexToByteArray()).pubKey.toHexKey())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wrongPasswordFails() {
|
||||
val keyPair = KeyPair()
|
||||
val hex = decodePrivateKeyAsHexOrNull(keyPair.privKey!!.toNsec())!!
|
||||
val ncryptsec = Nip49().encrypt(hex, "the right password")
|
||||
|
||||
assertFailsWith<Throwable> {
|
||||
Nip49().decrypt(ncryptsec, "the WRONG password")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user