mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-26 06:08:40 +01:00
* fix(CW-1638): include transaction notes in history CSV export The `note` column of the transaction-history CSV export was always written as an empty string for transaction rows, so notes a user added to a transaction never appeared in the exported file. Index the `TransactionDescription` Hive box once per export and look each transaction's note up by the same keys the app writes them under (`<txHash>_<primaryAddress>`, falling back to the legacy bare `<txHash>`), then write it through the existing `escapeField` so multi-line notes and notes containing commas or quotes stay valid CSV. `CsvExportService` now takes the box, so it is resolved through `getIt` at both export entry points. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q5EdTdWZSvzbKKmd9MCEJh * perf(CW-1638): resolve the note-key address once per CSV export `_noteFor` read `balanceViewModel.wallet.walletAddresses.primaryAddress` once per transaction row. On Monero and Wownero that getter is not a field read: it calls `getAddress`, which issues an FFI `numSubaddresses` call plus four `ffiAddress` calls before its address cache is consulted. Since `buildCsvContent` runs on the main isolate, exporting a wallet with a long history meant thousands of synchronous FFI round-trips with no yield point. Every item in one export belongs to the same wallet, so the value is invariant across rows. Resolve it once in `buildCsvContent` and thread it through to `_noteFor`. An export holding no transaction rows now skips the read entirely. No change to the exported output. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q5EdTdWZSvzbKKmd9MCEJh * fix(CW-1638): neutralise spreadsheet formulas in CSV export The note column this PR adds is fed by TransactionDescription.transactionNote, which is not always authored by the user: a scanned bitcoin: URI carries tx_description / message straight through payment_request.dart into Output.note, and an address-resolution result writes Output.note too. Both are folded into the note persisted by SendViewModel. escapeField only quoted for , " and \n, so a note beginning with = + - @ TAB or CR reached the cell untouched and Excel / LibreOffice / Sheets evaluate it as a formula (=IMPORTDATA to exfiltrate a neighbouring cell, =HYPERLINK for phishing anchor text, =cmd|… for DDE). trade.memo and the provider-supplied trade/order strings had the same exposure already. Neutralise inside escapeField rather than at the two call sites so every column is covered and a future free-text column cannot forget it. Values starting with a trigger are prefixed with an apostrophe, the spreadsheet text marker, guarded by a plain-number test so negative amounts are left alone — formatFixed really can emit a leading '-'. Also quote on a bare \r, which previously passed through unquoted and splits rows in many CSV parsers. Tests cover the escaping rules, the neutralisation, a row built end to end, and the note column itself, including the two lookup keys and the payment-request injection path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * perf(CW-1638): bound the CSV note lookup to the exported rows Review feedback: the note index copied every entry in the shared transactionDescriptionBox, including notes belonging to other wallets, so its memory scaled with the box rather than with the export. Collect the keys the exported rows can ask for first — for each transaction row both `<txHash>_<primaryAddress>` and the bare `<txHash>` — then walk the box once and keep only matching notes. Lookups stay O(1) per row and first-wins/key-preference behaviour is unchanged; a box walk is skipped entirely when the export has no transaction rows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q5EdTdWZSvzbKKmd9MCEJh * refactor(CW-1638): simplify the CSV note lookup to a plain scan Notes are written into the TransactionDescriptions box with `add` (send_view_model.dart:1257,1263 and transaction_details_view_model.dart:299), so entries carry auto-incrementing integer keys and `box.get(descriptionKey)` cannot find them. Drop the prebuilt lookup map and the wanted-key set in favour of a per-row scan that mirrors the app's own read path in `TransactionDetailsViewModel.note`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q5EdTdWZSvzbKKmd9MCEJh * docs(CW-1638): drop the explanatory comments from the CSV note lookup [skip ci] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q5EdTdWZSvzbKKmd9MCEJh --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Omar <omarh.ismail1@gmail.com>
340 lines
9.8 KiB
Dart
340 lines
9.8 KiB
Dart
import 'dart:io';
|
||
|
||
import 'package:cake_wallet/entities/transaction_description.dart';
|
||
import 'package:cake_wallet/generated/i18n.dart';
|
||
import 'package:cake_wallet/src/screens/backup/backup_page.dart';
|
||
import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart';
|
||
import 'package:cake_wallet/utils/share_util.dart';
|
||
import 'package:cake_wallet/utils/show_bar.dart';
|
||
import 'package:cake_wallet/utils/show_pop_up.dart';
|
||
import 'package:cake_wallet/view_model/dashboard/action_list_item.dart';
|
||
import 'package:cake_wallet/view_model/dashboard/anonpay_transaction_list_item.dart';
|
||
import 'package:cake_wallet/view_model/dashboard/date_section_item.dart';
|
||
import 'package:cake_wallet/view_model/dashboard/order_list_item.dart';
|
||
import 'package:cake_wallet/view_model/dashboard/payjoin_transaction_list_item.dart';
|
||
import 'package:cake_wallet/view_model/dashboard/trade_list_item.dart';
|
||
import 'package:cake_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);
|
||
}
|
||
}
|