add balance watcher [skip slack]

This commit is contained in:
Czarek Nakamoto 2025-04-14 00:23:09 +02:00
parent 87207c61ba
commit f39b58e35d
9 changed files with 574 additions and 1 deletions

View file

@ -1,6 +1,6 @@
name: Cake Wallet Android
on: [pull_request]
on: [push]
defaults:
run:

View file

@ -26,6 +26,9 @@ class SolanaTransactionInfo extends TransactionInfo {
final double txFee;
final TransactionDirection direction;
@override
int get confirmations => 0;
String? _fiatAmount;
@override

View file

@ -28,6 +28,9 @@ class TronTransactionInfo extends TransactionInfo {
final int? txFee;
final TransactionDirection direction;
@override
int get confirmations => 0;
factory TronTransactionInfo.fromJson(Map<String, dynamic> data) {
return TronTransactionInfo(
id: data['id'] as String,

View file

@ -36,8 +36,10 @@ import 'package:cake_wallet/entities/parse_address_from_domain.dart';
import 'package:cake_wallet/exchange/provider/trocador_exchange_provider.dart';
import 'package:cake_wallet/src/screens/dev/monero_background_sync.dart';
import 'package:cake_wallet/src/screens/dev/moneroc_call_profiler.dart';
import 'package:cake_wallet/src/screens/dev/wallet_sync_exporter_page.dart';
import 'package:cake_wallet/src/screens/settings/background_sync_page.dart';
import 'package:cake_wallet/view_model/dev/monero_background_sync.dart';
import 'package:cake_wallet/view_model/dev/wallet_sync_exporter.dart';
import 'package:cake_wallet/view_model/link_view_model.dart';
import 'package:cake_wallet/tron/tron.dart';
import 'package:cake_wallet/src/screens/transaction_details/rbf_details_page.dart';
@ -1452,5 +1454,8 @@ Future<void> setup({
getIt.registerFactory(() => DevMoneroBackgroundSyncPage(getIt.get<DevMoneroBackgroundSync>()));
getIt.registerFactory(() => DevMoneroCallProfilerPage());
getIt.registerFactory(() => WalletSyncExporterPage(getIt.get<WalletSyncExporter>()));
getIt.registerFactory(() => WalletSyncExporter());
_isSetupFinished = true;
}

View file

@ -38,6 +38,7 @@ import 'package:cake_wallet/src/screens/dashboard/pages/transactions_page.dart';
import 'package:cake_wallet/src/screens/dashboard/sign_page.dart';
import 'package:cake_wallet/src/screens/dev/monero_background_sync.dart';
import 'package:cake_wallet/src/screens/dev/moneroc_call_profiler.dart';
import 'package:cake_wallet/src/screens/dev/wallet_sync_exporter_page.dart';
import 'package:cake_wallet/src/screens/disclaimer/disclaimer_page.dart';
import 'package:cake_wallet/src/screens/exchange/exchange_page.dart';
import 'package:cake_wallet/src/screens/exchange/exchange_template_page.dart';
@ -847,6 +848,11 @@ Route<dynamic> createRoute(RouteSettings settings) {
builder: (_) => getIt.get<DevMoneroCallProfilerPage>(),
);
case Routes.devWalletSyncExporter:
return MaterialPageRoute<void>(
builder: (_) => getIt.get<WalletSyncExporterPage>(),
);
default:
return MaterialPageRoute<void>(
builder: (_) => Scaffold(

View file

@ -113,6 +113,7 @@ class Routes {
static const backgroundSync = '/background_sync';
static const devMoneroBackgroundSync = '/dev/monero_background_sync';
static const devMoneroCallProfiler = '/dev/monero_call_profiler';
static const devWalletSyncExporter = '/dev/wallet_sync_exporter';
static const signPage = '/sign_page';
static const connectDevices = '/device/connect';
static const urqrAnimatedPage = '/urqr/animated_page';

View file

@ -0,0 +1,280 @@
import 'package:cake_wallet/src/screens/base_page.dart';
import 'package:cake_wallet/src/widgets/primary_button.dart';
import 'package:cake_wallet/view_model/dev/wallet_sync_exporter.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
class WalletSyncExporterPage extends BasePage {
final WalletSyncExporter viewModel;
WalletSyncExporterPage(this.viewModel) {
viewModel.initialize();
}
@override
String? get title => "[dev] wallet sync exporter";
@override
Widget body(BuildContext context) {
return Observer(
builder: (_) {
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildStatusSection(context),
SizedBox(height: 16),
_buildSettingsSection(context),
SizedBox(height: 16),
_buildControlSection(context),
SizedBox(height: 16),
_buildExportInfoSection(context),
],
),
),
);
},
);
}
Widget _buildStatusSection(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Status', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
SizedBox(height: 8),
Container(
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(10),
),
padding: EdgeInsets.all(16),
child: Column(
children: [
_buildStatusRow(context, 'Sync in progress:', viewModel.isSyncing ? 'Yes' : 'No'),
if (viewModel.totalWallets > 0) ...[
_buildStatusRow(context, 'Progress:',
'${viewModel.currentWalletIndex}/${viewModel.totalWallets} (${viewModel.progress}%)'),
],
_buildStatusRow(context, 'Status:', viewModel.statusMessage),
_buildStatusRow(context, 'Timer active:',
viewModel.syncTimer != null ? 'Yes (${viewModel.exportIntervalMinutes}m)' : 'No'),
],
),
),
],
);
}
Widget _buildSettingsSection(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Settings', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
SizedBox(height: 8),
Container(
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(10),
),
padding: EdgeInsets.all(16),
child: Column(
children: [
_buildTextFieldRow(
context,
'Export path:',
viewModel.exportPath,
(value) => viewModel.setExportPath(value),
),
SizedBox(height: 8),
_buildTextFieldRow(
context,
'Interval (minutes):',
viewModel.exportIntervalMinutes.toString(),
(value) {
final interval = int.tryParse(value);
if (interval != null && interval > 0) {
viewModel.setExportInterval(interval);
}
},
keyboardType: TextInputType.number,
),
],
),
),
],
);
}
Widget _buildControlSection(BuildContext context) {
final isButtonsEnabled = !viewModel.isSyncing;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Controls', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
SizedBox(height: 8),
Wrap(
spacing: 16,
runSpacing: 16,
children: [
PrimaryButton(
text: "Sync & Export Now",
color: Colors.purple,
textColor: Colors.white,
onPressed: isButtonsEnabled ? () => viewModel.syncAndExport() : null,
isDisabled: !isButtonsEnabled,
),
PrimaryButton(
text: viewModel.syncTimer == null ? "Start Periodic Sync" : "Stop Periodic Sync",
color: Colors.purple,
textColor: Colors.white,
onPressed: isButtonsEnabled
? () {
if (viewModel.syncTimer == null) {
viewModel.startPeriodicSync();
} else {
viewModel.stopPeriodicSync();
}
}
: null,
isDisabled: !isButtonsEnabled,
),
],
),
],
);
}
Widget _buildExportInfoSection(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Last Export', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
SizedBox(height: 8),
Container(
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(10),
),
padding: EdgeInsets.all(16),
child: Column(
children: [
_buildStatusRow(
context,
'Last export time:',
viewModel.lastExportTime.isEmpty ? 'Never' : viewModel.lastExportTime,
),
],
),
),
if (viewModel.exportData.isNotEmpty) ...[
SizedBox(height: 8),
PrimaryButton(
text: "View Export Data",
color: Colors.purple,
textColor: Colors.white,
onPressed: () {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text('Export Data Summary'),
content: Text(_buildDataSummary()),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Close'),
),
],
);
},
);
},
),
],
],
);
}
Widget _buildStatusRow(BuildContext context, String title, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontWeight: FontWeight.bold,
),
),
SizedBox(width: 8),
Expanded(
child: Text(
value,
style: TextStyle(
fontWeight: FontWeight.normal,
),
),
),
],
),
);
}
Widget _buildTextFieldRow(
BuildContext context,
String title,
String value,
Function(String) onChanged,
{TextInputType keyboardType = TextInputType.text}
) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
title,
style: TextStyle(
fontWeight: FontWeight.bold,
),
),
SizedBox(width: 8),
Expanded(
child: TextField(
controller: TextEditingController(text: value),
onChanged: onChanged,
keyboardType: keyboardType,
decoration: InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(vertical: 8, horizontal: 8),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
),
),
),
),
],
);
}
String _buildDataSummary() {
final buffer = StringBuffer();
final data = viewModel.exportData;
buffer.writeln('Timestamp: ${data['timestamp'] ?? 'Unknown'}');
buffer.writeln('Wallets: ${(data['wallets'] as List?)?.length ?? 0}');
int totalTransactions = 0;
for (final wallet in (data['wallets'] as List?) ?? []) {
if (wallet is Map && wallet['transactions'] is List) {
totalTransactions += (wallet['transactions'] as List).length;
}
}
buffer.writeln('Total transactions: $totalTransactions');
return buffer.toString();
}
}

