From da6be1ad7407fae5dc7dcf381c28fca48504d5d5 Mon Sep 17 00:00:00 2001 From: Vishal Singh Date: Mon, 20 Apr 2026 10:50:08 +0530 Subject: [PATCH 01/13] Add persistent wallet ordering support --- rust/src/backup/model.rs | 1 + rust/src/database/wallet.rs | 198 +++++++++++++++++++++++++++++++++++- rust/src/wallet/metadata.rs | 6 ++ 3 files changed, 202 insertions(+), 3 deletions(-) diff --git a/rust/src/backup/model.rs b/rust/src/backup/model.rs index d6aa88318..df0be7f2d 100644 --- a/rust/src/backup/model.rs +++ b/rust/src/backup/model.rs @@ -300,6 +300,7 @@ mod tests { color: WalletColor::Blue, verified: true, network: Network::Bitcoin, + position: 0, master_fingerprint: None, selected_unit: Default::default(), sensitive_visible: true, diff --git a/rust/src/database/wallet.rs b/rust/src/database/wallet.rs index 84e925a1b..966efe8b6 100644 --- a/rust/src/database/wallet.rs +++ b/rust/src/database/wallet.rs @@ -1,4 +1,9 @@ -use std::{fmt::Display, sync::Arc, time::Duration}; +use std::{ + collections::{HashMap, HashSet}, + fmt::Display, + sync::Arc, + time::Duration, +}; use redb::{ReadOnlyTable, ReadableTableMetadata, TableDefinition}; use tracing::debug; @@ -35,6 +40,9 @@ pub enum WalletTableError { #[error("wallet already exists")] WalletAlreadyExists, + + #[error("invalid wallet reorder: {0}")] + InvalidWalletReorder(String), } #[derive(Debug, Clone, Copy, uniffi::Object)] @@ -124,6 +132,34 @@ impl WalletsTable { Ok(wallets) } + + /// Persist a new wallet order for the active wallet list. + pub fn reorder_wallets(&self, ordered_ids: Vec) -> Result<(), Error> { + let network = Database::global().global_config.selected_network(); + let mode = Database::global().global_config.wallet_mode(); + + let wallets = self.get_all(network, mode)?; + validate_reorder_order(&ordered_ids, &wallets)?; + + let mut by_id: HashMap = wallets + .into_iter() + .map(|w| (w.id.clone(), w)) + .collect(); + + let mut reordered = Vec::with_capacity(ordered_ids.len()); + for (i, id) in ordered_ids.iter().enumerate() { + let mut wallet = by_id.remove(id).ok_or_else(|| { + WalletTableError::InvalidWalletReorder("missing wallet after validation".into()) + })?; + wallet.position = i as u32; + reordered.push(wallet); + } + + self.save_all_wallets(network, mode, reordered)?; + Updater::send_update(Update::WalletsChanged); + + Ok(()) + } } impl WalletsTable { @@ -142,6 +178,8 @@ impl WalletsTable { } let wallet_for_backup = should_backup_to_cloud.then(|| wallet.clone()); + let mut wallet = wallet; + wallet.position = next_append_position(&wallets); wallets.push(wallet); self.save_all_wallets(network, mode, wallets)?; @@ -192,11 +230,24 @@ impl WalletsTable { Ok(wallet) } - /// Get all wallets for a network + /// Get all wallets for a network (sorted by [`WalletMetadata::position`], then id). pub fn get_all( &self, network: Network, mode: WalletMode, + ) -> Result, Error> { + let mut wallets = self.load_wallets_raw(network, mode)?; + if migrate_legacy_positions_if_needed(&mut wallets) { + self.save_all_wallets(network, mode, wallets.clone())?; + } + sort_wallets_by_position(&mut wallets); + Ok(wallets) + } + + fn load_wallets_raw( + &self, + network: Network, + mode: WalletMode, ) -> Result, Error> { let table = self.read_table()?; let key = WalletKey::from((network, mode)).to_string(); @@ -205,7 +256,7 @@ impl WalletsTable { .get(key.as_str()) .map_err_str(WalletTableError::ReadError)? .map(|value| value.value()) - .unwrap_or(vec![]); + .unwrap_or_default(); Ok(value) } @@ -335,4 +386,145 @@ impl WalletsTable { } } +fn next_append_position(wallets: &[WalletMetadata]) -> u32 { + wallets + .iter() + .map(|w| w.position) + .max() + .map(|m| m.saturating_add(1)) + .unwrap_or(0) +} + + +fn migrate_legacy_positions_if_needed(wallets: &mut Vec) -> bool { + if wallets.len() > 1 && wallets.iter().all(|w| w.position == 0) { + for (i, w) in wallets.iter_mut().enumerate() { + w.position = i as u32; + } + true + } else { + false + } +} + +fn sort_wallets_by_position(wallets: &mut [WalletMetadata]) { + wallets.sort_by(|a, b| { + a.position + .cmp(&b.position) + .then_with(|| a.id.cmp(&b.id)) + }); +} + +fn validate_reorder_order( + ordered_ids: &[WalletId], + wallets: &[WalletMetadata], +) -> Result<(), WalletTableError> { + let expected: HashSet = wallets.iter().map(|w| w.id.clone()).collect(); + if ordered_ids.len() != expected.len() { + return Err(WalletTableError::InvalidWalletReorder( + "order must list every wallet exactly once".into(), + )); + } + let mut seen = HashSet::new(); + for id in ordered_ids { + if !expected.contains(id) { + return Err(WalletTableError::InvalidWalletReorder(format!( + "unknown wallet id: {id}" + ))); + } + if !seen.insert(id.clone()) { + return Err(WalletTableError::InvalidWalletReorder(format!( + "duplicate wallet id: {id}" + ))); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::network::Network; + use crate::wallet::metadata::{WalletMode, WalletType}; + + fn wallet_with_id(id: &str, position: u32) -> WalletMetadata { + let mut m = WalletMetadata::preview_new(); + m.id = id.into(); + m.position = position; + m.network = Network::Bitcoin; + m.wallet_mode = WalletMode::Main; + m.wallet_type = WalletType::Hot; + m + } + + #[test] + fn migrate_legacy_assigns_positions_from_vec_order() { + let mut wallets = vec![wallet_with_id("a", 0), wallet_with_id("b", 0), wallet_with_id("c", 0)]; + assert!(migrate_legacy_positions_if_needed(&mut wallets)); + assert_eq!(wallets[0].position, 0); + assert_eq!(wallets[1].position, 1); + assert_eq!(wallets[2].position, 2); + } + + #[test] + fn migrate_legacy_skips_single_wallet() { + let mut wallets = vec![wallet_with_id("only", 0)]; + assert!(!migrate_legacy_positions_if_needed(&mut wallets)); + } + + #[test] + fn sort_wallets_by_position_then_id() { + let mut wallets = vec![ + wallet_with_id("z", 1), + wallet_with_id("a", 1), + wallet_with_id("m", 0), + ]; + sort_wallets_by_position(&mut wallets); + assert_eq!(AsRef::::as_ref(&wallets[0].id), "m"); + assert_eq!(AsRef::::as_ref(&wallets[1].id), "a"); + assert_eq!(AsRef::::as_ref(&wallets[2].id), "z"); + } + + #[test] + fn validate_reorder_accepts_permutation() { + let w = vec![ + wallet_with_id("a", 0), + wallet_with_id("b", 1), + wallet_with_id("c", 2), + ]; + let order = vec!["c".into(), "a".into(), "b".into()]; + assert!(validate_reorder_order(&order, &w).is_ok()); + } + + #[test] + fn validate_reorder_rejects_duplicate() { + let w = vec![wallet_with_id("a", 0), wallet_with_id("b", 1)]; + let order = vec!["a".into(), "a".into()]; + assert!(matches!( + validate_reorder_order(&order, &w), + Err(WalletTableError::InvalidWalletReorder(_)) + )); + } + + #[test] + fn validate_reorder_rejects_unknown_id() { + let w = vec![wallet_with_id("a", 0)]; + let order = vec!["nope".into()]; + assert!(validate_reorder_order(&order, &w).is_err()); + } + + #[test] + fn validate_reorder_rejects_partial_list() { + let w = vec![wallet_with_id("a", 0), wallet_with_id("b", 1)]; + let order = vec!["a".into()]; + assert!(validate_reorder_order(&order, &w).is_err()); + } + + #[test] + fn next_append_after_gap() { + let wallets = vec![wallet_with_id("a", 0), wallet_with_id("b", 10)]; + assert_eq!(next_append_position(&wallets), 11); + } +} + // redb::Key for WalletId is now implemented in the cove-types crate diff --git a/rust/src/wallet/metadata.rs b/rust/src/wallet/metadata.rs index 76d3aeb8a..bee5150e4 100644 --- a/rust/src/wallet/metadata.rs +++ b/rust/src/wallet/metadata.rs @@ -23,6 +23,10 @@ pub struct WalletMetadata { pub verified: bool, pub network: Network, + /// Wallet order in the sidebar. Lower values appear first. + #[serde(default)] + pub position: u32, + #[serde(default)] pub master_fingerprint: Option>, #[serde(default)] @@ -195,6 +199,7 @@ impl WalletMetadata { origin: None, verified: false, network, + position: 0, fiat_or_btc: FiatOrBtc::Btc, selected_unit: Unit::default(), sensitive_visible: true, @@ -244,6 +249,7 @@ impl WalletMetadata { color: WalletColor::random(), verified: false, network: Network::Bitcoin, + position: 0, fiat_or_btc: FiatOrBtc::Btc, address_type: WalletAddressType::default(), selected_unit: Unit::default(), From 7004e9fa633912b15d4d5ee986f315488a1adbc7 Mon Sep 17 00:00:00 2001 From: Vishal Singh Date: Mon, 20 Apr 2026 19:47:15 +0530 Subject: [PATCH 02/13] Add wallet reordering to mobile sidebars --- .../bitcoinppl/cove/sidebar/SidebarView.kt | 129 +++++++++++++++++- .../java/org/bitcoinppl/cove_core/cove.kt | 58 ++++++++ ios/Cove/Views/SidebarView.swift | 65 ++++++++- .../Sources/CoveCore/generated/cove.swift | 41 +++++- 4 files changed, 288 insertions(+), 5 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt index e05812275..62f0d5f12 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt @@ -3,6 +3,7 @@ package org.bitcoinppl.cove.sidebar import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -18,7 +19,8 @@ import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -30,10 +32,20 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -47,12 +59,27 @@ import org.bitcoinppl.cove_core.RouteFactory import org.bitcoinppl.cove_core.SettingsRoute import org.bitcoinppl.cove_core.WalletColor import org.bitcoinppl.cove_core.WalletMetadata +import android.util.Log @Composable fun SidebarView( app: AppManager, modifier: Modifier = Modifier, ) { + var walletList by remember { mutableStateOf(app.wallets) } + var draggedWalletId by remember { mutableStateOf(null) } + var draggedDistance by remember { mutableFloatStateOf(0f) } + var dragStartCenterY by remember { mutableFloatStateOf(0f) } + val listState = rememberLazyListState() + val haptic = LocalHapticFeedback.current + + LaunchedEffect(app.wallets, draggedWalletId) { + // Keep local list in sync with source-of-truth while not actively dragging. + if (draggedWalletId == null) { + walletList = app.wallets + } + } + Column( modifier = modifier @@ -116,12 +143,95 @@ fun SidebarView( // wallet list LazyColumn( modifier = Modifier.weight(1f), + state = listState, verticalArrangement = Arrangement.spacedBy(12.dp), ) { - items(app.wallets) { wallet -> + itemsIndexed( + items = walletList, + key = { _, wallet -> wallet.id.toString() }, + ) { _, wallet -> + val isDragged = wallet.id == draggedWalletId WalletItem( wallet = wallet, + modifier = + Modifier + .graphicsLayer { + translationY = if (isDragged) draggedDistance else 0f + }.pointerInput(wallet.id, walletList) { + detectDragGesturesAfterLongPress( + onDragStart = { + draggedWalletId = wallet.id + draggedDistance = 0f + val itemInfo = + listState.layoutInfo.visibleItemsInfo.firstOrNull { + it.key == wallet.id.toString() + } + dragStartCenterY = + if (itemInfo != null) { + itemInfo.offset + (itemInfo.size / 2f) + } else { + 0f + } + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + }, + onDrag = { change, dragAmount -> + change.consume() + val draggedId = draggedWalletId ?: return@detectDragGesturesAfterLongPress + draggedDistance += dragAmount.y + val fromIndex = walletList.indexOfFirst { it.id == draggedId } + if (fromIndex == -1) return@detectDragGesturesAfterLongPress + + val currentCenterY = dragStartCenterY + draggedDistance + val targetInfo = + listState.layoutInfo.visibleItemsInfo.firstOrNull { info -> + currentCenterY >= info.offset && + currentCenterY <= info.offset + info.size + } + ?: return@detectDragGesturesAfterLongPress + + val toIndex = targetInfo.index + if (toIndex == fromIndex) return@detectDragGesturesAfterLongPress + if (toIndex !in walletList.indices) return@detectDragGesturesAfterLongPress + + walletList = walletList.move(fromIndex, toIndex) + val refreshedInfo = + listState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == toIndex } + if (refreshedInfo != null) { + dragStartCenterY = refreshedInfo.offset + (refreshedInfo.size / 2f) + draggedDistance = currentCenterY - dragStartCenterY + } else { + draggedDistance = 0f + } + }, + onDragEnd = { + val draggedId = draggedWalletId + if (draggedId != null) { + val appOrder = app.wallets.map { it.id } + val localOrder = walletList.map { it.id } + if (localOrder != appOrder) { + runCatching { + app.database.wallets().reorderWallets(orderedIds = localOrder) + }.onFailure { + Log.e("SidebarView", "Failed to reorder wallets", it) + walletList = app.wallets + } + } + } + draggedWalletId = null + draggedDistance = 0f + dragStartCenterY = 0f + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + }, + onDragCancel = { + draggedWalletId = null + draggedDistance = 0f + dragStartCenterY = 0f + walletList = app.wallets + }, + ) + }, onClick = { + if (draggedWalletId != null) return@WalletItem app.closeSidebarAndNavigate { app.rust.selectWallet(wallet.id) } @@ -202,11 +312,12 @@ fun SidebarView( @Composable private fun WalletItem( wallet: WalletMetadata, + modifier: Modifier = Modifier, onClick: () -> Unit, ) { Row( modifier = - Modifier + modifier .fillMaxWidth() .clip(RoundedCornerShape(10.dp)) .background(CoveColor.coveLightGray.copy(alpha = 0.06f)) @@ -235,6 +346,18 @@ private fun WalletItem( } } +private fun List.move( + fromIndex: Int, + toIndex: Int, +): List { + if (fromIndex == toIndex) return this + val mutable = toMutableList() + val item = mutable.removeAt(fromIndex) + val safeTarget = toIndex.coerceIn(0, mutable.size) + mutable.add(safeTarget, item) + return mutable.toList() +} + // convert wallet color to compose color private fun WalletColor.toComposeColor(): Color = when (this) { diff --git a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt index ae443e770..3c5d3530d 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt @@ -1338,6 +1338,8 @@ internal object IntegrityCheckingUniffiLib { ): Short external fun uniffi_cove_checksum_method_walletstable_len( ): Short + external fun uniffi_cove_checksum_method_walletstable_reorder_wallets( + ): Short external fun uniffi_cove_checksum_method_priceresponse_get( ): Short external fun uniffi_cove_checksum_method_priceresponse_get_for_currency( @@ -2315,6 +2317,8 @@ internal object UniffiLib { ): Byte external fun uniffi_cove_fn_method_walletstable_len(`ptr`: Long,`network`: RustBufferNetwork.ByValue,`mode`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Short + external fun uniffi_cove_fn_method_walletstable_reorder_wallets(`ptr`: Long,`orderedIds`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): Unit external fun uniffi_cove_fn_clone_walletdatadb(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Long external fun uniffi_cove_fn_free_walletdatadb(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -3956,6 +3960,9 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_method_walletstable_len() != 51436.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_cove_checksum_method_walletstable_reorder_wallets() != 12851.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_cove_checksum_method_priceresponse_get() != 6552.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -26330,6 +26337,11 @@ public interface WalletsTableInterface { fun `len`(`network`: Network, `mode`: WalletMode): kotlin.UShort + /** + * Persist a new wallet order for the active wallet list. + */ + fun `reorderWallets`(`orderedIds`: List) + companion object } @@ -26508,6 +26520,22 @@ open class WalletsTable: Disposable, AutoCloseable, WalletsTableInterface + /** + * Persist a new wallet order for the active wallet list. + */ + @Throws(DatabaseException::class)override fun `reorderWallets`(`orderedIds`: List) + = + callWithHandle { + uniffiRustCallWithError(DatabaseException) { _status -> + UniffiLib.uniffi_cove_fn_method_walletstable_reorder_wallets( + it, + FfiConverterSequenceTypeWalletId.lower(`orderedIds`),_status) +} + } + + + + @@ -29566,6 +29594,11 @@ data class WalletMetadata ( , var `network`: Network , + /** + * Wallet order in the sidebar. Lower values appear first. + */ + var `position`: kotlin.UInt + , var `masterFingerprint`: Fingerprint? , var `selectedUnit`: BitcoinUnit @@ -29653,6 +29686,7 @@ data class WalletMetadata ( this.`color`, this.`verified`, this.`network`, + this.`position`, this.`masterFingerprint`, this.`selectedUnit`, this.`sensitiveVisible`, @@ -29683,6 +29717,7 @@ public object FfiConverterTypeWalletMetadata: FfiConverterRustBuffer WalletTableException.WalletAlreadyExists() + 4 -> WalletTableException.InvalidWalletReorder( + FfiConverterString.read(buf), + ) else -> throw RuntimeException("invalid error enum value, something is very wrong!!") } } @@ -51583,6 +51631,11 @@ public object FfiConverterTypeWalletTableError : FfiConverterRustBuffer ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) } } @@ -51602,6 +51655,11 @@ public object FfiConverterTypeWalletTableError : FfiConverterRustBuffer { + buf.putInt(4) + FfiConverterString.write(value.v1, buf) + Unit + } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } } diff --git a/ios/Cove/Views/SidebarView.swift b/ios/Cove/Views/SidebarView.swift index d1eefc1c7..9fba2d97a 100644 --- a/ios/Cove/Views/SidebarView.swift +++ b/ios/Cove/Views/SidebarView.swift @@ -6,10 +6,13 @@ // import SwiftUI +import UniformTypeIdentifiers struct SidebarView: View { @Environment(AppManager.self) private var app @Environment(\.navigate) private var navigate + @State private var walletList: [WalletMetadata] = [] + @State private var draggedWalletId: WalletId? let currentRoute: Route @@ -67,8 +70,9 @@ struct SidebarView: View { .padding(.bottom, 16) VStack(spacing: 12) { - ForEach(app.wallets, id: \.id) { wallet in + ForEach(walletList, id: \.id) { wallet in Button(action: { + guard draggedWalletId == nil else { return } goTo(Route.selectedWallet(wallet.id)) }) { HStack(spacing: 10) { @@ -99,6 +103,20 @@ struct SidebarView: View { app.pushRoutes(RouteFactory().nestedWalletSettings(id: wallet.id)) } } + .onDrag { + draggedWalletId = wallet.id + UIImpactFeedbackGenerator(style: .light).impactOccurred() + return NSItemProvider(object: "\(wallet.id)" as NSString) + } + .onDrop( + of: [UTType.plainText], + delegate: SidebarWalletDropDelegate( + item: wallet, + wallets: $walletList, + draggedWalletId: $draggedWalletId, + onReorderCommitted: persistWalletOrder + ) + ) } } @@ -136,6 +154,12 @@ struct SidebarView: View { .padding(20) .frame(maxWidth: .infinity) .background(.midnightBlue) + .onAppear { + walletList = app.wallets + } + .onChange(of: app.wallets) { _, updated in + walletList = updated + } } func goTo(_ route: Route) { @@ -170,4 +194,43 @@ struct SidebarView: View { navigateRouteOnMain(route) } + + private func persistWalletOrder() { + do { + try app.database.wallets().reorderWallets(orderedIds: walletList.map(\.id)) + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + } catch { + Log.error("Failed to reorder wallets \(error)") + walletList = app.wallets + } + } +} + +private struct SidebarWalletDropDelegate: DropDelegate { + let item: WalletMetadata + @Binding var wallets: [WalletMetadata] + @Binding var draggedWalletId: WalletId? + let onReorderCommitted: () -> Void + + func dropEntered(info _: DropInfo) { + guard let draggedWalletId, + draggedWalletId != item.id, + let from = wallets.firstIndex(where: { $0.id == draggedWalletId }), + let to = wallets.firstIndex(where: { $0.id == item.id }) + else { return } + + withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) { + wallets.move( + fromOffsets: IndexSet(integer: from), + toOffset: to > from ? to + 1 : to + ) + } + } + + func performDrop(info _: DropInfo) -> Bool { + guard draggedWalletId != nil else { return false } + draggedWalletId = nil + onReorderCommitted() + return true + } } diff --git a/ios/CoveCore/Sources/CoveCore/generated/cove.swift b/ios/CoveCore/Sources/CoveCore/generated/cove.swift index 91f773e20..92f94c246 100644 --- a/ios/CoveCore/Sources/CoveCore/generated/cove.swift +++ b/ios/CoveCore/Sources/CoveCore/generated/cove.swift @@ -11941,6 +11941,11 @@ public protocol WalletsTableProtocol: AnyObject, Sendable { func len(network: Network, mode: WalletMode) throws -> UInt16 + /** + * Persist a new wallet order for the active wallet list. + */ + func reorderWallets(orderedIds: [WalletId]) throws + } open class WalletsTable: WalletsTableProtocol, @unchecked Sendable { fileprivate let handle: UInt64 @@ -12040,6 +12045,17 @@ open func len(network: Network, mode: WalletMode)throws -> UInt16 { }) } + /** + * Persist a new wallet order for the active wallet list. + */ +open func reorderWallets(orderedIds: [WalletId])throws {try rustCallWithError(FfiConverterTypeDatabaseError_lift) { + uniffi_cove_fn_method_walletstable_reorder_wallets( + self.uniffiCloneHandle(), + FfiConverterSequenceTypeWalletId.lower(orderedIds),$0 + ) +} +} + } @@ -15317,6 +15333,10 @@ public struct WalletMetadata: Equatable, Hashable { public var color: WalletColor public var verified: Bool public var network: Network + /** + * Wallet order in the sidebar. Lower values appear first. + */ + public var position: UInt32 public var masterFingerprint: Fingerprint? public var selectedUnit: BitcoinUnit public var sensitiveVisible: Bool @@ -15340,7 +15360,10 @@ public struct WalletMetadata: Equatable, Hashable { // Default memberwise initializers are never public by default, so we // declare one manually. - public init(id: WalletId, name: String, color: WalletColor, verified: Bool, network: Network, masterFingerprint: Fingerprint?, selectedUnit: BitcoinUnit, sensitiveVisible: Bool, detailsExpanded: Bool, walletType: WalletType, walletMode: WalletMode, discoveryState: DiscoveryState, addressType: WalletAddressType, fiatOrBtc: FiatOrBtc, origin: String?, + public init(id: WalletId, name: String, color: WalletColor, verified: Bool, network: Network, + /** + * Wallet order in the sidebar. Lower values appear first. + */position: UInt32, masterFingerprint: Fingerprint?, selectedUnit: BitcoinUnit, sensitiveVisible: Bool, detailsExpanded: Bool, walletType: WalletType, walletMode: WalletMode, discoveryState: DiscoveryState, addressType: WalletAddressType, fiatOrBtc: FiatOrBtc, origin: String?, /** * Metadata data specific to different hardware wallets */hardwareMetadata: HardwareWalletMetadata?, @@ -15353,6 +15376,7 @@ public struct WalletMetadata: Equatable, Hashable { self.color = color self.verified = verified self.network = network + self.position = position self.masterFingerprint = masterFingerprint self.selectedUnit = selectedUnit self.sensitiveVisible = sensitiveVisible @@ -15428,6 +15452,7 @@ public struct FfiConverterTypeWalletMetadata: FfiConverterRustBuffer { color: FfiConverterTypeWalletColor.read(from: &buf), verified: FfiConverterBool.read(from: &buf), network: FfiConverterTypeNetwork.read(from: &buf), + position: FfiConverterUInt32.read(from: &buf), masterFingerprint: FfiConverterOptionTypeFingerprint.read(from: &buf), selectedUnit: FfiConverterTypeBitcoinUnit.read(from: &buf), sensitiveVisible: FfiConverterBool.read(from: &buf), @@ -15450,6 +15475,7 @@ public struct FfiConverterTypeWalletMetadata: FfiConverterRustBuffer { FfiConverterTypeWalletColor.write(value.color, into: &buf) FfiConverterBool.write(value.verified, into: &buf) FfiConverterTypeNetwork.write(value.network, into: &buf) + FfiConverterUInt32.write(value.position, into: &buf) FfiConverterOptionTypeFingerprint.write(value.masterFingerprint, into: &buf) FfiConverterTypeBitcoinUnit.write(value.selectedUnit, into: &buf) FfiConverterBool.write(value.sensitiveVisible, into: &buf) @@ -32018,6 +32044,8 @@ enum WalletTableError: Swift.Error, Equatable, Hashable, Foundation.LocalizedErr case ReadError(String ) case WalletAlreadyExists + case InvalidWalletReorder(String + ) @@ -32064,6 +32092,9 @@ public struct FfiConverterTypeWalletTableError: FfiConverterRustBuffer { try FfiConverterString.read(from: &buf) ) case 3: return .WalletAlreadyExists + case 4: return .InvalidWalletReorder( + try FfiConverterString.read(from: &buf) + ) default: throw UniffiInternalError.unexpectedEnumCase } @@ -32089,6 +32120,11 @@ public struct FfiConverterTypeWalletTableError: FfiConverterRustBuffer { case .WalletAlreadyExists: writeInt(&buf, Int32(3)) + + case let .InvalidWalletReorder(v1): + writeInt(&buf, Int32(4)) + FfiConverterString.write(v1, into: &buf) + } } } @@ -36567,6 +36603,9 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_walletstable_len() != 51436) { return InitializationResult.apiChecksumMismatch } + if (uniffi_cove_checksum_method_walletstable_reorder_wallets() != 12851) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_cove_checksum_method_priceresponse_get() != 6552) { return InitializationResult.apiChecksumMismatch } From c85fb7f4559a0dcd7fb71aceae53e731d5df1a4c Mon Sep 17 00:00:00 2001 From: Vishal Singh <141127867+geeekyvishal@users.noreply.github.com> Date: Mon, 20 Apr 2026 20:19:02 +0530 Subject: [PATCH 03/13] Update ios/Cove/Views/SidebarView.swift fix : onChange can reset an in-progress drag Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- ios/Cove/Views/SidebarView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ios/Cove/Views/SidebarView.swift b/ios/Cove/Views/SidebarView.swift index 9fba2d97a..79f9c6eca 100644 --- a/ios/Cove/Views/SidebarView.swift +++ b/ios/Cove/Views/SidebarView.swift @@ -158,7 +158,9 @@ struct SidebarView: View { walletList = app.wallets } .onChange(of: app.wallets) { _, updated in - walletList = updated + if draggedWalletId == nil { + walletList = updated + } } } From 311fd505b032a120d2057c893e461d1ef0cd660d Mon Sep 17 00:00:00 2001 From: Vishal Singh <141127867+geeekyvishal@users.noreply.github.com> Date: Mon, 20 Apr 2026 20:20:06 +0530 Subject: [PATCH 04/13] Update android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt fix : Misplaced import Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt index 62f0d5f12..1492f08c2 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt @@ -60,6 +60,7 @@ import org.bitcoinppl.cove_core.SettingsRoute import org.bitcoinppl.cove_core.WalletColor import org.bitcoinppl.cove_core.WalletMetadata import android.util.Log +import androidx.compose.foundation.Image @Composable fun SidebarView( From b1617043cc3df2fbb9a1867e486fb7b3ea9ee3a8 Mon Sep 17 00:00:00 2001 From: Vishal Singh Date: Mon, 20 Apr 2026 21:37:07 +0530 Subject: [PATCH 05/13] fix(android): move wallet reordering database call to background thread --- .../bitcoinppl/cove/sidebar/SidebarView.kt | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt index 1492f08c2..62cec2b06 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt @@ -36,6 +36,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -50,6 +51,9 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.bitcoinppl.cove.AppManager import org.bitcoinppl.cove.R import org.bitcoinppl.cove.ui.theme.CoveColor @@ -73,6 +77,7 @@ fun SidebarView( var dragStartCenterY by remember { mutableFloatStateOf(0f) } val listState = rememberLazyListState() val haptic = LocalHapticFeedback.current + val scope = rememberCoroutineScope() LaunchedEffect(app.wallets, draggedWalletId) { // Keep local list in sync with source-of-truth while not actively dragging. @@ -210,11 +215,15 @@ fun SidebarView( val appOrder = app.wallets.map { it.id } val localOrder = walletList.map { it.id } if (localOrder != appOrder) { - runCatching { - app.database.wallets().reorderWallets(orderedIds = localOrder) - }.onFailure { - Log.e("SidebarView", "Failed to reorder wallets", it) - walletList = app.wallets + scope.launch(Dispatchers.IO) { + runCatching { + app.database.wallets().reorderWallets(orderedIds = localOrder) + }.onFailure { + Log.e("SidebarView", "Failed to reorder wallets", it) + withContext(Dispatchers.Main) { + walletList = app.wallets + } + } } } } From 2a8fad7a3ae7d3cc2de0cadb9edc669fea2dd52c Mon Sep 17 00:00:00 2001 From: Vishal Singh Date: Mon, 20 Apr 2026 21:46:19 +0530 Subject: [PATCH 06/13] refactor(rust): make wallet parameter mutable and remove redundant binding --- rust/src/database/wallet.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/rust/src/database/wallet.rs b/rust/src/database/wallet.rs index 966efe8b6..385761160 100644 --- a/rust/src/database/wallet.rs +++ b/rust/src/database/wallet.rs @@ -165,7 +165,7 @@ impl WalletsTable { impl WalletsTable { fn save_new_wallet_metadata_with_backup_behavior( &self, - wallet: WalletMetadata, + mut wallet: WalletMetadata, should_backup_to_cloud: bool, ) -> Result<(), Error> { let network = wallet.network; @@ -178,7 +178,6 @@ impl WalletsTable { } let wallet_for_backup = should_backup_to_cloud.then(|| wallet.clone()); - let mut wallet = wallet; wallet.position = next_append_position(&wallets); wallets.push(wallet); self.save_all_wallets(network, mode, wallets)?; From 0fe7c3aa2ac1fe8dfa332b6b1a18bab526970b6d Mon Sep 17 00:00:00 2001 From: Vishal Singh Date: Mon, 20 Apr 2026 21:50:49 +0530 Subject: [PATCH 07/13] fix(android): change pointerInput key to wallet.id to prevent dropped drags --- .../src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt index 62cec2b06..fe1c6ba17 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt @@ -163,7 +163,7 @@ fun SidebarView( Modifier .graphicsLayer { translationY = if (isDragged) draggedDistance else 0f - }.pointerInput(wallet.id, walletList) { + }.pointerInput(wallet.id) { detectDragGesturesAfterLongPress( onDragStart = { draggedWalletId = wallet.id From febc5a1b72c1e9ac14e2faf409ea5bed25d93a1f Mon Sep 17 00:00:00 2001 From: Vishal Singh Date: Mon, 20 Apr 2026 22:04:16 +0530 Subject: [PATCH 08/13] fix(android): stabilize gesture listener by removing walletList from pointerInput keys --- rust/src/database/wallet.rs | 71 +++++++++++++++++++++++++++++-------- 1 file changed, 56 insertions(+), 15 deletions(-) diff --git a/rust/src/database/wallet.rs b/rust/src/database/wallet.rs index 385761160..0d8e6f3bd 100644 --- a/rust/src/database/wallet.rs +++ b/rust/src/database/wallet.rs @@ -134,26 +134,21 @@ impl WalletsTable { } /// Persist a new wallet order for the active wallet list. + /// + /// Validation rules: + /// - `ordered_ids` must be a full permutation of existing wallet IDs in the active bucket. + /// - Partial lists are rejected. + /// - Unknown IDs are rejected. + /// - Duplicate IDs are rejected. + /// + /// The write is atomic-like at the application level: validation and reorder construction + /// happen before `save_all_wallets` is called, so invalid inputs do not mutate persisted state. pub fn reorder_wallets(&self, ordered_ids: Vec) -> Result<(), Error> { let network = Database::global().global_config.selected_network(); let mode = Database::global().global_config.wallet_mode(); let wallets = self.get_all(network, mode)?; - validate_reorder_order(&ordered_ids, &wallets)?; - - let mut by_id: HashMap = wallets - .into_iter() - .map(|w| (w.id.clone(), w)) - .collect(); - - let mut reordered = Vec::with_capacity(ordered_ids.len()); - for (i, id) in ordered_ids.iter().enumerate() { - let mut wallet = by_id.remove(id).ok_or_else(|| { - WalletTableError::InvalidWalletReorder("missing wallet after validation".into()) - })?; - wallet.position = i as u32; - reordered.push(wallet); - } + let reordered = build_reordered_wallets(&ordered_ids, wallets)?; self.save_all_wallets(network, mode, reordered)?; Updater::send_update(Update::WalletsChanged); @@ -395,6 +390,11 @@ fn next_append_position(wallets: &[WalletMetadata]) -> u32 { } +/// Zero-inference migration for wallets saved before `WalletMetadata.position` existed. +/// +/// Older rows deserialize with `position == 0`. To avoid random or hash-based reordering, +/// we preserve the currently stored vector order exactly by assigning sequential positions +/// from each wallet's existing index (`0..n-1`). fn migrate_legacy_positions_if_needed(wallets: &mut Vec) -> bool { if wallets.len() > 1 && wallets.iter().all(|w| w.position == 0) { for (i, w) in wallets.iter_mut().enumerate() { @@ -440,6 +440,27 @@ fn validate_reorder_order( Ok(()) } +fn build_reordered_wallets( + ordered_ids: &[WalletId], + wallets: Vec, +) -> Result, WalletTableError> { + validate_reorder_order(ordered_ids, &wallets)?; + + let mut by_id: HashMap = + wallets.into_iter().map(|w| (w.id.clone(), w)).collect(); + + let mut reordered = Vec::with_capacity(ordered_ids.len()); + for (i, id) in ordered_ids.iter().enumerate() { + let mut wallet = by_id.remove(id).ok_or_else(|| { + WalletTableError::InvalidWalletReorder("missing wallet after validation".into()) + })?; + wallet.position = i as u32; + reordered.push(wallet); + } + + Ok(reordered) +} + #[cfg(test)] mod tests { use super::*; @@ -519,6 +540,26 @@ mod tests { assert!(validate_reorder_order(&order, &w).is_err()); } + #[test] + fn build_reordered_wallets_invalid_input_does_not_mutate_original_vector() { + let original = vec![wallet_with_id("a", 0), wallet_with_id("b", 1)]; + let working = original.clone(); + let order = vec!["a".into(), "unknown".into()]; + + assert!(build_reordered_wallets(&order, working.clone()).is_err()); + assert_eq!(working, original); + } + + #[test] + fn next_append_is_sequential_for_existing_sequence() { + let wallets = vec![ + wallet_with_id("a", 0), + wallet_with_id("b", 1), + wallet_with_id("c", 2), + ]; + assert_eq!(next_append_position(&wallets), 3); + } + #[test] fn next_append_after_gap() { let wallets = vec![wallet_with_id("a", 0), wallet_with_id("b", 10)]; From 00b06e017ca717b014c4e3020ccab4689d16f63a Mon Sep 17 00:00:00 2001 From: Vishal Singh Date: Mon, 20 Apr 2026 22:08:59 +0530 Subject: [PATCH 09/13] fix(android): eliminate UI flicker by syncing dragged state after DB write --- .../java/org/bitcoinppl/cove/sidebar/SidebarView.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt index fe1c6ba17..7785b313f 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt @@ -218,13 +218,25 @@ fun SidebarView( scope.launch(Dispatchers.IO) { runCatching { app.database.wallets().reorderWallets(orderedIds = localOrder) + }.onSuccess { + withContext(Dispatchers.Main) { + draggedWalletId = null + draggedDistance = 0f + dragStartCenterY = 0f + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } }.onFailure { Log.e("SidebarView", "Failed to reorder wallets", it) withContext(Dispatchers.Main) { walletList = app.wallets + draggedWalletId = null + draggedDistance = 0f + dragStartCenterY = 0f + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) } } } + return@detectDragGesturesAfterLongPress } } draggedWalletId = null From 05e0c07650ad3f4d95ba5c7805f26f613e0582a8 Mon Sep 17 00:00:00 2001 From: Vishal Singh Date: Mon, 20 Apr 2026 22:13:30 +0530 Subject: [PATCH 10/13] chore(rust): fix clippy ptr_arg warning by using mutable slices --- rust/src/database/wallet.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/src/database/wallet.rs b/rust/src/database/wallet.rs index 0d8e6f3bd..4b6fd0df4 100644 --- a/rust/src/database/wallet.rs +++ b/rust/src/database/wallet.rs @@ -395,7 +395,7 @@ fn next_append_position(wallets: &[WalletMetadata]) -> u32 { /// Older rows deserialize with `position == 0`. To avoid random or hash-based reordering, /// we preserve the currently stored vector order exactly by assigning sequential positions /// from each wallet's existing index (`0..n-1`). -fn migrate_legacy_positions_if_needed(wallets: &mut Vec) -> bool { +fn migrate_legacy_positions_if_needed(wallets: &mut [WalletMetadata]) -> bool { if wallets.len() > 1 && wallets.iter().all(|w| w.position == 0) { for (i, w) in wallets.iter_mut().enumerate() { w.position = i as u32; From dc49e3eafed3bd9cec6ff050d64e3a12fb1035c6 Mon Sep 17 00:00:00 2001 From: Vishal Singh Date: Mon, 20 Apr 2026 22:17:47 +0530 Subject: [PATCH 11/13] style: fix rust formatting --- .DS_Store | Bin 0 -> 10244 bytes ios/.DS_Store | Bin 0 -> 6148 bytes rust/.DS_Store | Bin 0 -> 10244 bytes rust/src/database/wallet.rs | 40 ++++++++---------------------------- 4 files changed, 9 insertions(+), 31 deletions(-) create mode 100644 .DS_Store create mode 100644 ios/.DS_Store create mode 100644 rust/.DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..1b431e4bab6b98dad02b9122aeedfe63ceb8e254 GIT binary patch literal 10244 zcmeHMO>7%Q6n^7`*d|Hiq)FO}AFb+3N<+zCQ$-wb97iRgkSI1KQBsn%y>T{O@0i_n z(jK)1Re-H@PF|D?%AwF+Fcqv0}liq2s}{p0Lu>wRz?$oR$R(c2R`@_0BI|B^MdUd zYamqwO$b_XDRQt7R~4zNO2iR^xa!!i2)Try6_>i|K*ZsL2+KsAPzbk<<11VoNW!JT zGw?v*fkqFovwJICfCA{?$mjP^>Z2cJUXi9!S%YS=FY?Jjd*#75Ur&1Z`6KzIjr!d{ zXnqY*sFgIz+G?!J##ztgs5x(GTR7WAN4w#q^<8B3_!Jhj{Ok3u z%uWeqdg~p0(94#lnac%B&(E!|+nTc|wU?N+oJ9>|p4z&jQ*&vBTX(eLjJZzHB114& zVl4ZrX4yy^bQYIX%TmpPmfX2!s9DXJGn3SDXmL@;Suo_>WV)p0bh8kD;SblD(RgCO zZN(s7^>3uN+7gt+@2NlYcE+TphD!4;C!Tmoh(zE#zQ$=-fj6K6AHr?817E`T@C*C} z4@oEKCuhldGDt3xOC&)i$pTp>YeXfRu70Sx)K7`=1R*vyd`m3_EP406yLTET?8)9XULIi5lm&~6efW{|Q`nrMgESBz{mjLAd; z8g$vUmioos@$w$t?9}roAEvPfX7K2`n8BDyTE+x^)0YuEo1b^dT`FZ z#lAT`d>L!`Rt>99roJs5CgZ_8`#ze17Q{^bX!+YJqZLvz&U3w2o~i502yg#>V_@8k zM4>19vXLvJI`{NFhAIM^xMkbmz&heHK=XQ&M}>?pj1e@0k-;|CG)#M?c@)UfQ68m} zkb;++M=Os6S)&!VV2;N-GJEsL3D27+*{TJ&#IL8HW_&5g+kV~Wsi&sv+gN+5!3NLr?_U)Yzz*zUQnhfC z+r+d$!Sn?prdLlOvLq6ezypB?j?V)vu4#qc|4$$O|Nrp;3@is82t4qwdw{g2(kXVe zRXzLrEwWRpf^`imD@?B9Qm%pzCc^spbv*uH*YW&91N(*;j}>eu1g*Ffd$9cXe+D#M b?FHxm`mKX}8Vt_=?5;ri@CJA^&i{V{b29+* literal 0 HcmV?d00001 diff --git a/ios/.DS_Store b/ios/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..131796aef837be9d6859af0ee0f4b72cc9c8200f GIT binary patch literal 6148 zcmeHKJxc>Y5S@(y4+D}yM7t|fNQekl7N^EW8zH@@xuA&gLQt`=xW>XiVkKf{V__$# zg|&_T1hG>Te6zEOdtQv%h=iT6`}THccHh4Hm@E;gnVoQ)C{IKl3S)KvRR`m7E-A~H zo*|&pb5y8GE3}1QE#>VxoB~dP|E2)HyE&Srh(f%x_WRr1TpO=cqk2OqpC5khS&z!) z)y-%X9^TSR=IrU}a=M#$_zUmGy3OJN(^6LR-KAQB;Y4Z79!9PJNJUTaS6*n0HA zqSJU{-Ki3ooLpeCR!T_E_87Goxja*abdn__;p>w=KRx`#6IYP_u;ko z!S}tH)Uj}X^4Q78=KFS6dwrIBoSMt#*v-%8Rd_fRUUfW!+sGgx3NWc@?pE{qr6mSYS1=D+%%a!sBwq$Z^YIB@xeUxPsHuj4JbqFex hj&*}m@d%1GjQQLE1_on6^uXL70WE{8oC1HUz&or=$XNgY literal 0 HcmV?d00001 diff --git a/rust/.DS_Store b/rust/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..6c7e438d1d84e0c80259b3169924d5f748b83f82 GIT binary patch literal 10244 zcmeHMO=u)V6n-_y&QB(BC)s3;=)$lgC~Szy>h2;QoJ>ZRppuXYYeKSSduDoO+w^qJ zcK0M3Ltt)#9u!5qiQvVPiXaMl(32qOac|2C>cN8`qNf$(tDnhKch5xjAgoJO=z7&v z->a(kb-$N>H2^>gt?Dd5766iXF_{_0sz+ht?243_?0FO=L4ANaP`f@`s?bI=g4P|H z0nLDBKr^5j&$DrOZp&7Lu#toFaQ5QkBRQg9vpT&oVg5yp(Mrg*p(S4n(d@WQIcc>Zq6Ha3Fah zb*&lD3`7}VxqBR@p#lcD(fpn4hsPVVSS;JLjQzkc}5Cr%HDUwB=Mi zu3HiVsZ=txnVH<%J3l)!l|6gm-2PN{@5!^+e&X@-`}?WnMDDStmN&QThC^TFI|m}e z7>tPf!}_&ec3G8eX&3pfW7n-rMp~9k4W)-i?mM0t9Xl~PJ~}Zmaq|Aj2gV+JXft!7 zY*e=DF5B@uwW(XMTjplwxQBKxRH(D$n%ijas&8KROV-OcG!{}=HmfgoyH<9ah#Bv8 z@JTPars=HLU8}lSt9hosse=JNx~VJgA#1OHE6>-@IHJ5U&HtCBm4;mWQ3d~ zr^y*IMb426Bv0nZGFc~Ah(TTw`mxT#J|*Xqf_T`7yu^V6wC8?d7DOOHS31U=d9fJ8zh(Wv! z6hqEd(80;+BXxY#X@DKlc{8LF$9Y{`$p{TuitB>U=jT&Z35oei+5!;y(?%5plRsm`-D0aWG8R@XzWOSa{%7 z-SQY*=T7WJLNp7veUJ(R2>SI)tdx0F`#*jAFvtWD-9XbDq1fKd@E8NSXa+O`ngPwg zoiH#Uz*%@nv%EQr*A={2VYs%CTm>rz zVg39#9&gxjJU@KFKAsiI@w}96A-Ov0adnj9H2**UG2q~UEg#J91D~%t|5r{2x}W7b S|L5<`(Z{!ewD7LY|NjA#Hl`{7 literal 0 HcmV?d00001 diff --git a/rust/src/database/wallet.rs b/rust/src/database/wallet.rs index 4b6fd0df4..4a0bae162 100644 --- a/rust/src/database/wallet.rs +++ b/rust/src/database/wallet.rs @@ -381,15 +381,9 @@ impl WalletsTable { } fn next_append_position(wallets: &[WalletMetadata]) -> u32 { - wallets - .iter() - .map(|w| w.position) - .max() - .map(|m| m.saturating_add(1)) - .unwrap_or(0) + wallets.iter().map(|w| w.position).max().map(|m| m.saturating_add(1)).unwrap_or(0) } - /// Zero-inference migration for wallets saved before `WalletMetadata.position` existed. /// /// Older rows deserialize with `position == 0`. To avoid random or hash-based reordering, @@ -407,11 +401,7 @@ fn migrate_legacy_positions_if_needed(wallets: &mut [WalletMetadata]) -> bool { } fn sort_wallets_by_position(wallets: &mut [WalletMetadata]) { - wallets.sort_by(|a, b| { - a.position - .cmp(&b.position) - .then_with(|| a.id.cmp(&b.id)) - }); + wallets.sort_by(|a, b| a.position.cmp(&b.position).then_with(|| a.id.cmp(&b.id))); } fn validate_reorder_order( @@ -427,9 +417,7 @@ fn validate_reorder_order( let mut seen = HashSet::new(); for id in ordered_ids { if !expected.contains(id) { - return Err(WalletTableError::InvalidWalletReorder(format!( - "unknown wallet id: {id}" - ))); + return Err(WalletTableError::InvalidWalletReorder(format!("unknown wallet id: {id}"))); } if !seen.insert(id.clone()) { return Err(WalletTableError::InvalidWalletReorder(format!( @@ -479,7 +467,8 @@ mod tests { #[test] fn migrate_legacy_assigns_positions_from_vec_order() { - let mut wallets = vec![wallet_with_id("a", 0), wallet_with_id("b", 0), wallet_with_id("c", 0)]; + let mut wallets = + vec![wallet_with_id("a", 0), wallet_with_id("b", 0), wallet_with_id("c", 0)]; assert!(migrate_legacy_positions_if_needed(&mut wallets)); assert_eq!(wallets[0].position, 0); assert_eq!(wallets[1].position, 1); @@ -494,11 +483,8 @@ mod tests { #[test] fn sort_wallets_by_position_then_id() { - let mut wallets = vec![ - wallet_with_id("z", 1), - wallet_with_id("a", 1), - wallet_with_id("m", 0), - ]; + let mut wallets = + vec![wallet_with_id("z", 1), wallet_with_id("a", 1), wallet_with_id("m", 0)]; sort_wallets_by_position(&mut wallets); assert_eq!(AsRef::::as_ref(&wallets[0].id), "m"); assert_eq!(AsRef::::as_ref(&wallets[1].id), "a"); @@ -507,11 +493,7 @@ mod tests { #[test] fn validate_reorder_accepts_permutation() { - let w = vec![ - wallet_with_id("a", 0), - wallet_with_id("b", 1), - wallet_with_id("c", 2), - ]; + let w = vec![wallet_with_id("a", 0), wallet_with_id("b", 1), wallet_with_id("c", 2)]; let order = vec!["c".into(), "a".into(), "b".into()]; assert!(validate_reorder_order(&order, &w).is_ok()); } @@ -552,11 +534,7 @@ mod tests { #[test] fn next_append_is_sequential_for_existing_sequence() { - let wallets = vec![ - wallet_with_id("a", 0), - wallet_with_id("b", 1), - wallet_with_id("c", 2), - ]; + let wallets = vec![wallet_with_id("a", 0), wallet_with_id("b", 1), wallet_with_id("c", 2)]; assert_eq!(next_append_position(&wallets), 3); } From cb352138739f9825be9ef8c8584ff430a0c43fbe Mon Sep 17 00:00:00 2001 From: Vishal Singh Date: Mon, 20 Apr 2026 23:10:54 +0530 Subject: [PATCH 12/13] chore: regenerate mobile bindings for wallet reordering --- .../java/org/bitcoinppl/cove_core/cove.kt | 20 ++++++++++++++++++- .../Sources/CoveCore/generated/cove.swift | 20 ++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt index be389b7ec..20af728bb 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt @@ -3929,7 +3929,7 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_method_walletstable_len() != 51436.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_cove_checksum_method_walletstable_reorder_wallets() != 12851.toShort()) { + if (lib.uniffi_cove_checksum_method_walletstable_reorder_wallets() != 2046.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if (lib.uniffi_cove_checksum_method_priceresponse_get() != 6552.toShort()) { @@ -26288,6 +26288,15 @@ public interface WalletsTableInterface { /** * Persist a new wallet order for the active wallet list. + * + * Validation rules: + * - `ordered_ids` must be a full permutation of existing wallet IDs in the active bucket. + * - Partial lists are rejected. + * - Unknown IDs are rejected. + * - Duplicate IDs are rejected. + * + * The write is atomic-like at the application level: validation and reorder construction + * happen before `save_all_wallets` is called, so invalid inputs do not mutate persisted state. */ fun `reorderWallets`(`orderedIds`: List) @@ -26471,6 +26480,15 @@ open class WalletsTable: Disposable, AutoCloseable, WalletsTableInterface /** * Persist a new wallet order for the active wallet list. + * + * Validation rules: + * - `ordered_ids` must be a full permutation of existing wallet IDs in the active bucket. + * - Partial lists are rejected. + * - Unknown IDs are rejected. + * - Duplicate IDs are rejected. + * + * The write is atomic-like at the application level: validation and reorder construction + * happen before `save_all_wallets` is called, so invalid inputs do not mutate persisted state. */ @Throws(DatabaseException::class)override fun `reorderWallets`(`orderedIds`: List) = diff --git a/ios/CoveCore/Sources/CoveCore/generated/cove.swift b/ios/CoveCore/Sources/CoveCore/generated/cove.swift index 0e899dca4..742cab307 100644 --- a/ios/CoveCore/Sources/CoveCore/generated/cove.swift +++ b/ios/CoveCore/Sources/CoveCore/generated/cove.swift @@ -11933,6 +11933,15 @@ public protocol WalletsTableProtocol: AnyObject, Sendable { /** * Persist a new wallet order for the active wallet list. + * + * Validation rules: + * - `ordered_ids` must be a full permutation of existing wallet IDs in the active bucket. + * - Partial lists are rejected. + * - Unknown IDs are rejected. + * - Duplicate IDs are rejected. + * + * The write is atomic-like at the application level: validation and reorder construction + * happen before `save_all_wallets` is called, so invalid inputs do not mutate persisted state. */ func reorderWallets(orderedIds: [WalletId]) throws @@ -12037,6 +12046,15 @@ open func len(network: Network, mode: WalletMode)throws -> UInt16 { /** * Persist a new wallet order for the active wallet list. + * + * Validation rules: + * - `ordered_ids` must be a full permutation of existing wallet IDs in the active bucket. + * - Partial lists are rejected. + * - Unknown IDs are rejected. + * - Duplicate IDs are rejected. + * + * The write is atomic-like at the application level: validation and reorder construction + * happen before `save_all_wallets` is called, so invalid inputs do not mutate persisted state. */ open func reorderWallets(orderedIds: [WalletId])throws {try rustCallWithError(FfiConverterTypeDatabaseError_lift) { uniffi_cove_fn_method_walletstable_reorder_wallets( @@ -36407,7 +36425,7 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_walletstable_len() != 51436) { return InitializationResult.apiChecksumMismatch } - if (uniffi_cove_checksum_method_walletstable_reorder_wallets() != 12851) { + if (uniffi_cove_checksum_method_walletstable_reorder_wallets() != 2046) { return InitializationResult.apiChecksumMismatch } if (uniffi_cove_checksum_method_priceresponse_get() != 6552) { From b8dfeafef22f4513468cf30b39e026047468712a Mon Sep 17 00:00:00 2001 From: Vishal Singh Date: Mon, 20 Apr 2026 23:16:33 +0530 Subject: [PATCH 13/13] chore: remove .DS_Store files and update gitignore --- .DS_Store | Bin 10244 -> 0 bytes .gitignore | 2 ++ ios/.DS_Store | Bin 6148 -> 0 bytes rust/.DS_Store | Bin 10244 -> 0 bytes 4 files changed, 2 insertions(+) delete mode 100644 .DS_Store delete mode 100644 ios/.DS_Store delete mode 100644 rust/.DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 1b431e4bab6b98dad02b9122aeedfe63ceb8e254..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10244 zcmeHMO>7%Q6n^7`*d|Hiq)FO}AFb+3N<+zCQ$-wb97iRgkSI1KQBsn%y>T{O@0i_n z(jK)1Re-H@PF|D?%AwF+Fcqv0}liq2s}{p0Lu>wRz?$oR$R(c2R`@_0BI|B^MdUd zYamqwO$b_XDRQt7R~4zNO2iR^xa!!i2)Try6_>i|K*ZsL2+KsAPzbk<<11VoNW!JT zGw?v*fkqFovwJICfCA{?$mjP^>Z2cJUXi9!S%YS=FY?Jjd*#75Ur&1Z`6KzIjr!d{ zXnqY*sFgIz+G?!J##ztgs5x(GTR7WAN4w#q^<8B3_!Jhj{Ok3u z%uWeqdg~p0(94#lnac%B&(E!|+nTc|wU?N+oJ9>|p4z&jQ*&vBTX(eLjJZzHB114& zVl4ZrX4yy^bQYIX%TmpPmfX2!s9DXJGn3SDXmL@;Suo_>WV)p0bh8kD;SblD(RgCO zZN(s7^>3uN+7gt+@2NlYcE+TphD!4;C!Tmoh(zE#zQ$=-fj6K6AHr?817E`T@C*C} z4@oEKCuhldGDt3xOC&)i$pTp>YeXfRu70Sx)K7`=1R*vyd`m3_EP406yLTET?8)9XULIi5lm&~6efW{|Q`nrMgESBz{mjLAd; z8g$vUmioos@$w$t?9}roAEvPfX7K2`n8BDyTE+x^)0YuEo1b^dT`FZ z#lAT`d>L!`Rt>99roJs5CgZ_8`#ze17Q{^bX!+YJqZLvz&U3w2o~i502yg#>V_@8k zM4>19vXLvJI`{NFhAIM^xMkbmz&heHK=XQ&M}>?pj1e@0k-;|CG)#M?c@)UfQ68m} zkb;++M=Os6S)&!VV2;N-GJEsL3D27+*{TJ&#IL8HW_&5g+kV~Wsi&sv+gN+5!3NLr?_U)Yzz*zUQnhfC z+r+d$!Sn?prdLlOvLq6ezypB?j?V)vu4#qc|4$$O|Nrp;3@is82t4qwdw{g2(kXVe zRXzLrEwWRpf^`imD@?B9Qm%pzCc^spbv*uH*YW&91N(*;j}>eu1g*Ffd$9cXe+D#M b?FHxm`mKX}8Vt_=?5;ri@CJA^&i{V{b29+* diff --git a/.gitignore b/.gitignore index 4ef9d2e2f..4cb338f3d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,5 @@ upload_certificate.pem /.jjconflict-base-* *.o /ios/build + +.DS_Store \ No newline at end of file diff --git a/ios/.DS_Store b/ios/.DS_Store deleted file mode 100644 index 131796aef837be9d6859af0ee0f4b72cc9c8200f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKJxc>Y5S@(y4+D}yM7t|fNQekl7N^EW8zH@@xuA&gLQt`=xW>XiVkKf{V__$# zg|&_T1hG>Te6zEOdtQv%h=iT6`}THccHh4Hm@E;gnVoQ)C{IKl3S)KvRR`m7E-A~H zo*|&pb5y8GE3}1QE#>VxoB~dP|E2)HyE&Srh(f%x_WRr1TpO=cqk2OqpC5khS&z!) z)y-%X9^TSR=IrU}a=M#$_zUmGy3OJN(^6LR-KAQB;Y4Z79!9PJNJUTaS6*n0HA zqSJU{-Ki3ooLpeCR!T_E_87Goxja*abdn__;p>w=KRx`#6IYP_u;ko z!S}tH)Uj}X^4Q78=KFS6dwrIBoSMt#*v-%8Rd_fRUUfW!+sGgx3NWc@?pE{qr6mSYS1=D+%%a!sBwq$Z^YIB@xeUxPsHuj4JbqFex hj&*}m@d%1GjQQLE1_on6^uXL70WE{8oC1HUz&or=$XNgY diff --git a/rust/.DS_Store b/rust/.DS_Store deleted file mode 100644 index 6c7e438d1d84e0c80259b3169924d5f748b83f82..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10244 zcmeHMO=u)V6n-_y&QB(BC)s3;=)$lgC~Szy>h2;QoJ>ZRppuXYYeKSSduDoO+w^qJ zcK0M3Ltt)#9u!5qiQvVPiXaMl(32qOac|2C>cN8`qNf$(tDnhKch5xjAgoJO=z7&v z->a(kb-$N>H2^>gt?Dd5766iXF_{_0sz+ht?243_?0FO=L4ANaP`f@`s?bI=g4P|H z0nLDBKr^5j&$DrOZp&7Lu#toFaQ5QkBRQg9vpT&oVg5yp(Mrg*p(S4n(d@WQIcc>Zq6Ha3Fah zb*&lD3`7}VxqBR@p#lcD(fpn4hsPVVSS;JLjQzkc}5Cr%HDUwB=Mi zu3HiVsZ=txnVH<%J3l)!l|6gm-2PN{@5!^+e&X@-`}?WnMDDStmN&QThC^TFI|m}e z7>tPf!}_&ec3G8eX&3pfW7n-rMp~9k4W)-i?mM0t9Xl~PJ~}Zmaq|Aj2gV+JXft!7 zY*e=DF5B@uwW(XMTjplwxQBKxRH(D$n%ijas&8KROV-OcG!{}=HmfgoyH<9ah#Bv8 z@JTPars=HLU8}lSt9hosse=JNx~VJgA#1OHE6>-@IHJ5U&HtCBm4;mWQ3d~ zr^y*IMb426Bv0nZGFc~Ah(TTw`mxT#J|*Xqf_T`7yu^V6wC8?d7DOOHS31U=d9fJ8zh(Wv! z6hqEd(80;+BXxY#X@DKlc{8LF$9Y{`$p{TuitB>U=jT&Z35oei+5!;y(?%5plRsm`-D0aWG8R@XzWOSa{%7 z-SQY*=T7WJLNp7veUJ(R2>SI)tdx0F`#*jAFvtWD-9XbDq1fKd@E8NSXa+O`ngPwg zoiH#Uz*%@nv%EQr*A={2VYs%CTm>rz zVg39#9&gxjJU@KFKAsiI@w}96A-Ov0adnj9H2**UG2q~UEg#J91D~%t|5r{2x}W7b S|L5<`(Z{!ewD7LY|NjA#Hl`{7