diff --git a/App.tsx b/App.tsx index 6217f164c7..1d27c79c33 100644 --- a/App.tsx +++ b/App.tsx @@ -135,6 +135,7 @@ import AddContact from './views/Settings/AddContact'; import ContactDetails from './views/ContactDetails'; import PendingHTLCs from './views/PendingHTLCs'; +import ActivityExportOptions from './views/ActivityExportOptions'; // POS import Order from './views/Order'; @@ -938,6 +939,12 @@ export default class App extends React.PureComponent { Sweepremoteclosed } /> + diff --git a/locales/en.json b/locales/en.json index 5259a69f4b..2caf85ee4b 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1017,8 +1017,22 @@ "views.ActivityFilter.ampInvoices": "AMP invoices", "views.ActivityToCsv.title": "Download Activity", "views.ActivityToCsv.csvDownloaded": "CSV file has been downloaded", + "views.ActivityToCsv.csvDownloadFailed": "Failed to download CSV file", "views.ActivityToCsv.textInputPlaceholder": "File name (optional)", "views.ActivityToCsv.downloadButton": "Download CSV", + "views.ActivityExport.title": "Export CSV Data", + "views.ActivityExport.exportPayments": "Export Payments", + "views.ActivityExport.exportInvoices": "Export Invoices", + "views.ActivityExport.exportTransactions": "Export Transactions", + "views.ActivityExport.fromDate": "From Date (Start Date)", + "views.ActivityExport.toDate": "To Date (End Date)", + "views.ActivityExport.dateRange": "Select Date Range:", + "views.ActivityExport.downloadCompleteData": "Download Complete Data", + "views.ActivityExport.explainerAndroid": "Downloaded CSV files can be found in Files > Downloads.", + "views.ActivityExport.explaineriOS": "Downloaded CSV files can be found in the Files app under the ZEUS folder.", + "views.ActivityExport.noDataAvailableForSelection": "No activity data is available for the selected type.", + "views.ActivityExport.noDataForSelectedDates": "No records found in the selected date range. Try a different date.", + "views.ActivityExport.noValidDataForDownload": "There is no valid data available for download.", "views.Routing.RoutingEvent.sourceChannel": "Source Channel", "views.Routing.RoutingEvent.destinationChannel": "Destination Channel", "views.Olympians.title": "Olympians", diff --git a/utils/ActivityCsvUtils.test.ts b/utils/ActivityCsvUtils.test.ts new file mode 100644 index 0000000000..323f8f7aa5 --- /dev/null +++ b/utils/ActivityCsvUtils.test.ts @@ -0,0 +1,200 @@ +import { + getFormattedDateTime, + convertActivityToCsv, + saveCsvFile, + CSV_KEYS +} from '.././utils/ActivityCsvUtils'; +import RNFS from 'react-native-fs'; +import { Platform } from 'react-native'; + +jest.mock('react-native-fs', () => ({ + DownloadDirectoryPath: '/mock/download/path', + DocumentDirectoryPath: '/mock/document/path', + writeFile: jest.fn() +})); + +jest.mock('react-native', () => ({ + Platform: { OS: 'android' } +})); + +describe('activityCsvUtils', () => { + describe('getFormattedDateTime', () => { + it('returns a properly formatted timestamp', () => { + const result = getFormattedDateTime(); + expect(result).toMatch(/^\d{8}_\d{6}$/); // Example: 20250212_140719 + }); + }); + + describe('convertActivityToCsv', () => { + it('correctly formats Invoice CSV data', async () => { + const mockInvoices = [ + { + getAmount: 1500, + getPaymentRequest: 'inv_req123', + getRHash: 'hash_inv1', + getMemo: 'Test Memo', + getNote: 'Test Note', + getCreationDate: '2024-02-10', + formattedTimeUntilExpiry: '30 min' + }, + { + getAmount: 3000, + getPaymentRequest: 'inv_req456', + getRHash: 'hash_inv2', + getMemo: '', + getNote: '', + getCreationDate: '2024-02-11', + formattedTimeUntilExpiry: '1 hour' + } + ]; + + const result = await convertActivityToCsv( + mockInvoices, + CSV_KEYS.invoice + ); + expect(result).toContain( + '"1500","inv_req123","hash_inv1","Test Memo","Test Note","2024-02-10","30 min"' + ); + expect(result).toContain( + '"3000","inv_req456","hash_inv2","","","2024-02-11","1 hour"' + ); + }); + + it('correctly formats Payment CSV data', async () => { + const mockPayments = [ + { + getDestination: 'dest123', + getPaymentRequest: 'pay_req123', + paymentHash: 'hash_pay1', + getAmount: 800, + getMemo: 'Payment Memo', + getNote: 'Payment Note', + getDate: '2024-02-09' + }, + { + getDestination: 'dest456', + getPaymentRequest: 'pay_req456', + paymentHash: 'hash_pay2', + getAmount: 1600, + getMemo: '', + getNote: '', + getDate: '2024-02-08' + } + ]; + + const result = await convertActivityToCsv( + mockPayments, + CSV_KEYS.payment + ); + expect(result).toContain( + '"dest123","pay_req123","hash_pay1","800","Payment Memo","Payment Note","2024-02-09"' + ); + expect(result).toContain( + '"dest456","pay_req456","hash_pay2","1600","","","2024-02-08"' + ); + }); + + it('correctly formats Transaction CSV data', async () => { + const mockTransactions = [ + { + tx: 'txhash1', + getAmount: 2000, + getFee: 50, + getNote: 'Tx Note1', + getDate: '2024-02-07' + }, + { + tx: 'txhash2', + getAmount: 5000, + getFee: 100, + getNote: '', + getDate: '2024-02-06' + } + ]; + + const result = await convertActivityToCsv( + mockTransactions, + CSV_KEYS.transaction + ); + expect(result).toContain( + '"txhash1","2000","50","Tx Note1","2024-02-07"' + ); + expect(result).toContain('"txhash2","5000","100","","2024-02-06"'); + }); + + it('handles missing fields for Invoice CSV', async () => { + const mockInvoices = [{ getAmount: 1500 }]; + const result = await convertActivityToCsv( + mockInvoices, + CSV_KEYS.invoice + ); + expect(result).toContain('"1500","","","","","",""'); + }); + + it('handles missing fields for Payment CSV', async () => { + const mockPayments = [{ getDestination: 'dest123' }]; + const result = await convertActivityToCsv( + mockPayments, + CSV_KEYS.payment + ); + expect(result).toContain('"dest123","","","","","",""'); + }); + + it('handles missing fields for Transaction CSV', async () => { + const mockTransactions = [{ tx: 'txhash1', getAmount: 2000 }]; + const result = await convertActivityToCsv( + mockTransactions, + CSV_KEYS.transaction + ); + expect(result).toContain('"txhash1","2000","","",""'); + }); + }); + + describe('saveCsvFile', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('writes the CSV file to the correct path on Android', async () => { + (Platform.OS as any) = 'android'; + (RNFS.writeFile as jest.Mock).mockResolvedValue(undefined); + + await saveCsvFile('test.csv', 'mock,csv,data'); + + expect(RNFS.writeFile).toHaveBeenCalledWith( + '/mock/download/path/test.csv', + 'mock,csv,data', + 'utf8' + ); + }); + + it('writes the CSV file to the correct path on iOS', async () => { + (Platform.OS as any) = 'ios'; + (RNFS.writeFile as jest.Mock).mockResolvedValue(undefined); + + await saveCsvFile('test.csv', 'mock,csv,data'); + + expect(RNFS.writeFile).toHaveBeenCalledWith( + '/mock/document/path/test.csv', + 'mock,csv,data', + 'utf8' + ); + }); + + it('throws an error when file writing fails (but suppresses console error)', async () => { + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + (RNFS.writeFile as jest.Mock).mockRejectedValue( + new Error('File write failed') + ); + + await expect( + saveCsvFile('test.csv', 'mock,csv,data') + ).rejects.toThrow('File write failed'); + + consoleErrorSpy.mockRestore(); + }); + }); +}); diff --git a/utils/ActivityCsvUtils.ts b/utils/ActivityCsvUtils.ts new file mode 100644 index 0000000000..18df30b754 --- /dev/null +++ b/utils/ActivityCsvUtils.ts @@ -0,0 +1,83 @@ +import RNFS from 'react-native-fs'; +import { Platform } from 'react-native'; + +// Keys for CSV export. +export const CSV_KEYS = { + invoice: [ + { label: 'Amount Paid (sat)', value: 'getAmount' }, + { label: 'Payment Request', value: 'getPaymentRequest' }, + { label: 'Payment Hash', value: 'getRHash' }, + { label: 'Memo', value: 'getMemo' }, + { label: 'Note', value: 'getNote' }, + { label: 'Creation Date', value: 'getCreationDate' }, + { label: 'Expiry', value: 'formattedTimeUntilExpiry' } + ], + payment: [ + { label: 'Destination', value: 'getDestination' }, + { label: 'Payment Request', value: 'getPaymentRequest' }, + { label: 'Payment Hash', value: 'paymentHash' }, + { label: 'Amount Paid (sat)', value: 'getAmount' }, + { label: 'Memo', value: 'getMemo' }, + { label: 'Note', value: 'getNote' }, + { label: 'Creation Date', value: 'getDate' } + ], + transaction: [ + { label: 'Transaction Hash', value: 'tx' }, + { label: 'Amount (sat)', value: 'getAmount' }, + { label: 'Total Fees (sat)', value: 'getFee' }, + { label: 'Note', value: 'getNote' }, + { label: 'Timestamp', value: 'getDate' } + ] +}; + +// Generates a formatted timestamp string for file naming. +export const getFormattedDateTime = (): string => { + const now = new Date(); + const year = now.getFullYear(); + const month = (now.getMonth() + 1).toString().padStart(2, '0'); + const day = now.getDate().toString().padStart(2, '0'); + const hours = now.getHours().toString().padStart(2, '0'); + const minutes = now.getMinutes().toString().padStart(2, '0'); + const seconds = now.getSeconds().toString().padStart(2, '0'); + return `${year}${month}${day}_${hours}${minutes}${seconds}`; +}; + +// Converts activity data into a CSV string. +export const convertActivityToCsv = async ( + data: Array, + keysToInclude: Array<{ label: string; value: string }> +): Promise => { + if (!data || data.length === 0) return ''; + + try { + const header = keysToInclude.map((field) => field.label).join(','); + const rows = data + .map((item) => + keysToInclude + .map((field) => `"${item[field.value] || ''}"`) + .join(',') + ) + .join('\n'); + + return `${header}\n${rows}`; + } catch (err) { + console.error(err); + return ''; + } +}; + +//Saves CSV file to the device. +export const saveCsvFile = async (fileName: string, csvData: string) => { + try { + const filePath = + Platform.OS === 'android' + ? `${RNFS.DownloadDirectoryPath}/${fileName}` + : `${RNFS.DocumentDirectoryPath}/${fileName}`; + + console.log(`Saving file to: ${filePath}`); + await RNFS.writeFile(filePath, csvData, 'utf8'); + } catch (err) { + console.error('Failed to save CSV file:', err); + throw err; + } +}; diff --git a/views/Activity/ActivityToCsv.tsx b/views/Activity/ActivityToCsv.tsx index 575b0dcb47..a5893e065c 100644 --- a/views/Activity/ActivityToCsv.tsx +++ b/views/Activity/ActivityToCsv.tsx @@ -1,15 +1,23 @@ import React, { useState } from 'react'; -import { StyleSheet, View, Platform, Alert, Modal } from 'react-native'; -import RNFS from 'react-native-fs'; -import Button from '../../components/Button'; -import TextInput from '../../components/TextInput'; -import { localeString } from '../../utils/LocaleUtils'; -import { themeColor } from '../../utils/ThemeUtils'; +import { StyleSheet, View, Alert, Modal } from 'react-native'; + import Invoice from '../../models/Invoice'; import Payment from '../../models/Payment'; import Transaction from '../../models/Transaction'; + +import Button from '../../components/Button'; +import TextInput from '../../components/TextInput'; import LoadingIndicator from '../../components/LoadingIndicator'; +import { localeString } from '../../utils/LocaleUtils'; +import { themeColor } from '../../utils/ThemeUtils'; +import { + getFormattedDateTime, + convertActivityToCsv, + saveCsvFile, + CSV_KEYS +} from '../../utils/ActivityCsvUtils'; + interface ActivityProps { filteredActivity: Array; isVisible: boolean; @@ -29,87 +37,22 @@ const ActivityToCsv: React.FC = ({ closeModal(); }; - const getFormattedDateTime = () => { - const now = new Date(); - const year = now.getFullYear(); - const month = (now.getMonth() + 1).toString().padStart(2, '0'); - const day = now.getDate().toString().padStart(2, '0'); - const hours = now.getHours().toString().padStart(2, '0'); - const minutes = now.getMinutes().toString().padStart(2, '0'); - const seconds = now.getSeconds().toString().padStart(2, '0'); - - return `${year}${month}${day}_${hours}${minutes}${seconds}`; - }; - - const convertActivityToCsv = async ( - data: Array, - keysToInclude: Array - ) => { - if (!data || data.length === 0) { - return ''; - } - - try { - const header = keysToInclude.map((field) => field.label).join(','); - const rows = data - ?.map((item: any) => - keysToInclude - .map((field) => `"${item[field.value]}"` || '') - .join(',') - ) - .join('\n'); - - return `${header}\n${rows}`; - } catch (err) { - console.error(err); - return ''; - } - }; - const downloadCsv = async () => { setIsLoading(true); setTimeout(async () => { - const invoiceKeys = [ - { label: 'Amount Paid (sat)', value: 'getAmount' }, - { label: 'Payment Request', value: 'getPaymentRequest' }, - { label: 'Payment Hash', value: 'getRHash' }, - { label: 'Memo', value: 'getMemo' }, - { label: 'Note', value: 'getNote' }, - { label: 'Creation Date', value: 'getCreationDate' }, - { label: 'Expiry', value: 'formattedTimeUntilExpiry' } - ]; - - const paymentKeys = [ - { label: 'Destination', value: 'getDestination' }, - { label: 'Payment Request', value: 'getPaymentRequest' }, - { label: 'Payment Hash', value: 'paymentHash' }, - { label: 'Amount Paid (sat)', value: 'getAmount' }, - { label: 'Memo', value: 'getMemo' }, - { label: 'Note', value: 'getNote' }, - { label: 'Creation Date', value: 'getDate' } - ]; - - const transactionKeys = [ - { label: 'Transaction Hash', value: 'tx' }, - { label: 'Amount (sat)', value: 'getAmount' }, - { label: 'Total Fees (sat)', value: 'getFee' }, - { label: 'Note', value: 'getNote' }, - { label: 'Timestamp', value: 'getDate' } - ]; - const invoiceCsv = await convertActivityToCsv( filteredActivity.filter((item: any) => item instanceof Invoice), - invoiceKeys + CSV_KEYS.invoice ); const paymentCsv = await convertActivityToCsv( filteredActivity.filter((item: any) => item instanceof Payment), - paymentKeys + CSV_KEYS.payment ); const transactionCsv = await convertActivityToCsv( filteredActivity.filter( (item: any) => item instanceof Transaction ), - transactionKeys + CSV_KEYS.transaction ); if (!invoiceCsv && !paymentCsv && !transactionCsv) { @@ -120,43 +63,21 @@ const ActivityToCsv: React.FC = ({ try { const dateTime = getFormattedDateTime(); const baseFileName = customFileName || `zeus_${dateTime}`; - const invoiceFileName = `${baseFileName}_ln_invoices.csv`; - const paymentFileName = `${baseFileName}_ln_payments.csv`; - const transactionFileName = `${baseFileName}_onchain.csv`; - - const invoiceFilePath = - Platform.OS === 'android' - ? `${RNFS.DownloadDirectoryPath}/${invoiceFileName}` - : `${RNFS.DocumentDirectoryPath}/${invoiceFileName}`; - - const paymentFilePath = - Platform.OS === 'android' - ? `${RNFS.DownloadDirectoryPath}/${paymentFileName}` - : `${RNFS.DocumentDirectoryPath}/${paymentFileName}`; - - const transactionFilePath = - Platform.OS === 'android' - ? `${RNFS.DownloadDirectoryPath}/${transactionFileName}` - : `${RNFS.DocumentDirectoryPath}/${transactionFileName}`; - - if (invoiceCsv) { - console.log('invoiceFilePath', invoiceFilePath); - await RNFS.writeFile(invoiceFilePath, invoiceCsv, 'utf8'); - } - - if (paymentCsv) { - console.log('paymentFilePath', paymentFilePath); - await RNFS.writeFile(paymentFilePath, paymentCsv, 'utf8'); - } - - if (transactionCsv) { - console.log('transactionFilePath', transactionFilePath); - await RNFS.writeFile( - transactionFilePath, - transactionCsv, - 'utf8' + if (invoiceCsv) + await saveCsvFile( + `${baseFileName}_ln_invoices.csv`, + invoiceCsv + ); + if (paymentCsv) + await saveCsvFile( + `${baseFileName}_ln_payments.csv`, + paymentCsv + ); + if (transactionCsv) + await saveCsvFile( + `${baseFileName}_onchain.csv`, + transactionCsv ); - } Alert.alert( localeString('general.success'), diff --git a/views/ActivityExportOptions.tsx b/views/ActivityExportOptions.tsx new file mode 100644 index 0000000000..46b9ba3a77 --- /dev/null +++ b/views/ActivityExportOptions.tsx @@ -0,0 +1,616 @@ +import * as React from 'react'; +import { + View, + TouchableOpacity, + StyleSheet, + Alert, + ScrollView, + Modal, + Platform +} from 'react-native'; +import Icon from 'react-native-vector-icons/Feather'; +import { inject, observer } from 'mobx-react'; + +import Header from '../components/Header'; +import Button from '../components/Button'; +import TextInput from '../components/TextInput'; +import Text from '../components/Text'; +import LoadingIndicator from '../components/LoadingIndicator'; + +import { localeString } from '../utils/LocaleUtils'; +import { themeColor } from '../utils/ThemeUtils'; +import { + getFormattedDateTime, + convertActivityToCsv, + saveCsvFile, + CSV_KEYS +} from '.././utils/ActivityCsvUtils'; + +import ActivityStore from '../stores/ActivityStore'; +import SettingsStore from '../stores/SettingsStore'; + +import Invoice from '../models/Invoice'; +import Payment from '../models/Payment'; +import Transaction from '../models/Transaction'; +import DatePicker from 'react-native-date-picker'; +import { CheckBox } from 'react-native-elements'; + +interface ActivityExportOptionsProps { + navigation: any; + ActivityStore: ActivityStore; + SettingsStore: SettingsStore; +} + +interface ActivityExportOptionsState { + isCsvLoading: boolean; + isActivityFetching: boolean; + filteredActivity: any; + isModalVisible: boolean; + showInfoModal: boolean; + customFileName: string; + fromDate: any; + toDate: any; + exportType: any; + downloadCompleteData: boolean; +} + +@inject('ActivityStore', 'SettingsStore') +@observer +export default class ActivityExportOptions extends React.Component< + ActivityExportOptionsProps, + ActivityExportOptionsState +> { + constructor(props: ActivityExportOptionsProps) { + super(props); + this.state = { + isCsvLoading: false, + isActivityFetching: true, + filteredActivity: [], + isModalVisible: false, + showInfoModal: false, + customFileName: '', + fromDate: null, + toDate: null, + exportType: '', + downloadCompleteData: false + }; + } + + componentDidMount() { + this.fetchAndFilterActivity(); + } + + fetchAndFilterActivity = async () => { + const { SettingsStore, ActivityStore } = this.props; + const { locale } = SettingsStore.settings; + + try { + // Call getActivityAndFilter to fetch and filter activity data + await ActivityStore.getActivityAndFilter(locale); + + // Update filteredActivity in state + this.setState({ + filteredActivity: ActivityStore.filteredActivity, + isActivityFetching: false + }); + } catch (err) { + console.error('Failed to fetch activity data:', err); + this.setState({ isActivityFetching: false }); + Alert.alert(localeString('general.error')); + } + }; + + filterDataByDate = (data: any) => { + const { fromDate, toDate, downloadCompleteData } = this.state; + if (!fromDate && !toDate) return data; + + if (downloadCompleteData) return data; + + return data.filter((item: any) => { + const itemDate = new Date(item.getDate); + if (isNaN(itemDate.getTime())) return false; + if (fromDate && itemDate < fromDate) return false; + if (toDate && itemDate > toDate) return false; + return true; + }); + }; + + downloadCsv = async (type: 'invoice' | 'payment' | 'transaction') => { + this.setState({ isCsvLoading: true }, async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + let { filteredActivity } = this.state; + + if (!filteredActivity || filteredActivity.length === 0) { + Alert.alert( + localeString( + 'views.ActivityExport.noDataAvailableForSelection' + ) + ); + this.setState({ isCsvLoading: false }); + return; + } + + let filteredData: any; + + filteredData = filteredActivity.filter((item: any) => { + if (type === 'invoice') return item instanceof Invoice; + if (type === 'payment') return item instanceof Payment; + if (type === 'transaction') return item instanceof Transaction; + return false; + }); + filteredData = this.filterDataByDate(filteredData); + + if (!filteredData || filteredData.length === 0) { + Alert.alert( + localeString('views.ActivityExport.noDataForSelectedDates') + ); + this.setState({ isCsvLoading: false }); + return; + } + + const csvData = await convertActivityToCsv( + filteredData, + CSV_KEYS[type] + ); + + if (!csvData) { + Alert.alert( + localeString('views.ActivityExport.noValidDataForDownload') + ); + this.setState({ isCsvLoading: false }); + return; + } + + try { + const dateTime = getFormattedDateTime(); + const baseFileName = this.state.customFileName + ? `${this.state.customFileName}.csv` + : `zeus_${dateTime}_${type}.csv`; + + await saveCsvFile(baseFileName, csvData); + + this.setState({ isModalVisible: false }); + + this.closeAndClearInput(); + + Alert.alert( + localeString('general.success'), + localeString('views.ActivityToCsv.csvDownloaded') + ); + } catch (err) { + console.error('Failed to save CSV file:', err); + Alert.alert( + localeString('general.error'), + localeString('views.ActivityToCsv.csvDownloadFailed') + ); + } finally { + this.setState({ isCsvLoading: false }); + } + }); + }; + + closeAndClearInput = () => { + this.setState({ + isModalVisible: false, + customFileName: '', + fromDate: new Date(), + toDate: new Date(), + downloadCompleteData: false + }); + }; + + openModal = (type: 'invoice' | 'transaction' | 'payment') => { + let earliestDate = new Date( + new Date().setMonth(new Date().getMonth() - 1) + ); // Default to 1 month ago + + this.setState({ + isModalVisible: true, + exportType: type, + fromDate: earliestDate, + toDate: new Date() + }); + }; + + renderModal = () => { + const { + isModalVisible, + isCsvLoading, + customFileName, + fromDate, + toDate, + downloadCompleteData + } = this.state; + return ( + + + + {isCsvLoading ? ( + + ) : ( + <> + + {localeString( + 'views.ActivityExport.dateRange' + )} + + + + + this.setState({ + downloadCompleteData: + !downloadCompleteData, + fromDate: + !downloadCompleteData + ? null + : fromDate, + toDate: !downloadCompleteData + ? null + : toDate + }) + } + containerStyle={{ + backgroundColor: 'transparent', + borderWidth: 0 + }} + textStyle={{ + color: themeColor('text') + }} + checkedColor="green" + /> + + {!downloadCompleteData && ( + <> + + {localeString( + 'views.ActivityExport.fromDate' + )} + + { + this.setState({ + fromDate: date + }); + if ( + toDate && + date > toDate + ) { + this.setState({ + toDate: date + }); + } + }} + style={{ + height: 100, + marginTop: 10, + marginBottom: 20, + alignSelf: 'center' + }} + maximumDate={new Date()} + textColor={themeColor('text')} + androidVariant="nativeAndroid" + /> + + {localeString( + 'views.ActivityExport.toDate' + )} + + { + if ( + fromDate && + date < fromDate + ) { + Alert.alert( + 'Invalid Date' + ); + } else { + this.setState({ + toDate: date + }); + } + }} + style={{ + height: 100, + marginTop: 10, + marginBottom: 20, + alignSelf: 'center' + }} + maximumDate={new Date()} + textColor={themeColor('text')} + androidVariant="nativeAndroid" + /> + + )} + + + + this.setState({ + customFileName: text + }) + } + style={{ marginHorizontal: 12 }} + /> + +