View file

@ -77,6 +77,12 @@ class OtherSettingsPage extends BasePage {
handler: (BuildContext context) =>
Navigator.of(context).pushNamed(Routes.devMoneroCallProfiler),
),
if (FeatureFlag.hasDevOptions)
SettingsCellWithArrow(
title: '[dev] wallet sync exporter',
handler: (BuildContext context) =>
Navigator.of(context).pushNamed(Routes.devWalletSyncExporter),
),
Spacer(),
SettingsVersionCell(
title: S.of(context).version(_otherSettingsViewModel.currentVersion)),

View file

@ -0,0 +1,269 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:cake_wallet/core/key_service.dart';
import 'package:cake_wallet/core/wallet_loading_service.dart';
import 'package:cake_wallet/di.dart';
import 'package:cake_wallet/store/settings_store.dart';
import 'package:cake_wallet/view_model/wallet_list/wallet_list_view_model.dart';
import 'package:cw_core/sync_status.dart';
import 'package:cw_core/wallet_base.dart';
import 'package:mobx/mobx.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:path_provider/path_provider.dart';
part 'wallet_sync_exporter.g.dart';
class WalletSyncExporter = WalletSyncExporterBase with _$WalletSyncExporter;
abstract class WalletSyncExporterBase with Store {
WalletSyncExporterBase();
static const String _exportPathKey = 'wallet_sync_export_path';
static const String _exportIntervalKey = 'wallet_sync_export_interval';
final walletLoadingService = getIt.get<WalletLoadingService>();
final walletListViewModel = getIt.get<WalletListViewModel>();
final settingsStore = getIt.get<SettingsStore>();
final keyService = getIt.get<KeyService>();
@observable
Timer? syncTimer;
@observable
bool isTimerActive = false;
@observable
String exportPath = '';
@observable
int exportIntervalMinutes = 30;
@observable
bool isSyncing = false;
@observable
String statusMessage = '';
@observable
int progress = 0;
@observable
int totalWallets = 0;
@observable
int currentWalletIndex = 0;
@observable
String lastExportTime = '';
@observable
Map<String, dynamic> exportData = {};
@observable
String lastError = '';
Future<void> initialize() async {
final prefs = await SharedPreferences.getInstance();
exportPath = prefs.getString(_exportPathKey) ?? await _getDefaultExportPath();
exportIntervalMinutes = prefs.getInt(_exportIntervalKey) ?? 30;
}
Future<String> _getDefaultExportPath() async {
final directory = await getApplicationDocumentsDirectory();
return '${directory.path}/wallet_export.json';
}
Future<void> setExportPath(String path) async {
exportPath = path;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_exportPathKey, exportPath);
}
Future<void> setExportInterval(int minutes) async {
exportIntervalMinutes = minutes;
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_exportIntervalKey, exportIntervalMinutes);
if (isTimerActive) {
stopPeriodicSync();
startPeriodicSync();
}
}
void startPeriodicSync() {
stopPeriodicSync();
syncTimer = Timer.periodic(
Duration(minutes: exportIntervalMinutes),
(_) => syncAndExport(),
);
isTimerActive = true;
statusMessage = 'Periodic sync started (every $exportIntervalMinutes minutes)';
}
void stopPeriodicSync() {
syncTimer?.cancel();
syncTimer = null;
isTimerActive = false;
statusMessage = 'Periodic sync stopped';
}
Future<void> syncAndExport() async {
if (isSyncing) {
statusMessage = 'Sync already in progress';
return;
}
try {
isSyncing = true;
statusMessage = 'Starting sync and export process...';
final wallets = walletListViewModel.wallets;
totalWallets = wallets.length;
currentWalletIndex = 0;
progress = 0;
final Map<String, dynamic> newExportData = {
'timestamp': DateTime.now().toIso8601String(),
'wallets': <Map<String, dynamic>>[],
};
for (final walletItem in wallets) {
currentWalletIndex++;
progress = ((currentWalletIndex / totalWallets) * 100).round();
statusMessage = 'Processing wallet ${currentWalletIndex}/$totalWallets: ${walletItem.name}';
try {
final wallet = await walletLoadingService.load(walletItem.type, walletItem.name);
lastError = '';
await _syncWallet(wallet);
final walletData = await _collectWalletData(wallet);
(newExportData['wallets'] as List).add({
...walletData,
if (lastError.isNotEmpty) 'error': lastError,
});
await wallet.close(shouldCleanup: true);
} catch (e) {
(newExportData['wallets'] as List).add({
'name': walletItem.name,
'type': walletItem.type.toString(),
'error': e.toString(),
});
statusMessage = 'Error processing ${walletItem.name}: $e';
}
}
exportData = newExportData;
await _saveJsonToFile(exportData);
lastExportTime = DateTime.now().toString();
statusMessage = 'Export completed successfully';
} catch (e) {
statusMessage = 'Export failed: $e';
} finally {
isSyncing = false;
}
}
Future<void> _syncWallet(WalletBase wallet) async {
final node = settingsStore.getCurrentNode(wallet.type);
await wallet.connectToNode(node: node);
await wallet.startSync();
int stuckTicks = 0;
bool isSynced = false;
int tick = 0;
while (!isSynced && stuckTicks < 30) {
tick++;
final syncStatus = wallet.syncStatus;
if (syncStatus is AttemptingSyncStatus ||
syncStatus is NotConnectedSyncStatus ||
syncStatus is ConnectedSyncStatus) {
statusMessage = 'Syncing ${wallet.name}: ${syncStatus.toString()} (stuckTicks: $stuckTicks/$tick)';
stuckTicks++;
} else {
stuckTicks = 0;
}
if (syncStatus is SyncedSyncStatus || syncStatus.progress() > 0.999) {
isSynced = true;
}
statusMessage = 'Syncing ${wallet.name}: ${(syncStatus.progress() * 100).round()}% (tick: $tick)';
if (tick > 30) {
lastError = 'Syncing ${wallet.name} takes $tick ticks, node: ${node.uriRaw}';
}
if (tick > 150) {
lastError = 'Syncing ${wallet.name} failed, timeout after 150 ticks, node: ${node.uriRaw}';
break;
}
await Future.delayed(const Duration(seconds: 1));
}
}
Future<Map<String, dynamic>> _collectWalletData(WalletBase wallet) async {
await wallet.walletAddresses.init();
await wallet.walletAddresses.updateAddressesInBox();
final Map<String, dynamic> walletData = {
'name': wallet.name,
'type': wallet.type.toString(),
'balance': Map.fromEntries(
wallet.balance.entries.map((entry) {
final currency = entry.key;
final balance = entry.value;
return MapEntry(currency.toString(), {
'formattedAvailableBalance': balance.formattedAvailableBalance,
'formattedAdditionalBalance': balance.formattedAdditionalBalance,
'formattedUnAvailableBalance': balance.formattedUnAvailableBalance,
'formattedSecondAvailableBalance': balance.formattedSecondAvailableBalance,
'formattedSecondAdditionalBalance': balance.formattedSecondAdditionalBalance,
'formattedFullAvailableBalance': balance.formattedFullAvailableBalance,
});
}),
),
'transactions': <Map<String, dynamic>>[],
'addresses': {
'primary': wallet.walletAddresses.address,
'all': wallet.walletAddresses.addressesMap,
}
};
final transactions = wallet.transactionHistory.transactions.values;
for (final tx in transactions) {
dynamic raw;
try {
raw = json.decode(json.encode(tx));
} catch (e) {
raw = {};
}
walletData['transactions'].add({
'id': tx.id,
'amount': tx.amount.toString(),
'fee': tx.fee?.toString(),
'date': tx.date.toIso8601String(),
'direction': tx.direction.toString(),
'confirmations': tx.confirmations,
'isPending': tx.isPending,
'raw': raw,
});
}
return walletData;
}
Future<void> _saveJsonToFile(Map<String, dynamic> data) async {
try {
final file = File(exportPath);
final jsonStr = JsonEncoder.withIndent(' ').convert(data);
await file.writeAsString(jsonStr);
statusMessage = 'Saved to: $exportPath';
} catch (e) {
statusMessage = 'Failed to save: $e';
throw e;
}
}
}