using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Threading; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using NBitcoin; using PalladiumWallet.App.Localization; using PalladiumWallet.Core.Chain; using PalladiumWallet.Core.Crypto; using PalladiumWallet.Core.Net; using PalladiumWallet.Core.Spv; using PalladiumWallet.Core.Storage; using PalladiumWallet.Core.Wallet; namespace PalladiumWallet.App.ViewModels; /// Transaction history row for the view. public sealed record HistoryRow(string Conferma, string Importo, string Txid, bool Verified = true); /// /// Address view row: public data and derivation path only. The private key is never held here; /// it is derived on demand when the user reveals it (see RevealedPrivKey). /// public sealed record AddressRow( string Tipo, int Indice, string Indirizzo, string Saldo, string NumTx, bool IsChange = false, string PubKey = "", string DerivPath = "", bool HasPrivKey = false); /// Address data passed to the detail overlay: derivation coordinates, never the key. public sealed record AddressInfo( Loc Loc, string Address, string DerivPath, string PubKey, bool IsChange, int Index, bool HasPrivKey); /// Address book contact: name + blockchain address. public sealed record ContactEntry(string Name, string Address); /// Wallet list entry: display name + full file path. public sealed record WalletFileEntry(string Name, string Path); /// /// Single application ViewModel (wizard + dashboard). Split into partial files /// by area: Wizard, Settings, Sync, Send, Contacts, Receive. /// public partial class MainWindowViewModel : ViewModelBase { // ---- wallet session state ---- private WalletDocument? _doc; private IWalletAccount? _account; private string? _walletPath; private string? _password; private WalletLock? _walletLock; // ---- network ---- private ElectrumClient? _client; private WalletSynchronizer? _synchronizer; private IReadOnlyDictionary? _lastTransactions; private bool _resyncRequested; // ---- send ---- private BuiltTransaction? _pendingSend; // ---- transaction detail ---- private CancellationTokenSource? _txDetailsCts; // ---- configuration and localisation ---- private AppConfig _config = AppConfig.Load(); private Loc _loc = Loc.Instance; public Loc Loc => _loc; // ---- wizard ---- private string? _pendingOpenPath; // ---- keep-alive ---- private bool _autoReconnect; private bool _syncFailed; private bool _resumeRecovering; private readonly DispatcherTimer _keepAliveTimer; // ---- server UI sync ---- private bool _syncingServerFields; // ---- app properties ---- public static string AppVersion => typeof(MainWindowViewModel).Assembly.GetName().Version is { } v ? $"{v.Major}.{v.Minor}.{v.Build}" : ""; public string WindowTitle => $"Palladium Wallet {AppVersion}"; /// True on desktop; false on Android/iOS — hides filesystem-dependent features. public bool IsDesktop => !OperatingSystem.IsAndroid() && !OperatingSystem.IsIOS(); public bool IsMobile => !IsDesktop; // Tab bar sizing: compact on mobile so all 5 tabs fit in one row. public double TabIconSize => IsMobile ? 20 : 24; public double TabFontSize => IsMobile ? 10 : 13; public double TabSpacing => IsMobile ? 4 : 4; public string UnitLabel => _config.Unit; public AppConfig CurrentConfig => _config; // ---- panel state ---- [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsSetupVisible))] private bool isWalletOpen; public bool IsSetupVisible => !IsWalletOpen; [ObservableProperty] private string statusMessage = ""; [ObservableProperty] private int selectedTabIndex; [RelayCommand] private void SelectTab(string index) => SelectedTabIndex = int.Parse(index); // ---- dashboard collections ---- public ObservableCollection History { get; } = []; public ObservableCollection Addresses { get; } = []; // ---- helpers ---- private NetKind Net => NetKind.Mainnet; /// Test seam: lets tests drop the real bootstrap servers so nothing reaches the internet. internal static ChainProfile? ProfileOverride { get; set; } private ChainProfile Profile => ProfileOverride ?? ChainProfiles.For(Net); private ServerRegistry Registry => new(Profile, AppPaths.ServersPath(Net)); private string Fmt(long sats, bool withLabel = true) => CoinAmount.FormatIn(sats, _config.Unit, withLabel); private string KeyWif(bool isChange, int index) { if (_account is null or { IsWatchOnly: true }) return ""; try { return _account.GetPrivateKey(isChange, index) ?.GetWif(PalladiumNetworks.For(Net)).ToString() ?? ""; } catch { return ""; } } // ---- constructor ---- public MainWindowViewModel() { _loc = Loc.SwitchTo(_config.Language); if (!AppPaths.IsDataLocationConfigured()) SetupStep = StepDataLocation; else RefreshSetupState(); _keepAliveTimer = new DispatcherTimer { Interval = System.TimeSpan.FromSeconds(20) }; _keepAliveTimer.Tick += async (_, _) => await KeepAliveTickAsync(); _keepAliveTimer.Start(); _ = CheckForUpdatesAsync(); } private bool _keepAliveRunning; private async System.Threading.Tasks.Task KeepAliveTickAsync() { // Re-entrancy guard: without it, a ping stuck on a half-open socket (see // below) would let every subsequent 20s tick pile up another concurrent // ping/reconnect attempt on top of it. if (IsSyncing || _keepAliveRunning) return; _keepAliveRunning = true; try { if (_client is { IsConnected: true } client) { // If the wallet is open and the last sync failed, retry automatically. if (_syncFailed && _account is not null) { await ConnectAndSync(); return; } try { // A "half-open" TCP connection (remote end gone with no FIN/RST // ever delivered — the common outcome of Android Doze/mobile-radio // suspend killing the route silently) never fails the write, so // PingAsync would otherwise await a response that never arrives. // Bound it explicitly instead of relying on it to throw. using var timeoutCts = new System.Threading.CancellationTokenSource(System.TimeSpan.FromSeconds(8)); await client.PingAsync(timeoutCts.Token); } catch { // TcpClient.Connected only reflects the last known socket state, so a // connection killed silently while the app was suspended still reports // IsConnected == true. A failed/timed-out ping is the only reliable // signal here: tear the dead client down so the next tick reconnects // instead of retrying forever on a dead socket. await DisconnectAsync(); if (_autoReconnect) { ConnectionStatus = Loc.Tr("conn.reconnecting"); await ConnectAndSync(); } } } else if (_autoReconnect) { ConnectionStatus = Loc.Tr("conn.reconnecting"); await ConnectAndSync(); } } finally { _keepAliveRunning = false; } } /// Forces an immediate connection health check, bypassing the 20s timer. /// Called when the app resumes from background/lock screen, since the socket may /// have died silently while suspended and the UI would otherwise show a stale /// "connected" state until the next scheduled tick (or forever, if it never fires /// during Doze). public async System.Threading.Tasks.Task CheckConnectionOnResumeAsync() { if (IsSyncing) { // A sync in progress when the phone locked may be stuck awaiting a // response on a socket that died silently while suspended (same // half-open-TCP scenario as the keep-alive ping, but sync requests have // no timeout of their own). Cancel it and tear the client down instead // of leaving it hung forever. _resumeRecovering tells ConnectAndSync's // error handling that this interruption is self-inflicted and expected, // so it shows "reconnecting" and restarts immediately instead of // flashing a scary error message and waiting for the next keep-alive tick. _resumeRecovering = true; ConnectionStatus = Loc.Tr("conn.reconnecting"); ConnectionStatusShort = Loc.Tr("conn.reconnecting"); _syncCts.Cancel(); // Deliberately not DisconnectAsync() here: it also nulls _synchronizer, // which still holds whatever this sync already downloaded/verified (e.g. // thousands of Merkle proofs on a large wallet). Nulling it before the // cancelled ConnectAndSync unwinds would discard that progress, forcing a // full restart instead of a resume. Only tear down the dead socket here; // ConnectAndSync's own cancellation handling persists the partial cache // (PersistPartialTxCache) before it recreates the synchronizer — same // sequencing already used for the "server changed mid-sync" case. if (_client is { } deadClient) { _client = null; try { await deadClient.DisposeAsync(); } catch { } } return; } await KeepAliveTickAsync(); } // ---- wallet lifecycle ---- [RelayCommand] private void CloseWallet() { _walletLock?.Dispose(); _walletLock = null; _ = _client?.DisposeAsync().AsTask(); _client = null; _synchronizer = null; _autoReconnect = false; _resyncRequested = false; _syncFailed = false; _doc = null; _account = null; _lastTransactions = null; _pendingSend = null; HasPendingSend = false; PendingPsbtBase64 = ""; OnPropertyChanged(nameof(IsWatchOnlyAccount)); History.Clear(); Contacts.Clear(); SelectedContactInList = null; SendToContact = null; SelectedAddressRow = null; AddressInfo = null; // also drops a revealed private key IsWalletOpen = false; IsConnected = false; ConnectionStatus = Loc.Tr("conn.none"); RefreshSetupState(); } }