mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-17 05:41:29 +02:00
CW-1078: Electrum Wallets not reconencting correctly if the OS closes the connection (#2405)
* fix: Electrum Wallets not reconencting correctly if the OS closes the connection * fix: Electrum connectivity bug. Enahnced checks and reconnect when needed, and Socket health logs page for logs * fix: Add extra checks * chore: Migrate to static log list instead of file storage * fix: Electrum connectivity review comments This change: - Adds reset count for used trusted nodes - Adds context guards for ScaffoldMessenger calls * fix: Reset the state for _resetCount when there is a wallet switch
This commit is contained in:
@@ -43,7 +43,7 @@ class ElectrumClient {
|
||||
static const connectionTimeout = Duration(seconds: 5);
|
||||
static const aliveTimerDuration = Duration(seconds: 4);
|
||||
|
||||
bool get isConnected => _isConnected;
|
||||
bool get isConnected => _isConnected && socket != null;
|
||||
ProxySocket? socket;
|
||||
void Function(ConnectionStatus)? onConnectionStatusChange;
|
||||
int _id;
|
||||
@@ -69,6 +69,9 @@ class ElectrumClient {
|
||||
Future<void> connect({required String host, required int port}) async {
|
||||
_setConnectionStatus(ConnectionStatus.connecting);
|
||||
|
||||
// Reset internal state to ensure clean connection
|
||||
_resetInternalState();
|
||||
|
||||
try {
|
||||
await socket?.close();
|
||||
} catch (_) {}
|
||||
@@ -76,7 +79,8 @@ class ElectrumClient {
|
||||
|
||||
final ssl = !(useSSL == false || (useSSL == null && uri.toString().contains("btc-electrum")));
|
||||
try {
|
||||
socket = await ProxyWrapper().getSocksSocket(ssl, host, port, connectionTimeout: connectionTimeout);
|
||||
socket = await ProxyWrapper()
|
||||
.getSocksSocket(ssl, host, port, connectionTimeout: connectionTimeout);
|
||||
} catch (e) {
|
||||
printV("connect: $e");
|
||||
if (e is HandshakeException) {
|
||||
@@ -120,6 +124,7 @@ class ElectrumClient {
|
||||
printV(errorMsg);
|
||||
unterminatedString = '';
|
||||
socket = null;
|
||||
_setConnectionStatus(ConnectionStatus.disconnected);
|
||||
},
|
||||
onDone: () {
|
||||
printV("SOCKET CLOSED!!!!!");
|
||||
@@ -209,11 +214,11 @@ class ElectrumClient {
|
||||
if (result is Map<String, dynamic>) {
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
if (throwOnError) {
|
||||
throw Exception('Invalid response format for getBalance');
|
||||
}
|
||||
|
||||
|
||||
return <String, dynamic>{};
|
||||
} catch (e) {
|
||||
if (throwOnError) {
|
||||
@@ -446,9 +451,8 @@ class ElectrumClient {
|
||||
|
||||
Future<dynamic> call(
|
||||
{required String method, List<Object> params = const [], Function(int)? idCallback}) async {
|
||||
if (socket == null) {
|
||||
return null;
|
||||
}
|
||||
if (!_isConnected || socket == null) return null;
|
||||
|
||||
final completer = Completer<dynamic>();
|
||||
_id += 1;
|
||||
final id = _id;
|
||||
@@ -462,9 +466,8 @@ class ElectrumClient {
|
||||
Future<dynamic> callWithTimeout(
|
||||
{required String method, List<Object> params = const [], int timeout = 5000}) async {
|
||||
try {
|
||||
if (socket == null) {
|
||||
return null;
|
||||
}
|
||||
if (!_isConnected || socket == null) return null;
|
||||
|
||||
final completer = Completer<dynamic>();
|
||||
_id += 1;
|
||||
final id = _id;
|
||||
@@ -490,6 +493,22 @@ class ElectrumClient {
|
||||
socket = null;
|
||||
} catch (_) {}
|
||||
onConnectionStatusChange = null;
|
||||
// Reset internal state when closing
|
||||
_resetInternalStateCompletely();
|
||||
}
|
||||
|
||||
void _resetInternalState() {
|
||||
// Only clears errors and unterminated string, leaves tasks or reset ID
|
||||
// This preserves active subscriptions while clearing error state
|
||||
_errors.clear();
|
||||
unterminatedString = '';
|
||||
}
|
||||
|
||||
void _resetInternalStateCompletely() {
|
||||
_id = 0;
|
||||
_tasks.clear();
|
||||
_errors.clear();
|
||||
unterminatedString = '';
|
||||
}
|
||||
|
||||
void _registryTask(int id, Completer<dynamic> completer) =>
|
||||
@@ -587,6 +606,8 @@ class ElectrumClient {
|
||||
}
|
||||
|
||||
String getErrorMessage(int id) => _errors[id.toString()] ?? '';
|
||||
|
||||
bool get isInternalStateConsistent => _errors.isEmpty;
|
||||
}
|
||||
|
||||
// FIXME: move me
|
||||
|
||||
@@ -50,6 +50,7 @@ import 'package:mobx/mobx.dart';
|
||||
import 'package:rxdart/subjects.dart';
|
||||
import 'package:sp_scanner/sp_scanner.dart';
|
||||
import 'package:hex/hex.dart';
|
||||
import 'package:cw_core/utils/socket_health_logger.dart';
|
||||
|
||||
part 'electrum_wallet.g.dart';
|
||||
|
||||
@@ -2355,13 +2356,14 @@ abstract class ElectrumWalletBase
|
||||
Future<bool> checkNodeHealth() async {
|
||||
try {
|
||||
final addresses = walletAddresses.allAddresses
|
||||
.where((address) => RegexUtils.addressTypeFromStr(address.address, network) is! MwebAddress)
|
||||
.where(
|
||||
(address) => RegexUtils.addressTypeFromStr(address.address, network) is! MwebAddress)
|
||||
.toList();
|
||||
|
||||
|
||||
if (addresses.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
final firstAddress = addresses.first;
|
||||
final sh = firstAddress.getScriptHash(network);
|
||||
await electrumClient.getBalance(sh, throwOnError: true);
|
||||
@@ -2592,6 +2594,153 @@ abstract class ElectrumWalletBase
|
||||
final amountInt = int.parse(amount);
|
||||
return bitcoinAmountToString(amount: amountInt);
|
||||
}
|
||||
|
||||
/// Checks the health of the socket connection
|
||||
/// and triggers a full reconnection if needed
|
||||
@override
|
||||
Future<bool> checkSocketHealth() async {
|
||||
try {
|
||||
SocketHealthLogger().logHealthCheck(
|
||||
walletType: type,
|
||||
walletName: name,
|
||||
syncStatus: syncStatus.toString(),
|
||||
wasReconnected: false,
|
||||
trigger: 'socket_health_check_start',
|
||||
);
|
||||
|
||||
if (!electrumClient.isConnected || !electrumClient.isInternalStateConsistent) {
|
||||
if (!electrumClient.isConnected) {
|
||||
SocketHealthLogger().logHealthCheck(
|
||||
walletType: type,
|
||||
walletName: name,
|
||||
isHealthy: false,
|
||||
syncStatus: syncStatus.toString(),
|
||||
wasReconnected: false,
|
||||
trigger: 'socket_health_check_socket_not_connected',
|
||||
);
|
||||
}
|
||||
|
||||
if (!electrumClient.isInternalStateConsistent) {
|
||||
SocketHealthLogger().logHealthCheck(
|
||||
walletType: type,
|
||||
walletName: name,
|
||||
isHealthy: false,
|
||||
syncStatus: syncStatus.toString(),
|
||||
wasReconnected: false,
|
||||
trigger: 'socket_health_check_internal_state_inconsistent',
|
||||
);
|
||||
}
|
||||
|
||||
await _performFullReconnection();
|
||||
|
||||
SocketHealthLogger().logHealthCheck(
|
||||
walletType: type,
|
||||
walletName: name,
|
||||
isHealthy: true,
|
||||
syncStatus: syncStatus.toString(),
|
||||
wasReconnected: true,
|
||||
trigger:
|
||||
'socket_health_check_reconnection_success_for_unhealthy_basic_check_or_internal_state_inconsistent',
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Make a call to the server to check if the connection is healthy
|
||||
// If the call fails, we need to reconnect
|
||||
try {
|
||||
final result = await electrumClient.call(
|
||||
method: 'server.version',
|
||||
params: ['', '1.4'],
|
||||
);
|
||||
|
||||
if (result == null) {
|
||||
throw Exception('Call mechanism test returned null');
|
||||
}
|
||||
|
||||
SocketHealthLogger().logHealthCheck(
|
||||
walletType: type,
|
||||
walletName: name,
|
||||
isHealthy: true,
|
||||
syncStatus: syncStatus.toString(),
|
||||
wasReconnected: false,
|
||||
trigger: 'socket_health_check_server_state_ok',
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
SocketHealthLogger().logHealthCheck(
|
||||
walletType: type,
|
||||
walletName: name,
|
||||
isHealthy: false,
|
||||
error: e.toString(),
|
||||
syncStatus: syncStatus.toString(),
|
||||
wasReconnected: false,
|
||||
trigger: 'socket_health_check_server_state_failed',
|
||||
);
|
||||
|
||||
await _performFullReconnection();
|
||||
|
||||
SocketHealthLogger().logHealthCheck(
|
||||
walletType: type,
|
||||
walletName: name,
|
||||
isHealthy: true,
|
||||
syncStatus: syncStatus.toString(),
|
||||
wasReconnected: true,
|
||||
trigger: 'socket_health_check_reconnection_success_for_server_state_failed',
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _performFullReconnection() async {
|
||||
try {
|
||||
SocketHealthLogger().logHealthCheck(
|
||||
walletType: type,
|
||||
walletName: name,
|
||||
syncStatus: syncStatus.toString(),
|
||||
wasReconnected: true,
|
||||
trigger: 'full_reconnection_start',
|
||||
);
|
||||
|
||||
await _receiveStream?.cancel();
|
||||
|
||||
await electrumClient.close();
|
||||
|
||||
if (node != null) {
|
||||
electrumClient.onConnectionStatusChange = _onConnectionStatusChange;
|
||||
|
||||
await electrumClient.connectToUri(node!.uri, useSSL: node!.useSSL);
|
||||
|
||||
await startSync();
|
||||
|
||||
SocketHealthLogger().logHealthCheck(
|
||||
walletType: type,
|
||||
walletName: name,
|
||||
isHealthy: true,
|
||||
syncStatus: syncStatus.toString(),
|
||||
wasReconnected: true,
|
||||
trigger: 'full_reconnection_success',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
SocketHealthLogger().logHealthCheck(
|
||||
walletType: type,
|
||||
walletName: name,
|
||||
isHealthy: false,
|
||||
error: e.toString(),
|
||||
syncStatus: syncStatus.toString(),
|
||||
wasReconnected: false,
|
||||
trigger: 'full_reconnection_failed',
|
||||
);
|
||||
|
||||
syncStatus = FailedSyncStatus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ScanNode {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:cw_core/utils/print_verbose.dart';
|
||||
import 'package:cw_core/wallet_type.dart';
|
||||
|
||||
class SocketHealthLogEntry {
|
||||
final DateTime timestamp;
|
||||
final WalletType? walletType;
|
||||
final String? walletName;
|
||||
final bool? isHealthy;
|
||||
final String? error;
|
||||
final String? syncStatus;
|
||||
final bool wasReconnected;
|
||||
final String trigger;
|
||||
|
||||
SocketHealthLogEntry({
|
||||
required this.timestamp,
|
||||
required this.walletType,
|
||||
required this.walletName,
|
||||
required this.isHealthy,
|
||||
this.error,
|
||||
this.syncStatus,
|
||||
required this.wasReconnected,
|
||||
required this.trigger,
|
||||
});
|
||||
|
||||
String toLogString() {
|
||||
final dateStr = timestamp.toIso8601String();
|
||||
final status = isHealthy != null ? (isHealthy! ? 'HEALTHY' : 'UNHEALTHY') : 'UNKNOWN';
|
||||
final reconnect = wasReconnected ? 'RECONNECTED' : 'NO_RECONNECT';
|
||||
final errorStr = error != null ? ' | Error: $error' : '';
|
||||
final syncStr = syncStatus != null ? ' | Sync: $syncStatus' : '';
|
||||
|
||||
return '[$dateStr] $trigger | ${walletType?.name ?? 'UNKNOWN'} | $walletName | $status | $reconnect$errorStr$syncStr';
|
||||
}
|
||||
}
|
||||
|
||||
class SocketHealthLogger {
|
||||
static final SocketHealthLogger _instance = SocketHealthLogger._internal();
|
||||
factory SocketHealthLogger() => _instance;
|
||||
SocketHealthLogger._internal();
|
||||
|
||||
static const int _maxLogs = 100;
|
||||
|
||||
static final List<SocketHealthLogEntry> logs = [];
|
||||
|
||||
void addLog(SocketHealthLogEntry entry) {
|
||||
try {
|
||||
printV('Adding log entry: ${entry.toLogString()}');
|
||||
|
||||
logs.add(entry);
|
||||
|
||||
if (logs.length > _maxLogs * 2) {
|
||||
final excessCount = logs.length - _maxLogs;
|
||||
logs.removeRange(0, excessCount);
|
||||
}
|
||||
} catch (e) {
|
||||
printV('Failed to add log: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void clearLogs() {
|
||||
logs.clear();
|
||||
printV('Cleared all logs');
|
||||
}
|
||||
|
||||
/// Log a health check with the given parameters
|
||||
void logHealthCheck({
|
||||
WalletType? walletType,
|
||||
String? walletName,
|
||||
bool? isHealthy,
|
||||
String? error,
|
||||
String? syncStatus,
|
||||
required bool wasReconnected,
|
||||
required String trigger,
|
||||
}) {
|
||||
addLog(
|
||||
SocketHealthLogEntry(
|
||||
timestamp: DateTime.now(),
|
||||
walletType: walletType,
|
||||
walletName: walletName,
|
||||
isHealthy: isHealthy,
|
||||
error: error,
|
||||
syncStatus: syncStatus,
|
||||
wasReconnected: wasReconnected,
|
||||
trigger: trigger,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,11 @@ abstract class WalletBase<BalanceType extends Balance, HistoryType extends Trans
|
||||
|
||||
bool canSend() => true;
|
||||
|
||||
/// Check if the wallet's socket connection is healthy.
|
||||
/// Returns true if the connection is alive, false otherwise.
|
||||
/// Default implementation returns true (no-op for wallets without socket connections).
|
||||
Future<bool> checkSocketHealth() async => true;
|
||||
|
||||
/// This is used to check if the current node is healthy by making a lightweight RPC call
|
||||
/// Each wallet implementation should override this to make a single, efficient call
|
||||
/// Returns true if the node is healthy, false otherwise
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cake_wallet/entities/seed_type.dart';
|
||||
import 'package:cake_wallet/reactions/bip39_wallet_utils.dart';
|
||||
import 'package:cake_wallet/reactions/wallet_utils.dart';
|
||||
import 'package:cake_wallet/wallet_types.g.dart';
|
||||
import 'package:cw_core/wallet_type.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@@ -15,6 +15,15 @@ class NodeSwitchingService {
|
||||
|
||||
static const int _healthCheckIntervalSeconds = 30;
|
||||
|
||||
// The number of times we want to reset the overall used trusted nodes list.
|
||||
// We don't want an infinite loop if all trusted nodes are down.
|
||||
static const int _usedTrustedNodeResetCount = 2;
|
||||
|
||||
// State to manage the reset count
|
||||
int _resetCount = 0;
|
||||
|
||||
String walletName = '';
|
||||
|
||||
Timer? _healthCheckTimer;
|
||||
|
||||
bool _isSwitching = false;
|
||||
@@ -64,6 +73,10 @@ class NodeSwitchingService {
|
||||
Future<void> _switchToNextTrustedNode() async {
|
||||
_isSwitching = true;
|
||||
|
||||
if (walletName.isNotEmpty && (walletName != appStore.wallet!.name)) _resetCount = 0;
|
||||
|
||||
walletName = appStore.wallet!.name;
|
||||
|
||||
try {
|
||||
final walletType = appStore.wallet!.type;
|
||||
final currentNode = settingsStore.getCurrentNode(walletType);
|
||||
@@ -98,6 +111,10 @@ class NodeSwitchingService {
|
||||
// If all trusted nodes have been used, reset the list and start over
|
||||
if (nextNode == null) {
|
||||
printV('All trusted nodes have been tried, resetting and starting over');
|
||||
_resetCount++;
|
||||
|
||||
if (_resetCount > _usedTrustedNodeResetCount) return;
|
||||
|
||||
_usedNodeKeys[walletType]!.clear();
|
||||
nextNode = trustedNodes.first;
|
||||
}
|
||||
|
||||
@@ -278,6 +278,8 @@ import 'package:cake_wallet/view_model/dev/background_sync_logs_view_model.dart'
|
||||
import 'package:cake_wallet/src/screens/dev/background_sync_logs_page.dart';
|
||||
import 'package:cake_wallet/core/trade_monitor.dart';
|
||||
import 'package:cake_wallet/core/reset_service.dart';
|
||||
import 'package:cake_wallet/view_model/dev/socket_health_logs_view_model.dart';
|
||||
import 'package:cake_wallet/src/screens/dev/socket_health_logs_page.dart';
|
||||
|
||||
final getIt = GetIt.instance;
|
||||
|
||||
@@ -1504,6 +1506,9 @@ Future<void> setup({
|
||||
|
||||
getIt.registerFactory(() => DevBackgroundSyncLogsPage(getIt.get<BackgroundSyncLogsViewModel>()));
|
||||
|
||||
getIt.registerFactory(() => SocketHealthLogsViewModel());
|
||||
getIt.registerFactory(() => DevSocketHealthLogsPage(getIt.get<SocketHealthLogsViewModel>()));
|
||||
|
||||
getIt.registerFactory(() => DevNetworkRequests());
|
||||
|
||||
getIt.registerFactory(() => StartTorPage(StartTorViewModel(),));
|
||||
|
||||
@@ -21,3 +21,14 @@ bool isBIP39Wallet(WalletType walletType) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool isElectrumWallet(WalletType walletType) {
|
||||
switch (walletType) {
|
||||
case WalletType.bitcoin:
|
||||
case WalletType.litecoin:
|
||||
case WalletType.bitcoinCash:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import 'package:cake_wallet/src/screens/dev/network_requests.dart';
|
||||
import 'package:cake_wallet/src/screens/dev/secure_preferences_page.dart';
|
||||
import 'package:cake_wallet/src/screens/dev/shared_preferences_page.dart';
|
||||
import 'package:cake_wallet/src/screens/dev/background_sync_logs_page.dart';
|
||||
import 'package:cake_wallet/src/screens/dev/socket_health_logs_page.dart';
|
||||
import 'package:cake_wallet/src/screens/disclaimer/disclaimer_page.dart';
|
||||
import 'package:cake_wallet/src/screens/disclaimer/third_party_disclaimer_page.dart';
|
||||
import 'package:cake_wallet/src/screens/exchange/exchange_page.dart';
|
||||
@@ -907,6 +908,11 @@ Route<dynamic> createRoute(RouteSettings settings) {
|
||||
builder: (_) => getIt.get<DevBackgroundSyncLogsPage>(),
|
||||
);
|
||||
|
||||
case Routes.devSocketHealthLogs:
|
||||
return CupertinoPageRoute<void>(
|
||||
builder: (_) => getIt.get<DevSocketHealthLogsPage>(),
|
||||
);
|
||||
|
||||
case Routes.devNetworkRequests:
|
||||
return MaterialPageRoute<void>(
|
||||
builder: (_) => getIt.get<DevNetworkRequests>(),
|
||||
|
||||
@@ -119,6 +119,7 @@ class Routes {
|
||||
static const devSharedPreferences = '/dev/shared_preferences';
|
||||
static const devSecurePreferences = '/dev/secure_preferences';
|
||||
static const devBackgroundSyncLogs = '/dev/background_sync_logs';
|
||||
static const devSocketHealthLogs = '/dev/socket_health_logs';
|
||||
static const devNetworkRequests = '/dev/network_requests';
|
||||
|
||||
static const signPage = '/sign_page';
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import 'package:cake_wallet/di.dart';
|
||||
import 'package:cake_wallet/src/screens/base_page.dart';
|
||||
import 'package:cake_wallet/view_model/dev/socket_health_logs_view_model.dart';
|
||||
import 'package:cw_core/utils/socket_health_logger.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:cake_wallet/store/app_store.dart';
|
||||
import 'package:cake_wallet/reactions/wallet_utils.dart';
|
||||
|
||||
class DevSocketHealthLogsPage extends BasePage {
|
||||
final SocketHealthLogsViewModel viewModel;
|
||||
|
||||
DevSocketHealthLogsPage(this.viewModel) {
|
||||
viewModel.loadLogs();
|
||||
}
|
||||
|
||||
@override
|
||||
String? get title => "[dev] socket health logs";
|
||||
|
||||
@override
|
||||
Widget body(BuildContext context) {
|
||||
return Observer(
|
||||
builder: (_) {
|
||||
if (viewModel.logs.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text("No logs available"),
|
||||
SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () => viewModel.loadLogs(),
|
||||
child: Text("Load Logs"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_StatsCard(viewModel),
|
||||
Expanded(
|
||||
child: _LogsList(viewModel),
|
||||
),
|
||||
_ActionButtons(viewModel),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatsCard extends StatelessWidget {
|
||||
const _StatsCard(this.viewModel);
|
||||
|
||||
final SocketHealthLogsViewModel viewModel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: EdgeInsets.all(8),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Observer(
|
||||
builder: (_) => Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_StatItem('Total', viewModel.totalLogs.toString(), Colors.blue),
|
||||
_StatItem('Healthy', viewModel.healthyLogs.toString(), Colors.green),
|
||||
_StatItem('Unhealthy', viewModel.unhealthyLogs.toString(), Colors.red),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
if (viewModel.error != null)
|
||||
Text(
|
||||
'Error: ${viewModel.error}',
|
||||
style: TextStyle(fontSize: 12, color: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatItem extends StatelessWidget {
|
||||
const _StatItem(this.label, this.value, this.color);
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LogsList extends StatelessWidget {
|
||||
const _LogsList(this.viewModel);
|
||||
|
||||
final SocketHealthLogsViewModel viewModel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Observer(
|
||||
builder: (_) {
|
||||
final logs = viewModel.logs;
|
||||
if (logs.isEmpty) {
|
||||
return Center(child: Text("No logs available"));
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: logs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final log = logs[index];
|
||||
return _LogItem(log);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LogItem extends StatelessWidget {
|
||||
const _LogItem(this.log);
|
||||
|
||||
final SocketHealthLogEntry log;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dateFormat = DateFormat('MM-dd HH:mm:ss.SSS');
|
||||
final isHealthy = log.isHealthy;
|
||||
|
||||
return Card(
|
||||
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
color: isHealthy != null ? (isHealthy ? Colors.green[50] : Colors.red[50]) : Colors.grey[50],
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
log.toLogString(),
|
||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||
fontFamily: 'Monospace',
|
||||
fontSize: 11,
|
||||
color: isHealthy != null ? (isHealthy ? Colors.green : Colors.red) : Colors.grey,
|
||||
),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${dateFormat.format(log.timestamp)} | ${log.trigger}',
|
||||
style: TextStyle(fontSize: 10, color: Colors.grey[600]),
|
||||
),
|
||||
if (log.error != null)
|
||||
Text(
|
||||
'Error: ${log.error}',
|
||||
style: TextStyle(fontSize: 10, color: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
leading: Icon(
|
||||
isHealthy != null ? (isHealthy ? Icons.check_circle : Icons.error) : Icons.help,
|
||||
color: isHealthy != null ? (isHealthy ? Colors.green : Colors.red) : Colors.grey,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionButtons extends StatelessWidget {
|
||||
const _ActionButtons(this.viewModel);
|
||||
|
||||
final SocketHealthLogsViewModel viewModel;
|
||||
|
||||
void _copyLogsToClipboard(BuildContext context) {
|
||||
final logsText = viewModel.getLogsAsText();
|
||||
Clipboard.setData(ClipboardData(text: logsText));
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Logs copied to clipboard'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _testEnhancedHealthCheck(BuildContext context) async {
|
||||
final wallet = getIt.get<AppStore>().wallet;
|
||||
if (wallet == null || !isElectrumWallet(wallet.type)) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('No Electrum wallet found'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final isHealthy = await wallet.checkSocketHealth();
|
||||
|
||||
SocketHealthLogger().logHealthCheck(
|
||||
walletType: wallet.type,
|
||||
walletName: wallet.name,
|
||||
isHealthy: isHealthy,
|
||||
syncStatus: wallet.syncStatus.toString(),
|
||||
wasReconnected: true,
|
||||
trigger: 'manual_enhanced_health_check',
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Enhanced health check completed: ${isHealthy ? "Healthy" : "Unhealthy"}'),
|
||||
backgroundColor: isHealthy ? Colors.green : Colors.red,
|
||||
),
|
||||
);
|
||||
|
||||
viewModel.loadLogs();
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Enhanced health check failed: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _refreshLogs(BuildContext context) {
|
||||
viewModel.loadLogs();
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Logs refreshed'),
|
||||
duration: Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () => viewModel.clearLogs(),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: Text('Clear Logs'),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () => _copyLogsToClipboard(context),
|
||||
child: Text('Copy to Clipboard'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () => _testEnhancedHealthCheck(context),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.purple,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: Text('Enhanced Health Check'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () => _refreshLogs(context),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.green,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: Text('Refresh'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import 'package:cake_wallet/core/new_wallet_type_arguments.dart';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cake_wallet/generated/i18n.dart';
|
||||
import 'package:cake_wallet/reactions/bip39_wallet_utils.dart';
|
||||
import 'package:cake_wallet/reactions/wallet_utils.dart';
|
||||
import 'package:cake_wallet/routes.dart';
|
||||
import 'package:cake_wallet/src/screens/base_page.dart';
|
||||
import 'package:cake_wallet/src/screens/new_wallet/widgets/select_button.dart';
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'package:cake_wallet/core/auth_service.dart';
|
||||
import 'package:cake_wallet/core/node_switching_service.dart';
|
||||
import 'package:cake_wallet/core/totp_request_details.dart';
|
||||
import 'package:cake_wallet/core/trade_monitor.dart';
|
||||
import 'package:cake_wallet/di.dart';
|
||||
import 'package:cake_wallet/utils/device_info.dart';
|
||||
import 'package:cake_wallet/view_model/link_view_model.dart';
|
||||
import 'package:cw_core/utils/print_verbose.dart';
|
||||
@@ -20,6 +19,8 @@ import 'package:cake_wallet/entities/qr_scanner.dart';
|
||||
import 'package:mobx/mobx.dart';
|
||||
import 'package:uni_links/uni_links.dart';
|
||||
import 'package:cake_wallet/src/screens/setup_2fa/setup_2fa_enter_code_page.dart';
|
||||
import 'package:cake_wallet/reactions/wallet_utils.dart';
|
||||
import 'package:cw_core/utils/socket_health_logger.dart';
|
||||
|
||||
class Root extends StatefulWidget {
|
||||
Root({
|
||||
@@ -169,6 +170,29 @@ class RootState extends State<Root> with WidgetsBindingObserver {
|
||||
// Trigger node health check when app resumes
|
||||
widget.nodeSwitchingService.performHealthCheck();
|
||||
|
||||
// Electrum Wallet socket health check and reconnection flow
|
||||
final wallet = widget.appStore.wallet;
|
||||
if (wallet != null && isElectrumWallet(wallet.type)) {
|
||||
SocketHealthLogger().logHealthCheck(
|
||||
walletType: wallet.type,
|
||||
walletName: wallet.name,
|
||||
syncStatus: wallet.syncStatus.toString(),
|
||||
wasReconnected: false,
|
||||
trigger: 'app_resume',
|
||||
);
|
||||
|
||||
wallet.checkSocketHealth().then((isHealthy) {
|
||||
SocketHealthLogger().logHealthCheck(
|
||||
walletType: wallet.type,
|
||||
walletName: wallet.name,
|
||||
isHealthy: isHealthy,
|
||||
syncStatus: wallet.syncStatus.toString(),
|
||||
wasReconnected: true,
|
||||
trigger: 'app_resume_socket_health_check',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
|
||||
@@ -93,6 +93,12 @@ class OtherSettingsPage extends BasePage {
|
||||
handler: (BuildContext context) =>
|
||||
Navigator.of(context).pushNamed(Routes.devBackgroundSyncLogs),
|
||||
),
|
||||
if (FeatureFlag.hasDevOptions)
|
||||
SettingsCellWithArrow(
|
||||
title: '[dev] socket health logs',
|
||||
handler: (BuildContext context) =>
|
||||
Navigator.of(context).pushNamed(Routes.devSocketHealthLogs),
|
||||
),
|
||||
if (FeatureFlag.hasDevOptions)
|
||||
SettingsCellWithArrow(
|
||||
title: '[dev] network requests logs',
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:cw_core/utils/socket_health_logger.dart';
|
||||
import 'package:mobx/mobx.dart';
|
||||
|
||||
part 'socket_health_logs_view_model.g.dart';
|
||||
|
||||
class SocketHealthLogsViewModel = SocketHealthLogsViewModelBase with _$SocketHealthLogsViewModel;
|
||||
|
||||
abstract class SocketHealthLogsViewModelBase with Store {
|
||||
|
||||
@observable
|
||||
bool isLoading = false;
|
||||
|
||||
@observable
|
||||
String? error;
|
||||
|
||||
@observable
|
||||
ObservableList<SocketHealthLogEntry> logs = ObservableList<SocketHealthLogEntry>();
|
||||
|
||||
@computed
|
||||
int get totalLogs => logs.length;
|
||||
|
||||
@computed
|
||||
int get unhealthyLogs => logs.where((log) => log.isHealthy == false).length;
|
||||
|
||||
@computed
|
||||
int get healthyLogs => totalLogs - unhealthyLogs;
|
||||
|
||||
String getLogsAsText() {
|
||||
if (logs.isEmpty) return 'No logs available';
|
||||
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('Socket Health Logs');
|
||||
buffer.writeln('Generated: ${DateTime.now().toIso8601String()}');
|
||||
buffer.writeln('Total logs: $totalLogs');
|
||||
buffer.writeln('Unhealthy logs: $unhealthyLogs');
|
||||
buffer.writeln('Healthy logs: $healthyLogs');
|
||||
buffer.writeln('');
|
||||
|
||||
for (final log in logs) {
|
||||
buffer.writeln(log.toLogString());
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
@action
|
||||
void loadLogs() {
|
||||
this.logs.clear();
|
||||
this.logs.addAll(SocketHealthLogger.logs);
|
||||
}
|
||||
|
||||
@action
|
||||
void clearLogs() {
|
||||
SocketHealthLogger().clearLogs();
|
||||
this.logs.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:cake_wallet/core/wallet_loading_service.dart';
|
||||
import 'package:cake_wallet/entities/wallet_group.dart';
|
||||
import 'package:cake_wallet/entities/wallet_manager.dart';
|
||||
import 'package:cake_wallet/reactions/bip39_wallet_utils.dart';
|
||||
import 'package:cake_wallet/reactions/wallet_utils.dart';
|
||||
import 'package:cake_wallet/store/app_store.dart';
|
||||
import 'package:cake_wallet/view_model/wallet_list/wallet_list_item.dart';
|
||||
import 'package:cake_wallet/view_model/wallet_list/wallet_list_view_model.dart';
|
||||
|
||||
Reference in New Issue
Block a user