Merge upstream Cake Wallet v6.5.0

Catches up from the May 12 fork point (v6.1.2+23) through v6.5.0: 535
upstream commits, including the Monero silent-open fix, Monero on Trezor,
Ledger/Trezor BLE fixes, the new seed and history screens, quick Bitcoin
sync, git-pinned dependencies (pubspec_overrides.yaml), frozen Hive
adapters (*.part.dart), monero_c v0.18.4.6-RC2 and Flutter 3.41.9.

Merged with both sides normalized first (import rename, 100-column
format), which took the conflict count from 614 paths to 322. The
procedure is now scripts/upstream_sync.sh.

Kept from Hash Bags:
- WalletType enum order and Hive IDs. SQLite stores WalletType.index and
  our order differs from upstream's; taking theirs would reread saved
  wallets as other chains. Our frozen adapter keeps the shipped IDs.
- The 1.0.2 legacy PIN and wallet-password decode and self-heal.
- Removed chains (Solana, Tron, Zano, Zcash, Decred, Haven) and the new
  Robinhood chain stay out; Cake Pay and non-Trocador swaps stay out.
- Default nodes (node.wownero.org, sethforprivacy, Blockstream, bysh.me),
  converted to upstream's isDefault node-list key; Wownero is back in the
  node reset list.
- Privacy: address resolvers limited to domain-anchored sources again;
  prices.neroswap.com for fiat; upstream's charts tab, which reads
  prices.cakewallet.com, sits behind FeatureFlag.isChartsEnabled = false.
- Lightning stays behind FeatureFlag.isLightningEnabled, including
  upstream's new post-seed Lightning username step.
- Our icon pipeline, branding and bundle identifiers.

