Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions WALLET_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -772,25 +772,25 @@ existing wallet have no obvious entry point.

**Restructure per §2.6**:

- [ ] Replace the top-level layout with the two-row picker (Spark /
- [x] Replace the top-level layout with the two-row picker (Spark /
NWC) described in §2.6 Screen 1.
- [ ] Add a Spark sub-screen matching §2.6 Screen 2 with four option
- [x] Add a Spark sub-screen matching §2.6 Screen 2 with four option
rows (or three when `hasKeypair() == false`).
- [ ] Move the `maybeAutoCreateDefaultWallet()` entry point to the new
- [x] Move the `maybeAutoCreateDefaultWallet()` entry point to the new
"Use my default wallet" row at the top of the Spark sub-screen.
Ignore `skipAutoCreate` on explicit tap.
- [ ] Add the string resources from §2.6 to `strings.xml`.
- [ ] Verify the existing flows still wire through:
- [x] Add the string resources from §2.6 to `strings.xml`.
- [x] Verify the existing flows still wire through:
- Create new wallet → existing BIP39-generate + confirm-backup flow
- Restore from seed phrase → existing 12-word entry flow
- Restore from relays → existing NIP-78 backup search flow
- Nostr Wallet Connect → existing NWC paste-string flow
- [ ] Disconnect flow on a default wallet says **"Switch Wallet"** and
- [x] Disconnect flow on a default wallet says **"Switch Wallet"** and
the body copy refers to the wallet as your *default wallet* —
never "Wisp wallet" or "wisp wallet".
- [ ] Settings section header renamed from "Danger Zone" to
- [x] Settings section header renamed from "Danger Zone" to
**"Disconnect Wallet"** (per §4.8).
- [ ] Dashboard welcome banner for default wallets per §3.5 (blue/accent
- [x] Dashboard welcome banner for default wallets per §3.5 (blue/accent
tint, key icon, "secured by your key" copy) — separate from the
existing amber warning banner for custom wallets.

Expand Down
133 changes: 123 additions & 10 deletions app/src/main/kotlin/com/wisp/app/Navigation.kt

Large diffs are not rendered by default.

93 changes: 93 additions & 0 deletions app/src/main/kotlin/com/wisp/app/nostr/Nip78.kt
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,97 @@ object Nip78 {
/** Extract the d-tag value from an event, or null. */
fun extractDTag(event: NostrEvent): String? =
event.tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1)

// ─── App Settings Backup ──────────────────────────────────────────────────

const val APP_SETTINGS_D_TAG = "wisp-app-settings:v1"

/**
* Versioned, NIP-44-encrypted UI-prefs payload stored as kind 30078.
* Every field is optional so older / newer clients round-trip without
* data loss. JSON keys match iOS Nip78Backup.AppSettingsPayload
* byte-for-byte so the backups are bit-compatible across platforms.
*/
@kotlinx.serialization.Serializable
data class AppSettingsPayload(
// Reactions (iOS-only UI today; Android round-trips defaultReaction
// + defaultReactionEnabled as opaque values so iOS settings survive
// an Android publish.)
val defaultReaction: String? = null,
val defaultReactionEnabled: Boolean? = null,
// Quick zaps
val quickZapEnabled: Boolean? = null,
val quickZapAmountSats: Long? = null,
val quickZapAmountFiat: Double? = null,
val quickZapMessage: String? = null,
val zapIconStyle: String? = null,
// Fiat prefs
val fiatModeEnabled: Boolean? = null,
val fiatCurrency: String? = null,
// Zap presets
val zapPresetsCSV: String? = null,
// Reaction popup state (Android: unicodeEmojis + frequency map in
// CustomEmojiRepository; iOS: EmojiRepository).
val quickReactions: List<String>? = null,
val frequency: Map<String, Int>? = null,
// Appearance
val largeText: Boolean? = null,
val themeName: String? = null,
// iOS-only light/dark override today; Android round-trips it.
val colorScheme: String? = null,
// iOS encodes ARGB as Swift `Int` (64-bit), so values like
// 0xFFFF9800 (4_294_940_672) overflow Kotlin's signed 32-bit
// Int. Use Long here and convert at the setter — the lower 32
// bits round-trip correctly even when Int reads them negative.
val accentColorARGB: Long? = null,
// Media
val autoLoadMedia: Boolean? = null,
val videoAutoplay: Boolean? = null,
// iOS-only toggle for animated avatars; Android always animates,
// so this is round-tripped opaquely.
val animateAvatars: Boolean? = null,
val mediaLayoutStyle: String? = null,
// Posting
val clientTagEnabled: Boolean? = null,
val postUndoTimerEnabled: Boolean? = null,
val postUndoTimerSeconds: Int? = null,
val postUndoTimerForReplies: Boolean? = null,
val version: Int? = 1
)

private val lenientJson = kotlinx.serialization.json.Json {
ignoreUnknownKeys = true
encodeDefaults = false
}

/** Build and sign a kind 30078 event carrying the NIP-44-encrypted settings JSON. */
suspend fun createAppSettingsEvent(signer: NostrSigner, payload: AppSettingsPayload): NostrEvent {
val json = lenientJson.encodeToString(AppSettingsPayload.serializer(), payload)
val encrypted = signer.nip44Encrypt(json, signer.pubkeyHex)
val tags = listOf(
listOf("d", APP_SETTINGS_D_TAG),
listOf("encryption", "nip44")
)
return signer.signEvent(kind = KIND, content = encrypted, tags = tags)
}

/** Decrypt a kind 30078 app-settings event and parse the JSON payload. Returns null on any failure. */
suspend fun decryptAppSettings(signer: NostrSigner, event: NostrEvent): AppSettingsPayload? {
if (event.content.isBlank()) return null
return try {
val decrypted = signer.nip44Decrypt(event.content, event.pubkey)
lenientJson.decodeFromString(AppSettingsPayload.serializer(), decrypted)
} catch (e: Exception) {
android.util.Log.w("AppSettingsSync", "decryptAppSettings exception: ${e.javaClass.simpleName}: ${e.message}", e)
null
}
}

/** Filter to fetch this user's app-settings backup (single addressable event). */
fun appSettingsFilter(pubkeyHex: String): Filter = Filter(
kinds = listOf(KIND),
authors = listOf(pubkeyHex),
dTags = listOf(APP_SETTINGS_D_TAG),
limit = 1
)
}
Loading