mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
feat(desktop): guided first-run key backup + reachable settings backup
Reworks the Desktop new-account key backup after testing surfaced that the original card never rendered and the settings entry sat on dead code. - Guided 3-step "Save your keys" onboarding (NewKeyOnboardingScreen), full window + scrollable so it never clips. Used by BOTH the cold-start login and the in-app "+ Add Account" generate paths. Retires NewKeyWarningCard. - Split key generation from activation (AccountManager.buildNewAccount + activateAccount + begin/finish/cancelNewAccountOnboarding). generateNewAccount previously flipped account state immediately, tearing down the screen before any backup UI could show — the root cause of "no save-your-keys prompt". - Move BackupKeysCard into the reachable Settings screen (RelaySettingsScreen); the old ProfileScreen host had no call sites. - Fix the password show/hide eye (was a non-clickable Icon) in both the onboarding and settings encrypted-copy sections. - Run NIP-49 encryption off the UI thread so the "Copy encrypted" button stays responsive and reliably flips to "Copied!"; match the plain copy button style. - Add EncryptedKeyBackupTest: nsec -> hex -> Nip49 encrypt -> decrypt round-trip (and wrong-password fails). - Plans + manual testing sheet under desktopApp/plans/. Known follow-up: Desktop loginWithKey does not yet import ncryptsec1, so encrypted backups can't be restored on Desktop yet (Android already handles it). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
129bad5a02
commit
b6210e9fd0
@@ -25,6 +25,17 @@
|
|||||||
<string name="new_key_saved_checkbox">I have saved my keys somewhere safe</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_copy_encrypted_button">Copy encrypted (recommended)</string>
|
||||||
<string name="new_key_encrypt_password_label">Password for encrypted backup</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) -->
|
<!-- Key Backup (Desktop settings) -->
|
||||||
<string name="backup_keys_title">Backup Keys</string>
|
<string name="backup_keys_title">Backup Keys</string>
|
||||||
|
|||||||
@@ -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,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.LoginScreen
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
|
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.auth.ForceLogoutDialog
|
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.DesktopDmRoute
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker
|
import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawer
|
import com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawer
|
||||||
@@ -1337,300 +1338,322 @@ private fun AppInner(
|
|||||||
LocalNotificationSettings provides notifSettings,
|
LocalNotificationSettings provides notifSettings,
|
||||||
LocalNotificationReadState provides notifReadState,
|
LocalNotificationReadState provides notifReadState,
|
||||||
) {
|
) {
|
||||||
when (accountState) {
|
val pendingNewAccount by accountManager.pendingNewAccount.collectAsState()
|
||||||
is AccountState.Loading -> {
|
val pendingNew = pendingNewAccount
|
||||||
// Branded loading screen while accounts load from storage
|
if (pendingNew != null) {
|
||||||
val loadingIcon = com.vitorpamplona.amethyst.desktop.platform.IconResources.rawBitmapPainter
|
// First-run "save your keys" onboarding for a freshly generated
|
||||||
Box(
|
// account. Rendered above the account-state switch so it survives
|
||||||
modifier = Modifier.fillMaxSize(),
|
// until the user finishes (which activates + persists the account).
|
||||||
contentAlignment = Alignment.Center,
|
NewKeyOnboardingScreen(
|
||||||
) {
|
npub = pendingNew.npub,
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
nsec = pendingNew.nsec,
|
||||||
androidx.compose.material3.CircularProgressIndicator(
|
onFinish = {
|
||||||
modifier = Modifier.size(32.dp),
|
scope.launch(Dispatchers.IO) {
|
||||||
color = MaterialTheme.colorScheme.primary,
|
accountManager.ensureCurrentAccountInStorage()
|
||||||
strokeWidth = 3.dp,
|
accountManager.finishNewAccountOnboarding()
|
||||||
)
|
accountManager.saveCurrentAccount()
|
||||||
Spacer(Modifier.height(16.dp))
|
accountManager.ensureCurrentAccountInStorage()
|
||||||
Text(
|
accountManager.refreshAccountList()
|
||||||
"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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
onCancel = { accountManager.cancelNewAccountOnboarding() },
|
||||||
|
)
|
||||||
is AccountState.LoggedOut -> {
|
} else {
|
||||||
LoginScreen(
|
when (accountState) {
|
||||||
accountManager = accountManager,
|
is AccountState.Loading -> {
|
||||||
onLoginSuccess = {
|
// Branded loading screen while accounts load from storage
|
||||||
// Start heartbeat if bunker account
|
val loadingIcon = com.vitorpamplona.amethyst.desktop.platform.IconResources.rawBitmapPainter
|
||||||
val current = accountManager.currentAccount()
|
Box(
|
||||||
if (current?.signerType is com.vitorpamplona.amethyst.commons.model.account.SignerType.Remote) {
|
modifier = Modifier.fillMaxSize(),
|
||||||
accountManager.startHeartbeat(scope)
|
contentAlignment = Alignment.Center,
|
||||||
}
|
|
||||||
// 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,
|
|
||||||
) {
|
) {
|
||||||
ComposeNoteDialog(
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
onDismiss = onDismissComposeDialog,
|
androidx.compose.material3.CircularProgressIndicator(
|
||||||
relayManager = relayManager,
|
modifier = Modifier.size(32.dp),
|
||||||
account = account,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
localCache = localCache,
|
strokeWidth = 3.dp,
|
||||||
replyTo = replyToNote,
|
)
|
||||||
draftDTag = composeEditDraftTag,
|
Spacer(Modifier.height(16.dp))
|
||||||
draftInitialContent = composeEditContent,
|
Text(
|
||||||
initialScheduledForSec = composeEditScheduledForSec,
|
"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
|
is AccountState.LoggedOut -> {
|
||||||
if (showAppDrawer) {
|
LoginScreen(
|
||||||
val openColumns by deckState.columns.collectAsState()
|
accountManager = accountManager,
|
||||||
AppDrawer(
|
onLoginSuccess = {
|
||||||
initialTab = appDrawerInitialTab,
|
// Start heartbeat if bunker account
|
||||||
openColumnTypes =
|
val current = accountManager.currentAccount()
|
||||||
if (layoutMode == LayoutMode.DECK) {
|
if (current?.signerType is com.vitorpamplona.amethyst.commons.model.account.SignerType.Remote) {
|
||||||
openColumns.map { it.type.typeKey() }.toSet()
|
accountManager.startHeartbeat(scope)
|
||||||
} 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
// Save account (privkey to keychain + metadata to disk)
|
||||||
onSelectScreen = { type ->
|
// then ensure multi-account storage is up to date.
|
||||||
when (layoutMode) {
|
// Uses App-level scope so it survives LoginScreen leaving composition.
|
||||||
LayoutMode.DECK -> {
|
scope.launch(Dispatchers.IO) {
|
||||||
if (deckState.hasColumnOfType(type)) {
|
accountManager.saveCurrentAccount()
|
||||||
deckState.focusExistingColumn(type)
|
accountManager.ensureCurrentAccountInStorage()
|
||||||
} else {
|
accountManager.refreshAccountList()
|
||||||
deckState.addColumn(type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LayoutMode.SINGLE_PANE -> {
|
|
||||||
singlePaneState.navigate(type)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
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()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2353,10 +2376,6 @@ fun ProfileScreen(
|
|||||||
isReadOnly = account.isReadOnly,
|
isReadOnly = account.isReadOnly,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
|
|
||||||
BackupKeysCard(account = account)
|
|
||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
@@ -2422,6 +2441,11 @@ fun RelaySettingsScreen(
|
|||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Account Keys / Backup Section
|
||||||
|
BackupKeysCard(account = account)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
// Wallet Connect Section
|
// Wallet Connect Section
|
||||||
Text(
|
Text(
|
||||||
"Wallet Connect (NWC)",
|
"Wallet Connect (NWC)",
|
||||||
|
|||||||
+56
-10
@@ -554,20 +554,66 @@ class AccountManager internal constructor(
|
|||||||
return Result.success(Unit)
|
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 keyPair = KeyPair()
|
||||||
val signer = NostrSignerInternal(keyPair)
|
val signer = NostrSignerInternal(keyPair)
|
||||||
|
|
||||||
val state =
|
return AccountState.LoggedIn(
|
||||||
AccountState.LoggedIn(
|
signer = signer,
|
||||||
signer = signer,
|
pubKeyHex = keyPair.pubKey.toHexKey(),
|
||||||
pubKeyHex = keyPair.pubKey.toHexKey(),
|
npub = keyPair.pubKey.toNpub(),
|
||||||
npub = keyPair.pubKey.toNpub(),
|
nsec = keyPair.privKey?.toNsec(),
|
||||||
nsec = keyPair.privKey?.toNsec(),
|
isReadOnly = false,
|
||||||
isReadOnly = false,
|
)
|
||||||
)
|
}
|
||||||
|
|
||||||
|
/** Activates a previously-[buildNewAccount]-ed (or any) state as the current account. */
|
||||||
|
fun activateAccount(state: AccountState.LoggedIn) {
|
||||||
_accountState.value = state
|
_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> {
|
fun loginWithKey(keyInput: String): Result<AccountState.LoggedIn> {
|
||||||
|
|||||||
@@ -39,9 +39,6 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
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.resources.login_title
|
||||||
import com.vitorpamplona.amethyst.commons.ui.theme.StatusGreen
|
import com.vitorpamplona.amethyst.commons.ui.theme.StatusGreen
|
||||||
import com.vitorpamplona.amethyst.desktop.account.AccountManager
|
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.network.RelayStatus
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.auth.LoginCard
|
import com.vitorpamplona.amethyst.desktop.ui.auth.LoginCard
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.auth.NewKeyWarningCard
|
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -63,9 +58,6 @@ fun LoginScreen(
|
|||||||
accountManager: AccountManager,
|
accountManager: AccountManager,
|
||||||
onLoginSuccess: () -> Unit,
|
onLoginSuccess: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var showNewKeyDialog by remember { mutableStateOf(false) }
|
|
||||||
var generatedAccount by remember { mutableStateOf<AccountState.LoggedIn?>(null) }
|
|
||||||
|
|
||||||
val loginProgress by accountManager.loginProgress.collectAsState()
|
val loginProgress by accountManager.loginProgress.collectAsState()
|
||||||
val keychainUnavailable by accountManager.keychainUnavailable.collectAsState()
|
val keychainUnavailable by accountManager.keychainUnavailable.collectAsState()
|
||||||
|
|
||||||
@@ -108,8 +100,10 @@ fun LoginScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onGenerateNew = {
|
onGenerateNew = {
|
||||||
generatedAccount = accountManager.generateNewAccount()
|
// Hand off to the first-run onboarding screen (hoisted above the
|
||||||
showNewKeyDialog = true
|
// account-state switch in Main): builds the key, walks the user
|
||||||
|
// through backup, then activates + persists on finish.
|
||||||
|
accountManager.beginNewAccountOnboarding()
|
||||||
},
|
},
|
||||||
onLoginBunker = { bunkerUri ->
|
onLoginBunker = { bunkerUri ->
|
||||||
accountManager.loginWithBunker(bunkerUri).map {
|
accountManager.loginWithBunker(bunkerUri).map {
|
||||||
@@ -125,19 +119,6 @@ fun LoginScreen(
|
|||||||
},
|
},
|
||||||
loginProgress = loginProgress,
|
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)
|
Result.success(Unit)
|
||||||
},
|
},
|
||||||
onGenerateNew = {
|
onGenerateNew = {
|
||||||
scope.launch {
|
// Hand off to the first-run onboarding screen (hoisted above the
|
||||||
withContext(Dispatchers.IO) {
|
// account-state switch in Main). It builds the key, walks the user
|
||||||
accountManager.ensureCurrentAccountInStorage()
|
// through backup, then activates + persists on finish.
|
||||||
}
|
accountManager.beginNewAccountOnboarding()
|
||||||
accountManager.generateNewAccount()
|
onDismiss()
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
accountManager.saveCurrentAccount()
|
|
||||||
}
|
|
||||||
onAccountAdded()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onLoginBunker = { bunkerUri ->
|
onLoginBunker = { bunkerUri ->
|
||||||
accountManager.ensureCurrentAccountInStorage()
|
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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
-296
@@ -1,296 +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.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.width
|
|
||||||
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.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.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
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 androidx.compose.ui.unit.dp
|
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
|
||||||
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_encrypt_failed
|
|
||||||
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_public_label
|
|
||||||
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_warning_message
|
|
||||||
import com.vitorpamplona.amethyst.commons.resources.new_key_warning_title
|
|
||||||
import com.vitorpamplona.amethyst.desktop.util.copyToClipboard
|
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull
|
|
||||||
import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
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.
|
|
||||||
*
|
|
||||||
* The npub is shareable (plain + copy). The nsec is an unrecoverable password:
|
|
||||||
* it is shown so the user can save it, offers plaintext AND NIP-49 encrypted
|
|
||||||
* copy, and is never rendered as a QR code. A soft acknowledgement checkbox
|
|
||||||
* nudges the user to confirm they saved their keys before continuing.
|
|
||||||
*
|
|
||||||
* @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,
|
|
||||||
) {
|
|
||||||
var acknowledged by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
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(8.dp))
|
|
||||||
CopyKeyButton(value = 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(8.dp))
|
|
||||||
CopyKeyButton(value = secretKey)
|
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
EncryptedCopyRow(nsec = secretKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(16.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(16.dp))
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = onContinue,
|
|
||||||
enabled = acknowledged,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text(stringResource(Res.string.new_key_continue_button))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun CopyKeyButton(value: String) {
|
|
||||||
var copied by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = {
|
|
||||||
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 {
|
|
||||||
stringResource(Res.string.action_copy)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (copied) {
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
delay(2000)
|
|
||||||
copied = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun EncryptedCopyRow(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) }
|
|
||||||
|
|
||||||
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 = {
|
|
||||||
Icon(
|
|
||||||
symbol = if (showChars) MaterialSymbols.VisibilityOff else MaterialSymbols.Visibility,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.padding(end = 8.dp),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
val hex = decodePrivateKeyAsHexOrNull(nsec)
|
|
||||||
val encrypted =
|
|
||||||
hex?.let { runCatching { Nip49().encrypt(it, password) }.getOrNull() }
|
|
||||||
if (encrypted != null) {
|
|
||||||
copyToClipboard(encrypted)
|
|
||||||
copied = true
|
|
||||||
} else {
|
|
||||||
error = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = password.isNotBlank(),
|
|
||||||
colors =
|
|
||||||
ButtonDefaults.buttonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
symbol = MaterialSymbols.Key,
|
|
||||||
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)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (copied) {
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
delay(2000)
|
|
||||||
copied = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun NewKeyWarningCardPreview() {
|
|
||||||
NewKeyWarningCard(
|
|
||||||
npub = "npub1example1234567890abcdefghijklmnopqrstuvwxyz",
|
|
||||||
nsec = "nsec1example1234567890abcdefghijklmnopqrstuvwxyz",
|
|
||||||
onContinue = {},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+32
-20
@@ -31,10 +31,10 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
@@ -90,7 +90,10 @@ import com.vitorpamplona.amethyst.desktop.util.copyToClipboard
|
|||||||
import com.vitorpamplona.amethyst.desktop.util.copyToClipboardThenClear
|
import com.vitorpamplona.amethyst.desktop.util.copyToClipboardThenClear
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull
|
import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull
|
||||||
import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49
|
import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -348,6 +351,8 @@ private fun EncryptedCopy(nsec: String) {
|
|||||||
var showChars by remember { mutableStateOf(false) }
|
var showChars by remember { mutableStateOf(false) }
|
||||||
var error by remember { mutableStateOf(false) }
|
var error by remember { mutableStateOf(false) }
|
||||||
var copied by remember { mutableStateOf(false) }
|
var copied by remember { mutableStateOf(false) }
|
||||||
|
var working by remember { mutableStateOf(false) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = password,
|
value = password,
|
||||||
@@ -367,37 +372,44 @@ private fun EncryptedCopy(nsec: String) {
|
|||||||
visualTransformation =
|
visualTransformation =
|
||||||
if (showChars) VisualTransformation.None else PasswordVisualTransformation(),
|
if (showChars) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
Icon(
|
IconButton(onClick = { showChars = !showChars }) {
|
||||||
symbol = if (showChars) MaterialSymbols.VisibilityOff else MaterialSymbols.Visibility,
|
Icon(
|
||||||
contentDescription = null,
|
symbol = if (showChars) MaterialSymbols.VisibilityOff else MaterialSymbols.Visibility,
|
||||||
modifier = Modifier.padding(end = 8.dp),
|
contentDescription = null,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
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(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
val hex = decodePrivateKeyAsHexOrNull(nsec)
|
error = false
|
||||||
val encrypted =
|
working = true
|
||||||
hex?.let { runCatching { Nip49().encrypt(it, password) }.getOrNull() }
|
scope.launch {
|
||||||
if (encrypted != null) {
|
val encrypted =
|
||||||
copyToClipboard(encrypted)
|
withContext(Dispatchers.Default) {
|
||||||
copied = true
|
decodePrivateKeyAsHexOrNull(nsec)?.let {
|
||||||
} else {
|
runCatching { Nip49().encrypt(it, password) }.getOrNull()
|
||||||
error = true
|
}
|
||||||
|
}
|
||||||
|
working = false
|
||||||
|
if (encrypted != null) {
|
||||||
|
copyToClipboard(encrypted)
|
||||||
|
copied = true
|
||||||
|
} else {
|
||||||
|
error = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = password.isNotBlank(),
|
enabled = password.isNotBlank() && !working,
|
||||||
colors =
|
|
||||||
ButtonDefaults.buttonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
|
||||||
),
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
symbol = MaterialSymbols.Key,
|
symbol = MaterialSymbols.ContentCopy,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.padding(end = 4.dp),
|
modifier = Modifier.padding(end = 4.dp),
|
||||||
)
|
)
|
||||||
|
|||||||
+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