diff --git a/.github/workflows/pr_test_build_android.yml b/.github/workflows/pr_test_build_android.yml index 8f6139747..e3dd61875 100644 --- a/.github/workflows/pr_test_build_android.yml +++ b/.github/workflows/pr_test_build_android.yml @@ -1,6 +1,6 @@ name: Cake Wallet Android -on: [pull_request] +on: [push] defaults: run: diff --git a/cw_solana/lib/solana_transaction_info.dart b/cw_solana/lib/solana_transaction_info.dart index f51c55dad..fc330e1f5 100644 --- a/cw_solana/lib/solana_transaction_info.dart +++ b/cw_solana/lib/solana_transaction_info.dart @@ -26,6 +26,9 @@ class SolanaTransactionInfo extends TransactionInfo { final double txFee; final TransactionDirection direction; + @override + int get confirmations => 0; + String? _fiatAmount; @override diff --git a/cw_tron/lib/tron_transaction_info.dart b/cw_tron/lib/tron_transaction_info.dart index 28c704d20..3cf7c7c8f 100644 --- a/cw_tron/lib/tron_transaction_info.dart +++ b/cw_tron/lib/tron_transaction_info.dart @@ -28,6 +28,9 @@ class TronTransactionInfo extends TransactionInfo { final int? txFee; final TransactionDirection direction; + @override + int get confirmations => 0; + factory TronTransactionInfo.fromJson(Map data) { return TronTransactionInfo( id: data['id'] as String, diff --git a/lib/di.dart b/lib/di.dart index dcd0165ec..9fb46aa1b 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -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 setup({ getIt.registerFactory(() => DevMoneroBackgroundSyncPage(getIt.get())); getIt.registerFactory(() => DevMoneroCallProfilerPage()); + getIt.registerFactory(() => WalletSyncExporterPage(getIt.get())); + getIt.registerFactory(() => WalletSyncExporter()); + _isSetupFinished = true; } diff --git a/lib/router.dart b/lib/router.dart index e69f75f21..21ea3ea81 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -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 createRoute(RouteSettings settings) { builder: (_) => getIt.get(), ); + case Routes.devWalletSyncExporter: + return MaterialPageRoute( + builder: (_) => getIt.get(), + ); + default: return MaterialPageRoute( builder: (_) => Scaffold( diff --git a/lib/routes.dart b/lib/routes.dart index f7f3e2f6f..05c5ec5ce 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -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'; diff --git a/lib/src/screens/dev/wallet_sync_exporter_page.dart b/lib/src/screens/dev/wallet_sync_exporter_page.dart new file mode 100644 index 000000000..5a715c0cd --- /dev/null +++ b/lib/src/screens/dev/wallet_sync_exporter_page.dart @@ -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(); + } +} \ No newline at end of file diff --git a/lib/src/screens/settings/other_settings_page.dart b/lib/src/screens/settings/other_settings_page.dart index f841e2998..206810565 100644 --- a/lib/src/screens/settings/other_settings_page.dart +++ b/lib/src/screens/settings/other_settings_page.dart @@ -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)), diff --git a/lib/view_model/dev/wallet_sync_exporter.dart b/lib/view_model/dev/wallet_sync_exporter.dart new file mode 100644 index 000000000..3ef55d02e --- /dev/null +++ b/lib/view_model/dev/wallet_sync_exporter.dart @@ -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(); + final walletListViewModel = getIt.get(); + final settingsStore = getIt.get(); + final keyService = getIt.get(); + + @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 exportData = {}; + + @observable + String lastError = ''; + + Future initialize() async { + final prefs = await SharedPreferences.getInstance(); + exportPath = prefs.getString(_exportPathKey) ?? await _getDefaultExportPath(); + exportIntervalMinutes = prefs.getInt(_exportIntervalKey) ?? 30; + } + + Future _getDefaultExportPath() async { + final directory = await getApplicationDocumentsDirectory(); + return '${directory.path}/wallet_export.json'; + } + + Future setExportPath(String path) async { + exportPath = path; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_exportPathKey, exportPath); + } + + Future 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 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 newExportData = { + 'timestamp': DateTime.now().toIso8601String(), + 'wallets': >[], + }; + + 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 _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> _collectWalletData(WalletBase wallet) async { + await wallet.walletAddresses.init(); + await wallet.walletAddresses.updateAddressesInBox(); + final Map 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': >[], + '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 _saveJsonToFile(Map 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; + } + } +} \ No newline at end of file