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/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..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 @@ -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,14 +32,28 @@ 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.rememberCoroutineScope +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 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 @@ -47,12 +63,29 @@ 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 +import androidx.compose.foundation.Image @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 + val scope = rememberCoroutineScope() + + 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 +149,111 @@ 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) { + 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) { + 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 + 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 +334,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 +368,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 d322a3731..9a7c461a0 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 @@ -1316,6 +1316,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( @@ -2290,6 +2292,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, @@ -3929,6 +3933,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() != 2046.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") } @@ -26310,6 +26317,20 @@ public interface WalletsTableInterface { fun `len`(`network`: Network, `mode`: WalletMode): kotlin.UShort + /** + * 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) + companion object } @@ -26488,6 +26509,31 @@ 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) + = + callWithHandle { + uniffiRustCallWithError(DatabaseException) { _status -> + UniffiLib.uniffi_cove_fn_method_walletstable_reorder_wallets( + it, + FfiConverterSequenceTypeWalletId.lower(`orderedIds`),_status) +} + } + + + + @@ -29546,6 +29592,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 @@ -29633,6 +29684,7 @@ data class WalletMetadata ( this.`color`, this.`verified`, this.`network`, + this.`position`, this.`masterFingerprint`, this.`selectedUnit`, this.`sensitiveVisible`, @@ -29663,6 +29715,7 @@ public object FfiConverterTypeWalletMetadata: FfiConverterRustBuffer WalletTableException.WalletAlreadyExists() + 4 -> WalletTableException.InvalidWalletReorder( + FfiConverterString.read(buf), + ) else -> throw RuntimeException("invalid error enum value, something is very wrong!!") } } @@ -51505,6 +51571,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) + ) } } @@ -51524,6 +51595,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..79f9c6eca 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,14 @@ struct SidebarView: View { .padding(20) .frame(maxWidth: .infinity) .background(.midnightBlue) + .onAppear { + walletList = app.wallets + } + .onChange(of: app.wallets) { _, updated in + if draggedWalletId == nil { + walletList = updated + } + } } func goTo(_ route: Route) { @@ -170,4 +196,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 e0019bf64..6623b6dd5 100644 --- a/ios/CoveCore/Sources/CoveCore/generated/cove.swift +++ b/ios/CoveCore/Sources/CoveCore/generated/cove.swift @@ -11950,6 +11950,20 @@ public protocol WalletsTableProtocol: AnyObject, Sendable { 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. + */ + func reorderWallets(orderedIds: [WalletId]) throws + } open class WalletsTable: WalletsTableProtocol, @unchecked Sendable { fileprivate let handle: UInt64 @@ -12049,6 +12063,26 @@ 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( + self.uniffiCloneHandle(), + FfiConverterSequenceTypeWalletId.lower(orderedIds),$0 + ) +} +} + } @@ -15326,6 +15360,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 @@ -15349,7 +15387,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?, @@ -15362,6 +15403,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 @@ -15437,6 +15479,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), @@ -15459,6 +15502,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) @@ -31972,6 +32016,8 @@ enum WalletTableError: Swift.Error, Equatable, Hashable, Foundation.LocalizedErr case ReadError(String ) case WalletAlreadyExists + case InvalidWalletReorder(String + ) @@ -32018,6 +32064,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 } @@ -32043,6 +32092,11 @@ public struct FfiConverterTypeWalletTableError: FfiConverterRustBuffer { case .WalletAlreadyExists: writeInt(&buf, Int32(3)) + + case let .InvalidWalletReorder(v1): + writeInt(&buf, Int32(4)) + FfiConverterString.write(v1, into: &buf) + } } } @@ -36390,6 +36444,9 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_walletstable_len() != 51436) { return InitializationResult.apiChecksumMismatch } + if (uniffi_cove_checksum_method_walletstable_reorder_wallets() != 2046) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_cove_checksum_method_priceresponse_get() != 6552) { return InitializationResult.apiChecksumMismatch } 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..4a0bae162 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,12 +132,35 @@ impl WalletsTable { Ok(wallets) } + + /// 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)?; + let reordered = build_reordered_wallets(&ordered_ids, wallets)?; + + self.save_all_wallets(network, mode, reordered)?; + Updater::send_update(Update::WalletsChanged); + + Ok(()) + } } 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; @@ -142,6 +173,7 @@ impl WalletsTable { } let wallet_for_backup = should_backup_to_cloud.then(|| wallet.clone()); + wallet.position = next_append_position(&wallets); wallets.push(wallet); self.save_all_wallets(network, mode, wallets)?; @@ -192,11 +224,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 +250,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 +380,169 @@ 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) +} + +/// 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 [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; + } + 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(()) +} + +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::*; + 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 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)]; + 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(),