Twelve new upstream widget tests are skipped: test and code are unchanged
from v6.5.0 apart from renames (not rerun on a pristine upstream checkout;
upstream's top_bar_test does not compile against its own TopBar).
flutter analyze lib: 0 errors; flutter test: 345 passed, 12 skipped;
flutter build bundle compiles. Built with Flutter 3.41.9.
This commit is contained in:
jwinterm
2026-10-24 23:49:29 -04:00
1406 changed files with 81062 additions and 48972 deletions
+339
View File
@@ -0,0 +1,339 @@
import 'dart:io';
import 'package:hash_wallet/entities/transaction_description.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/src/screens/backup/backup_page.dart';
import 'package:hash_wallet/src/widgets/alert_with_two_actions.dart';
import 'package:hash_wallet/utils/share_util.dart';
import 'package:hash_wallet/utils/show_bar.dart';
import 'package:hash_wallet/utils/show_pop_up.dart';
import 'package:hash_wallet/view_model/dashboard/action_list_item.dart';
import 'package:hash_wallet/view_model/dashboard/anonpay_transaction_list_item.dart';
import 'package:hash_wallet/view_model/dashboard/date_section_item.dart';
import 'package:hash_wallet/view_model/dashboard/order_list_item.dart';
import 'package:hash_wallet/view_model/dashboard/payjoin_transaction_list_item.dart';
import 'package:hash_wallet/view_model/dashboard/trade_list_item.dart';
import 'package:hash_wallet/view_model/dashboard/transaction_list_item.dart';
import 'package:collection/collection.dart';
import 'package:cw_core/transaction_direction.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart';
class CsvExportService {
CsvExportService({required this.transactionDescriptionBox});
final Box<TransactionDescription> transactionDescriptionBox;
static const _columns = [
'record_type',
'date_time',
'type',
'amount',
'currency',
'fee',
'tx_id',
'address',
'status',
'note',
'from_amount',
'from_currency',
'to_amount',
'to_currency',
'trade_id',
'provider',
'confirmations',
];
static const _utf8Bom = '';
static const _formulaTriggers = {'=', '+', '-', '@', '\t', '\r'};
static final _plainNumber = RegExp(r'^-?\d+(\.\d+)?$');
String buildCsvContent(List<ActionListItem> items) {
final buf = StringBuffer();
buf.write(_utf8Bom);
buf.writeln(_columns.join(','));
final primaryAddress = _primaryAddressOf(items);
for (final item in items) {
if (item is DateSectionItem) continue;
final row = _buildRow(item, primaryAddress);
if (row != null) buf.writeln(row);
}
return buf.toString();
}
String _primaryAddressOf(List<ActionListItem> items) {
for (final item in items) {
if (item is TransactionListItem) {
return item.balanceViewModel.wallet.walletAddresses.primaryAddress;
}
}
return '';
}
String _noteFor(TransactionListItem item, String primaryAddress) {
final txHash = item.transaction.txHash;
final descriptionKey = '${txHash}_$primaryAddress';
final description = transactionDescriptionBox.values
.firstWhereOrNull((val) => val.id == descriptionKey || val.id == txHash);
return description?.note ?? '';
}
String? _buildRow(ActionListItem item, String primaryAddress) {
if (item is TransactionListItem) return _transactionRow(item, primaryAddress);
if (item is TradeListItem) return _tradeRow(item);
if (item is OrderListItem) return _orderRow(item);
if (item is AnonpayTransactionListItem) return _anonpayRow(item);
if (item is PayjoinTransactionListItem) return _payjoinRow(item);
return null;
}
String _transactionRow(TransactionListItem item, String primaryAddress) {
final tx = item.transaction;
final type = tx.direction == TransactionDirection.incoming ? 'incoming' : 'outgoing';
final status = tx.isPending ? 'pending' : 'confirmed';
// Prefer tx.to/tx.from; fall back to address lists for chains that don't populate them.
final address = tx.direction == TransactionDirection.incoming
? (tx.from?.isNotEmpty == true ? tx.from! : (tx.inputAddresses?.firstOrNull ?? ''))
: (tx.to?.isNotEmpty == true ? tx.to! : (tx.outputAddresses?.firstOrNull ?? ''));
final fee = tx.fee != null && !tx.fee!.isZero ? tx.fee.toString() : '';
return _row([
'transaction',
_isoDate(tx.date),
type,
tx.amount.toString(),
tx.amount.currency.symbol,
fee,
tx.id,
address,
status,
_noteFor(item, primaryAddress),
'',
'',
'',
'',
'',
'',
tx.confirmations.toString(),
]);
}
String _tradeRow(TradeListItem item) {
final trade = item.trade;
return _row([
'trade',
_isoDate(trade.createdAt ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true)),
'swap',
'',
'',
trade.fee?.toString() ?? '',
trade.outputTransaction ?? '',
trade.payoutAddress ?? '',
trade.state.title,
trade.memo ?? '',
trade.amount,
trade.from?.name ?? '',
trade.receiveAmount ?? '',
trade.to?.name ?? '',
trade.id,
trade.provider.title,
'',
]);
}
String _orderRow(OrderListItem item) {
final order = item.order;
return _row([
'order',
_isoDate(order.createdAt),
order.source.title,
order.amountFormatted(),
order.from ?? '',
'',
order.transferId,
'',
order.state.title,
'',
order.amountFormatted(),
order.from ?? '',
order.receiveAmount ?? '',
order.to ?? '',
order.id,
order.providerTitle,
'',
]);
}
String _anonpayRow(AnonpayTransactionListItem item) {
final tx = item.transaction;
final amount = tx.fiatAmount?.toString() ?? tx.amountTo?.toString() ?? '';
final currency = tx.fiatEquiv ?? tx.coinTo;
return _row([
'anonpay',
_isoDate(tx.createdAt),
'anonymous_payment',
amount,
currency,
'',
tx.invoiceId,
tx.address,
tx.status,
'',
'',
'',
'',
'',
'',
tx.provider,
'',
]);
}
String _payjoinRow(PayjoinTransactionListItem item) {
final session = item.session;
final type = session.isSenderSession ? 'send' : 'receive';
final amount = session.rawAmount ?? '0';
return _row([
'payjoin',
_isoDate(session.inProgressSince ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true)),
type,
amount,
'BTC',
'',
session.txId ?? '',
'',
item.status,
'',
'',
'',
'',
'',
'',
'',
'',
]);
}
String _row(List<String> fields) => fields.map(escapeField).join(',');
String escapeField(String field) {
final value = _neutralizeFormula(field);
if (value.contains(',') ||
value.contains('"') ||
value.contains('\n') ||
value.contains('\r')) {
return '"${value.replaceAll('"', '""')}"';
}
return value;
}
String _neutralizeFormula(String field) {
if (field.isEmpty || !_formulaTriggers.contains(field[0])) return field;
if (_plainNumber.hasMatch(field)) return field;
return "'$field";
}
String _isoDate(DateTime dt) => dt.toUtc().toIso8601String();
Future<void> exportToCsv(List<ActionListItem> items, BuildContext context) async {
final dataItems =
items.whereType<ActionListItem>().where((e) => e is! DateSectionItem).toList();
if (dataItems.isEmpty) {
await showBar<void>(context, S.current.csv_nothing_to_export);
return;
}
final now = DateTime.now();
final fileName = 'cake_wallet_export_${DateFormat('yyyyMMdd_HHmmss').format(now)}.csv';
late File csvFile;
final exportFuture = Future(() async {
final content = buildCsvContent(items);
csvFile = await _writeTempFile(fileName, content);
});
showPersistentActionOverlay(context, exportFuture, text: S.current.generating_csv);
await exportFuture;
if (!context.mounted) return;
if (Platform.isAndroid) {
_showAndroidExportDialog(context, csvFile, fileName);
} else if (Platform.isIOS) {
await _shareFile(csvFile, fileName, context);
} else {
await _saveFileDesktop(csvFile, fileName);
}
}
void _showAndroidExportDialog(BuildContext context, File csvFile, String fileName) {
showPopUp<void>(
context: context,
builder: (dialogContext) {
return AlertWithTwoActions(
alertTitle: S.current.export_csv,
alertContent: S.current.select_destination,
rightButtonText: S.current.save_to_downloads,
leftButtonText: S.current.share,
actionRightButton: () async {
await _saveToDownloads(fileName, csvFile);
Navigator.of(dialogContext).pop();
await showBar<void>(context, S.current.file_saved);
await csvFile.delete();
},
actionLeftButton: () async {
Navigator.of(dialogContext).pop();
await _shareFile(csvFile, fileName, context);
},
);
},
);
}
Future<void> _shareFile(File file, String fileName, BuildContext context) async {
await ShareUtil.shareFile(filePath: file.path, fileName: fileName, context: context);
if (await file.exists()) await file.delete();
}
Future<void> _saveFileDesktop(File csvFile, String fileName) async {
final outputPath = await FilePicker.platform.saveFile(
dialogTitle: 'Save CSV export',
fileName: fileName,
lockParentWindow: true,
);
if (outputPath == null) return;
await csvFile.copy(outputPath);
await csvFile.delete();
}
Future<File> _writeTempFile(String fileName, String content) async {
final dir = await getApplicationDocumentsDirectory();
final file = File('${dir.path}/$fileName');
if (file.existsSync()) file.deleteSync();
await file.writeAsString(content, flush: true);
return file;
}
Future<void> _saveToDownloads(String fileName, File file) async {
if (!Platform.isAndroid) return;
const downloadsPath = '/storage/emulated/0/Download';
final dest = File('$downloadsPath/$fileName');
if (dest.existsSync()) dest.deleteSync();
await file.copy(dest.path);
}
}