diff --git a/common/src/main/java/haveno/common/file/FileUtil.java b/common/src/main/java/haveno/common/file/FileUtil.java index 19d8b8a4c0..60ac2c3abc 100644 --- a/common/src/main/java/haveno/common/file/FileUtil.java +++ b/common/src/main/java/haveno/common/file/FileUtil.java @@ -26,6 +26,7 @@ import org.apache.commons.io.IOUtils; import javax.annotation.Nullable; import java.io.File; import java.io.FileInputStream; +import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; @@ -35,6 +36,7 @@ import java.util.Arrays; import java.util.Comparator; import java.util.Date; import java.util.List; +import java.util.Scanner; @Slf4j public class FileUtil { @@ -240,4 +242,14 @@ public class FileUtil { renameFile(storageFile, corruptedFile); } } + + public static boolean doesFileContainKeyword(File file, String keyword) throws FileNotFoundException { + Scanner s = new Scanner(file); + while (s.hasNextLine()) { + if (s.nextLine().contains(keyword)) { + return true; + } + } + return false; + } } diff --git a/core/src/main/java/haveno/core/api/CoreNotificationService.java b/core/src/main/java/haveno/core/api/CoreNotificationService.java index fe7d345958..6c40d7498b 100644 --- a/core/src/main/java/haveno/core/api/CoreNotificationService.java +++ b/core/src/main/java/haveno/core/api/CoreNotificationService.java @@ -60,7 +60,7 @@ public class CoreNotificationService { sendNotification(NotificationMessage.newBuilder() .setType(NotificationType.CHAT_MESSAGE) .setTimestamp(System.currentTimeMillis()) - .setChatMessage(chatMessage.toProtoChatMessageBuilder()) + .setChatMessage(chatMessage.toProtoNetworkEnvelope().getChatMessage()) .build()); } diff --git a/core/src/main/java/haveno/core/proto/network/CoreNetworkProtoResolver.java b/core/src/main/java/haveno/core/proto/network/CoreNetworkProtoResolver.java index f2be266ddc..6827295fbf 100644 --- a/core/src/main/java/haveno/core/proto/network/CoreNetworkProtoResolver.java +++ b/core/src/main/java/haveno/core/proto/network/CoreNetworkProtoResolver.java @@ -55,6 +55,7 @@ import haveno.core.trade.messages.SignContractResponse; import haveno.network.p2p.AckMessage; import haveno.network.p2p.BundleOfEnvelopes; import haveno.network.p2p.CloseConnectionMessage; +import haveno.network.p2p.FileTransferPart; import haveno.network.p2p.PrefixedSealedAndSignedMessage; import haveno.network.p2p.peers.getdata.messages.GetDataResponse; import haveno.network.p2p.peers.getdata.messages.GetUpdatedDataRequest; @@ -178,6 +179,9 @@ public class CoreNetworkProtoResolver extends CoreProtoResolver implements Netwo case GET_INVENTORY_RESPONSE: return GetInventoryResponse.fromProto(proto.getGetInventoryResponse(), messageVersion); + case FILE_TRANSFER_PART: + return FileTransferPart.fromProto(proto.getFileTransferPart(), messageVersion); + default: throw new ProtobufferException("Unknown proto message case (PB.NetworkEnvelope). messageCase=" + proto.getMessageCase() + "; proto raw data=" + proto.toString()); diff --git a/core/src/main/java/haveno/core/support/dispute/Dispute.java b/core/src/main/java/haveno/core/support/dispute/Dispute.java index 2b6df83d0e..2143561bc6 100644 --- a/core/src/main/java/haveno/core/support/dispute/Dispute.java +++ b/core/src/main/java/haveno/core/support/dispute/Dispute.java @@ -30,8 +30,13 @@ import haveno.core.locale.Res; import haveno.core.payment.payload.PaymentAccountPayload; import haveno.core.proto.CoreProtoResolver; import haveno.core.support.SupportType; +import haveno.core.support.dispute.mediation.FileTransferReceiver; +import haveno.core.support.dispute.mediation.FileTransferSender; +import haveno.core.support.dispute.mediation.FileTransferSession; import haveno.core.support.messages.ChatMessage; import haveno.core.trade.Contract; +import haveno.network.p2p.NodeAddress; +import haveno.network.p2p.network.NetworkNode; import javafx.beans.property.BooleanProperty; import javafx.beans.property.IntegerProperty; import javafx.beans.property.ObjectProperty; @@ -49,6 +54,8 @@ import lombok.Setter; import lombok.extern.slf4j.Slf4j; import javax.annotation.Nullable; + +import java.io.IOException; import java.util.ArrayList; import java.util.Date; import java.util.HashMap; @@ -151,6 +158,25 @@ public final class Dispute implements NetworkPayload, PersistablePayload { private transient final BooleanProperty isClosedProperty = new SimpleBooleanProperty(); private transient final IntegerProperty badgeCountProperty = new SimpleIntegerProperty(); + private transient FileTransferReceiver fileTransferSession = null; + + public FileTransferReceiver createOrGetFileTransferReceiver(NetworkNode networkNode, + NodeAddress peerNodeAddress, + FileTransferSession.FtpCallback callback) throws IOException { + // the receiver stores its state temporarily here in the dispute + // this method gets called to retrieve the session each time a part of the log files is received + if (fileTransferSession == null) { + fileTransferSession = new FileTransferReceiver(networkNode, peerNodeAddress, this.tradeId, this.traderId, this.getRoleStringForLogFile(), callback); + } + return fileTransferSession; + } + + public FileTransferSender createFileTransferSender(NetworkNode networkNode, + NodeAddress peerNodeAddress, + FileTransferSession.FtpCallback callback) { + return new FileTransferSender(networkNode, peerNodeAddress, this.tradeId, this.traderId, this.getRoleStringForLogFile(), false, callback); + } + /////////////////////////////////////////////////////////////////////////////////////////// // Constructor @@ -478,6 +504,11 @@ public final class Dispute implements NetworkPayload, PersistablePayload { } } + public String getRoleStringForLogFile() { + return (disputeOpenerIsBuyer ? "BUYER" : "SELLER") + "_" + + (disputeOpenerIsMaker ? "MAKER" : "TAKER"); + } + @Nullable public PaymentAccountPayload getBuyerPaymentAccountPayload() { return contract.isBuyerMakerAndSellerTaker() ? makerPaymentAccountPayload : takerPaymentAccountPayload; diff --git a/core/src/main/java/haveno/core/support/dispute/DisputeManager.java b/core/src/main/java/haveno/core/support/dispute/DisputeManager.java index 9c529e10d2..892900f591 100644 --- a/core/src/main/java/haveno/core/support/dispute/DisputeManager.java +++ b/core/src/main/java/haveno/core/support/dispute/DisputeManager.java @@ -940,7 +940,7 @@ public abstract class DisputeManager> extends Sup return new Tuple2<>(peerNodeAddress, receiverPubKeyRing); } - private boolean isAgent(Dispute dispute) { + public boolean isAgent(Dispute dispute) { return keyRing.getPubKeyRing().equals(dispute.getAgentPubKeyRing()); } @@ -1038,6 +1038,20 @@ public abstract class DisputeManager> extends Sup requestPersistence(); } + protected void addMediationLogsReceivedMessage(Dispute dispute, String logsIdentifier) { + String logsReceivedMessage = Res.get("support.mediatorReceivedLogs", logsIdentifier); + ChatMessage chatMessage = new ChatMessage( + getSupportType(), + dispute.getTradeId(), + keyRing.hashCode(), + false, + logsReceivedMessage, + p2PService.getAddress()); + chatMessage.setSystemMessage(true); + dispute.addAndPersistChatMessage(chatMessage); + requestPersistence(); + } + // If price was going down between take offer time and open dispute time the buyer has an incentive to // not send the payment but to try to make a new trade with the better price. We risks to lose part of the // security deposit (in mediation we will always get back 0.003 BTC to keep some incentive to accept mediated diff --git a/core/src/main/java/haveno/core/support/dispute/arbitration/ArbitrationManager.java b/core/src/main/java/haveno/core/support/dispute/arbitration/ArbitrationManager.java index 2abb2a251b..eac5a6c957 100644 --- a/core/src/main/java/haveno/core/support/dispute/arbitration/ArbitrationManager.java +++ b/core/src/main/java/haveno/core/support/dispute/arbitration/ArbitrationManager.java @@ -43,6 +43,7 @@ import haveno.common.UserThread; import haveno.common.app.Version; import haveno.common.config.Config; import haveno.common.crypto.KeyRing; +import haveno.common.proto.network.NetworkEnvelope; import haveno.core.api.XmrConnectionService; import haveno.core.api.CoreNotificationService; import haveno.core.locale.Res; @@ -55,6 +56,9 @@ import haveno.core.support.dispute.DisputeResult; import haveno.core.support.dispute.DisputeResult.Winner; import haveno.core.support.dispute.DisputeSummaryVerification; import haveno.core.support.dispute.arbitration.arbitrator.ArbitratorManager; +import haveno.core.support.dispute.mediation.FileTransferReceiver; +import haveno.core.support.dispute.mediation.FileTransferSender; +import haveno.core.support.dispute.mediation.FileTransferSession; import haveno.core.support.dispute.messages.DisputeClosedMessage; import haveno.core.support.dispute.messages.DisputeOpenedMessage; import haveno.core.support.messages.ChatMessage; @@ -67,8 +71,11 @@ import haveno.core.trade.TradeManager; import haveno.core.xmr.wallet.TradeWalletService; import haveno.core.xmr.wallet.XmrWalletService; import haveno.network.p2p.AckMessageSourceType; +import haveno.network.p2p.FileTransferPart; import haveno.network.p2p.NodeAddress; import haveno.network.p2p.P2PService; +import haveno.network.p2p.network.Connection; +import haveno.network.p2p.network.MessageListener; import lombok.extern.slf4j.Slf4j; import monero.wallet.MoneroWallet; import monero.wallet.model.MoneroDestination; @@ -76,6 +83,7 @@ import monero.wallet.model.MoneroMultisigSignResult; import monero.wallet.model.MoneroTxSet; import monero.wallet.model.MoneroTxWallet; +import java.io.IOException; import java.math.BigInteger; import java.util.HashMap; import java.util.HashSet; @@ -88,7 +96,7 @@ import static com.google.common.base.Preconditions.checkNotNull; @Slf4j @Singleton -public final class ArbitrationManager extends DisputeManager { +public final class ArbitrationManager extends DisputeManager implements MessageListener, FileTransferSession.FtpCallback { private final ArbitratorManager arbitratorManager; @@ -116,6 +124,7 @@ public final class ArbitrationManager extends DisputeManager new IOException("could not locate Dispute for tradeId/traderId")); + return dispute.createFileTransferSender(p2PService.getNetworkNode(), + dispute.getContract().getArbitratorNodeAddress(), callback); + } + + private void processFilePartReceived(FileTransferPart ftp) { + if (!ftp.isInitialRequest()) { + return; // existing sessions are processed by FileTransferSession object directly + } + // we create a new session which is related to an open dispute from our list + Optional dispute = findDispute(ftp.getTradeId(), ftp.getTraderId()); + if (dispute.isEmpty()) { + log.error("Received log upload request for unknown TradeId/TraderId {}/{}", ftp.getTradeId(), ftp.getTraderId()); + return; + } + if (dispute.get().isClosed()) { + log.error("Received a file transfer request for closed dispute {}", ftp.getTradeId()); + return; + } + try { + FileTransferReceiver session = dispute.get().createOrGetFileTransferReceiver( + p2PService.getNetworkNode(), ftp.getSenderNodeAddress(), this); + session.processFilePartReceived(ftp); + } catch (IOException e) { + log.error("Unable to process a received file message" + e); + } + } + + @Override + public void onMessage(NetworkEnvelope networkEnvelope, Connection connection) { + if (networkEnvelope instanceof FileTransferPart) { // mediator receiving log file data + FileTransferPart ftp = (FileTransferPart) networkEnvelope; + processFilePartReceived(ftp); + } + } + + @Override + public void onFtpProgress(double progressPct) { + log.trace("ftp progress: {}", progressPct); + } + + @Override + public void onFtpComplete(FileTransferSession session) { + Optional dispute = findDispute(session.getFullTradeId(), session.getTraderId()); + dispute.ifPresent(d -> addMediationLogsReceivedMessage(d, session.getZipId())); + } + + @Override + public void onFtpTimeout(String statusMsg, FileTransferSession session) { + session.resetSession(); + } } diff --git a/core/src/main/java/haveno/core/support/dispute/mediation/FileTransferReceiver.java b/core/src/main/java/haveno/core/support/dispute/mediation/FileTransferReceiver.java new file mode 100644 index 0000000000..6c79469daf --- /dev/null +++ b/core/src/main/java/haveno/core/support/dispute/mediation/FileTransferReceiver.java @@ -0,0 +1,126 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package haveno.core.support.dispute.mediation; + +import haveno.network.p2p.AckMessage; +import haveno.network.p2p.AckMessageSourceType; +import haveno.network.p2p.FileTransferPart; +import haveno.network.p2p.NodeAddress; +import haveno.network.p2p.network.NetworkNode; + +import haveno.common.UserThread; +import haveno.common.config.Config; +import haveno.common.util.Utilities; + +import java.nio.file.FileSystems; + +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; + +import java.util.concurrent.TimeUnit; + +import lombok.extern.slf4j.Slf4j; + +import javax.annotation.Nullable; + +@Slf4j +public class FileTransferReceiver extends FileTransferSession { + protected final String zipFilePath; + + public FileTransferReceiver(NetworkNode networkNode, + NodeAddress peerNodeAddress, + String tradeId, + int traderId, + String traderRole, + @Nullable FileTransferSession.FtpCallback callback) throws IOException { + super(networkNode, peerNodeAddress, tradeId, traderId, traderRole, callback); + zipFilePath = ensureReceivingDirectoryExists().getAbsolutePath() + FileSystems.getDefault().getSeparator() + zipId + ".zip"; + } + + public void processFilePartReceived(FileTransferPart ftp) { + checkpointLastActivity(); + // check that the supplied sequence number is in line with what we are expecting + if (currentBlockSeqNum < 0) { + // we have not yet started receiving a file, validate this ftp packet as the initiation request + initReceiveSession(ftp.uid, ftp.seqNumOrFileLength); + } else if (currentBlockSeqNum == ftp.seqNumOrFileLength) { + // we are in the middle of receiving a file; add the block of data to the file + processReceivedBlock(ftp, networkNode, peerNodeAddress); + } else { + log.error("ftp sequence num mismatch, expected {} received {}", currentBlockSeqNum, ftp.seqNumOrFileLength); + resetSession(); // aborts the file transfer + } + } + + public void initReceiveSession(String uid, long expectedFileBytes) { + networkNode.addMessageListener(this); + this.expectedFileLength = expectedFileBytes; + fileOffsetBytes = 0; + currentBlockSeqNum = 0; + initSessionTimer(); + log.info("Received a start file transfer request, tradeId={}, traderId={}, size={}", fullTradeId, traderId, expectedFileBytes); + log.info("New file will be written to {}", zipFilePath); + UserThread.execute(() -> ackReceivedPart(uid, networkNode, peerNodeAddress)); + } + + private void processReceivedBlock(FileTransferPart ftp, NetworkNode networkNode, NodeAddress peerNodeAddress) { + try { + RandomAccessFile file = new RandomAccessFile(zipFilePath, "rwd"); + file.seek(fileOffsetBytes); + file.write(ftp.messageData.toByteArray(), 0, ftp.messageData.size()); + fileOffsetBytes = fileOffsetBytes + ftp.messageData.size(); + log.info("Sequence number {} for {}, received data {} / {}", + ftp.seqNumOrFileLength, Utilities.getShortId(ftp.tradeId), fileOffsetBytes, expectedFileLength); + currentBlockSeqNum++; + UserThread.runAfter(() -> { + ackReceivedPart(ftp.uid, networkNode, peerNodeAddress); + if (fileOffsetBytes >= expectedFileLength) { + log.info("Success! We have reached the EOF, received {} expected {}", fileOffsetBytes, expectedFileLength); + ftpCallback.ifPresent(c -> c.onFtpComplete(this)); + resetSession(); + } + }, 100, TimeUnit.MILLISECONDS); + } catch (IOException e) { + log.error(e.toString()); + e.printStackTrace(); + } + } + + private void ackReceivedPart(String uid, NetworkNode networkNode, NodeAddress peerNodeAddress) { + AckMessage ackMessage = new AckMessage(peerNodeAddress, + AckMessageSourceType.LOG_TRANSFER, + FileTransferPart.class.getSimpleName(), + uid, + Utilities.getShortId(fullTradeId), + true, // result + null); // errorMessage + log.info("Send AckMessage for {} to peer {}. id={}, uid={}", + ackMessage.getSourceMsgClassName(), peerNodeAddress, ackMessage.getSourceId(), ackMessage.getSourceUid()); + sendMessage(ackMessage, networkNode, peerNodeAddress); + } + + private static File ensureReceivingDirectoryExists() throws IOException { + File directory = new File(Config.appDataDir() + "/clientLogs"); + if (!directory.exists() && !directory.mkdirs()) { + log.error("Could not create directory {}", directory.getAbsolutePath()); + throw new IOException("Could not create directory: " + directory.getAbsolutePath()); + } + return directory; + } +} diff --git a/core/src/main/java/haveno/core/support/dispute/mediation/FileTransferSender.java b/core/src/main/java/haveno/core/support/dispute/mediation/FileTransferSender.java new file mode 100644 index 0000000000..4efa3ad4b0 --- /dev/null +++ b/core/src/main/java/haveno/core/support/dispute/mediation/FileTransferSender.java @@ -0,0 +1,198 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package haveno.core.support.dispute.mediation; + +import haveno.network.p2p.FileTransferPart; +import haveno.network.p2p.NodeAddress; +import haveno.network.p2p.network.NetworkNode; + +import haveno.common.UserThread; +import haveno.common.config.Config; +import haveno.common.util.Utilities; + +import com.google.protobuf.ByteString; + +import java.net.URI; + +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; + +import java.io.IOException; +import java.io.RandomAccessFile; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; + +import lombok.extern.slf4j.Slf4j; + +import javax.annotation.Nullable; + +import static haveno.common.file.FileUtil.doesFileContainKeyword; + +@Slf4j +public class FileTransferSender extends FileTransferSession { + protected final String zipFilePath; + private final boolean isTest; + + public FileTransferSender(NetworkNode networkNode, + NodeAddress peerNodeAddress, + String tradeId, + int traderId, + String traderRole, + boolean isTest, + @Nullable FileTransferSession.FtpCallback callback) { + super(networkNode, peerNodeAddress, tradeId, traderId, traderRole, callback); + zipFilePath = Utilities.getUserDataDir() + FileSystems.getDefault().getSeparator() + zipId + ".zip"; + this.isTest = isTest; + updateProgress(); + } + + public void createZipFileToSend() { + createZipFileOfLogs(zipFilePath, zipId, fullTradeId); + } + + public static void createZipFileOfLogs(String zipFilePath, String zipId, String fullTradeId) { + try { + Map env = new HashMap<>(); + env.put("create", "true"); + URI uri = URI.create("jar:file:///" + zipFilePath + .replace('\\', '/') + .replaceAll(" ", "%20")); + FileSystem zipfs = FileSystems.newFileSystem(uri, env); + Files.createDirectory(zipfs.getPath(zipId)); // store logfiles in a usefully-named subdir + Stream paths = Files.walk(Paths.get(Config.appDataDir().toString()), 1); + paths.filter(Files::isRegularFile).forEach(externalTxtFile -> { + try { + // always include haveno.log; and other .log files if they contain the TradeId + if (externalTxtFile.getFileName().toString().equals("haveno.log") || + (fullTradeId == null && externalTxtFile.getFileName().toString().matches(".*.log")) || + (externalTxtFile.getFileName().toString().matches(".*.log") && + doesFileContainKeyword(externalTxtFile.toFile(), fullTradeId))) { + Path pathInZipfile = zipfs.getPath(zipId + "/" + externalTxtFile.getFileName().toString()); + log.info("adding {} to zip file {}", pathInZipfile, zipfs); + Files.copy(externalTxtFile, pathInZipfile, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException e) { + log.error(e.toString()); + e.printStackTrace(); + } + }); + zipfs.close(); + } catch (IOException | IllegalArgumentException ex) { + log.error(ex.toString()); + ex.printStackTrace(); + } + } + + public void initSend() throws IOException { + initSessionTimer(); + networkNode.addMessageListener(this); + RandomAccessFile file = new RandomAccessFile(zipFilePath, "r"); + expectedFileLength = file.length(); + file.close(); + // an empty block is sent as request to initiate file transfer, peer must ACK for transfer to continue + dataAwaitingAck = Optional.of(new FileTransferPart(networkNode.getNodeAddress(), fullTradeId, traderId, UUID.randomUUID().toString(), expectedFileLength, ByteString.EMPTY)); + uploadData(); + } + + public void sendNextBlock() throws IOException, IllegalStateException { + if (dataAwaitingAck.isPresent()) { + log.warn("prepNextBlockToSend invoked, but we are still waiting for a previous ACK"); + throw new IllegalStateException("prepNextBlockToSend invoked, but we are still waiting for a previous ACK"); + } + RandomAccessFile file = new RandomAccessFile(zipFilePath, "r"); + file.seek(fileOffsetBytes); + byte[] buff = new byte[FILE_BLOCK_SIZE]; + int nBytesRead = file.read(buff, 0, FILE_BLOCK_SIZE); + file.close(); + if (nBytesRead < 0) { + log.info("Success! We have reached the EOF, {} bytes sent. Removing zip file {}", fileOffsetBytes, zipFilePath); + Files.delete(Paths.get(zipFilePath)); + ftpCallback.ifPresent(c -> c.onFtpComplete(this)); + UserThread.runAfter(this::resetSession, 1); + return; + } + dataAwaitingAck = Optional.of(new FileTransferPart(networkNode.getNodeAddress(), fullTradeId, traderId, UUID.randomUUID().toString(), currentBlockSeqNum, ByteString.copyFrom(buff, 0, nBytesRead))); + uploadData(); + } + + public void retrySend() { + if (transferIsInProgress()) { + log.info("Retry send of current block"); + initSessionTimer(); + uploadData(); + } else { + UserThread.runAfter(() -> ftpCallback.ifPresent((f) -> f.onFtpTimeout("Could not re-send", this)), 1); + } + } + + protected void uploadData() { + if (dataAwaitingAck.isEmpty()) { + return; + } + FileTransferPart ftp = dataAwaitingAck.get(); + log.info("Send FileTransferPart seq {} length {} to peer {}, UID={}", + ftp.seqNumOrFileLength, ftp.messageData.size(), peerNodeAddress, ftp.uid); + sendMessage(ftp, networkNode, peerNodeAddress); + } + + public boolean processAckForFilePart(String ackUid) { + if (dataAwaitingAck.isEmpty()) { + log.warn("We received an ACK we were not expecting. {}", ackUid); + return false; + } + if (!dataAwaitingAck.get().uid.equals(ackUid)) { + log.warn("We received an ACK that has a different UID to what we were expecting. We ignore and wait for the correct ACK"); + log.info("Received {} expecting {}", ackUid, dataAwaitingAck.get().uid); + return false; + } + // fileOffsetBytes gets incremented by the size of the block that was ack'd + fileOffsetBytes += dataAwaitingAck.get().messageData.size(); + currentBlockSeqNum++; + dataAwaitingAck = Optional.empty(); + checkpointLastActivity(); + updateProgress(); + if (isTest) { + return true; + } + UserThread.runAfter(() -> { // to trigger continuing the file transfer + try { + sendNextBlock(); + } catch (IOException e) { + log.error(e.toString()); + e.printStackTrace(); + } + }, 100, TimeUnit.MILLISECONDS); + return true; + } + + public void updateProgress() { + double progressPct = expectedFileLength > 0 ? + ((double) fileOffsetBytes / expectedFileLength) : 0.0; + ftpCallback.ifPresent(c -> c.onFtpProgress(progressPct)); + log.info("ftp progress: {}", String.format("%.0f%%", progressPct * 100)); + } +} diff --git a/core/src/main/java/haveno/core/support/dispute/mediation/FileTransferSession.java b/core/src/main/java/haveno/core/support/dispute/mediation/FileTransferSession.java new file mode 100644 index 0000000000..96a9e73f96 --- /dev/null +++ b/core/src/main/java/haveno/core/support/dispute/mediation/FileTransferSession.java @@ -0,0 +1,174 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package haveno.core.support.dispute.mediation; + +import haveno.network.p2p.AckMessage; +import haveno.network.p2p.AckMessageSourceType; +import haveno.network.p2p.FileTransferPart; +import haveno.network.p2p.NodeAddress; +import haveno.network.p2p.network.Connection; +import haveno.network.p2p.network.MessageListener; +import haveno.network.p2p.network.NetworkNode; + +import haveno.common.UserThread; +import haveno.common.proto.network.NetworkEnvelope; +import haveno.common.util.Utilities; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.common.util.concurrent.SettableFuture; + +import java.text.SimpleDateFormat; + +import java.util.Date; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import org.jetbrains.annotations.NotNull; + +import javax.annotation.Nullable; + +import static haveno.network.p2p.network.Connection.getPermittedMessageSize; + +@Slf4j +public abstract class FileTransferSession implements MessageListener { + protected static final int FTP_SESSION_TIMEOUT_MILLIS = (int) TimeUnit.SECONDS.toMillis(60); + protected static final int FILE_BLOCK_SIZE = getPermittedMessageSize() - 1024; // allowing space for protobuf + + public interface FtpCallback { + void onFtpProgress(double progressPct); + + void onFtpComplete(FileTransferSession session); + + void onFtpTimeout(String statusMsg, FileTransferSession session); + } + + @Getter + protected final String fullTradeId; + @Getter + protected final int traderId; + @Getter + protected final String zipId; + protected final Optional ftpCallback; + protected final NetworkNode networkNode; // for sending network messages + protected final NodeAddress peerNodeAddress; + protected Optional dataAwaitingAck; + protected long fileOffsetBytes; + protected long currentBlockSeqNum; + protected long expectedFileLength; + protected long lastActivityTime; + + public FileTransferSession(NetworkNode networkNode, + NodeAddress peerNodeAddress, + String tradeId, + int traderId, + String traderRole, + @Nullable FileTransferSession.FtpCallback callback) { + this.networkNode = networkNode; + this.peerNodeAddress = peerNodeAddress; + this.fullTradeId = tradeId; + this.traderId = traderId; + this.ftpCallback = Optional.ofNullable(callback); + this.zipId = Utilities.getShortId(fullTradeId) + "_" + traderRole.toUpperCase() + "_" + + new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date()); + resetSession(); + } + + public void resetSession() { + lastActivityTime = 0; + currentBlockSeqNum = -1; + fileOffsetBytes = 0; + expectedFileLength = 0; + dataAwaitingAck = Optional.empty(); + networkNode.removeMessageListener(this); + log.info("Ftp session parameters have been reset."); + } + + @Override + public void onMessage(NetworkEnvelope networkEnvelope, Connection connection) { + if (networkEnvelope instanceof FileTransferPart) { + // mediator receiving log file data + FileTransferPart ftp = (FileTransferPart) networkEnvelope; + if (this instanceof FileTransferReceiver) { + ((FileTransferReceiver) this).processFilePartReceived(ftp); + } + } else if (networkEnvelope instanceof AckMessage) { + AckMessage ackMessage = (AckMessage) networkEnvelope; + if (ackMessage.getSourceType() == AckMessageSourceType.LOG_TRANSFER) { + if (ackMessage.isSuccess()) { + log.info("Received AckMessage for {} with id {} and uid {}", + ackMessage.getSourceMsgClassName(), ackMessage.getSourceId(), ackMessage.getSourceUid()); + if (this instanceof FileTransferSender) { + ((FileTransferSender) this).processAckForFilePart(ackMessage.getSourceUid()); + } + } else { + log.warn("Received AckMessage with error state for {} with id {} and errorMessage={}", + ackMessage.getSourceMsgClassName(), ackMessage.getSourceId(), ackMessage.getErrorMessage()); + } + } + } + } + + protected void checkpointLastActivity() { + lastActivityTime = System.currentTimeMillis(); + } + + protected void initSessionTimer() { + UserThread.runAfter(() -> { + if (!transferIsInProgress()) // transfer may have finished before this timer executes + return; + if (System.currentTimeMillis() - lastActivityTime < FTP_SESSION_TIMEOUT_MILLIS) { + log.info("Last activity was {}, we have not yet timed out.", new Date(lastActivityTime)); + initSessionTimer(); + } else { + log.warn("File transfer session timed out. expected: {} received: {}", expectedFileLength, fileOffsetBytes); + ftpCallback.ifPresent((e) -> e.onFtpTimeout("Timed out during send", this)); + } + }, FTP_SESSION_TIMEOUT_MILLIS / 4, TimeUnit.MILLISECONDS); // check more frequently than the timeout + } + + protected boolean transferIsInProgress() { + return fileOffsetBytes != expectedFileLength; + } + + protected void sendMessage(NetworkEnvelope message, NetworkNode networkNode, NodeAddress nodeAddress) { + SettableFuture future = networkNode.sendMessage(nodeAddress, message); + if (future != null) { // is null when testing with Mockito + Futures.addCallback(future, new FutureCallback<>() { + @Override + public void onSuccess(Connection connection) { + } + + @Override + public void onFailure(@NotNull Throwable throwable) { + String errorSend = "Sending " + message.getClass().getSimpleName() + + " to " + nodeAddress.getFullAddress() + + " failed. That is expected if the peer is offline.\n\t" + + ".\n\tException=" + throwable.getMessage(); + log.warn(errorSend); + ftpCallback.ifPresent((f) -> f.onFtpTimeout("Peer offline", FileTransferSession.this)); + resetSession(); + } + }, MoreExecutors.directExecutor()); + } + } +} diff --git a/core/src/main/java/haveno/core/support/messages/ChatMessage.java b/core/src/main/java/haveno/core/support/messages/ChatMessage.java index 17faaefe40..a9663ee353 100644 --- a/core/src/main/java/haveno/core/support/messages/ChatMessage.java +++ b/core/src/main/java/haveno/core/support/messages/ChatMessage.java @@ -17,25 +17,25 @@ package haveno.core.support.messages; -import haveno.common.app.Version; -import haveno.common.util.Utilities; +import haveno.core.locale.Res; import haveno.core.support.SupportType; import haveno.core.support.dispute.Attachment; import haveno.core.support.dispute.Dispute; import haveno.core.support.dispute.DisputeResult; + import haveno.network.p2p.NodeAddress; + +import haveno.common.UserThread; +import haveno.common.app.Version; +import haveno.common.util.Utilities; + import javafx.beans.property.BooleanProperty; import javafx.beans.property.ReadOnlyBooleanProperty; import javafx.beans.property.ReadOnlyStringProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; -import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.Setter; -import lombok.extern.slf4j.Slf4j; -import javax.annotation.Nullable; import java.util.ArrayList; import java.util.Date; import java.util.List; @@ -44,13 +44,22 @@ import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; +import java.lang.ref.WeakReference; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; + +import javax.annotation.Nullable; + /* Message for direct communication between two nodes. Originally built for trader to * arbitrator communication as no other direct communication was allowed. Arbitrator is * considered as the server and trader as the client in arbitration chats * * For trader to trader communication the maker is considered to be the server * and the taker is considered as the client. - * */ + */ @EqualsAndHashCode(callSuper = true) // listener is transient and therefore excluded anyway @Getter @Slf4j @@ -84,14 +93,14 @@ public final class ChatMessage extends SupportMessage { private final StringProperty sendMessageErrorProperty; private final StringProperty ackErrorProperty; - transient private Listener listener; + transient private WeakReference listener; public ChatMessage(SupportType supportType, - String tradeId, - int traderId, - boolean senderIsTrader, - String message, - NodeAddress senderNodeAddress) { + String tradeId, + int traderId, + boolean senderIsTrader, + String message, + NodeAddress senderNodeAddress) { this(supportType, tradeId, traderId, @@ -111,12 +120,12 @@ public final class ChatMessage extends SupportMessage { } public ChatMessage(SupportType supportType, - String tradeId, - int traderId, - boolean senderIsTrader, - String message, - NodeAddress senderNodeAddress, - ArrayList attachments) { + String tradeId, + int traderId, + boolean senderIsTrader, + String message, + NodeAddress senderNodeAddress, + ArrayList attachments) { this(supportType, tradeId, traderId, @@ -136,12 +145,12 @@ public final class ChatMessage extends SupportMessage { } public ChatMessage(SupportType supportType, - String tradeId, - int traderId, - boolean senderIsTrader, - String message, - NodeAddress senderNodeAddress, - long date) { + String tradeId, + int traderId, + boolean senderIsTrader, + String message, + NodeAddress senderNodeAddress, + long date) { this(supportType, tradeId, traderId, @@ -198,7 +207,9 @@ public final class ChatMessage extends SupportMessage { notifyChangeListener(); } - public protobuf.ChatMessage.Builder toProtoChatMessageBuilder() { + // We cannot rename protobuf definition because it would break backward compatibility + @Override + public protobuf.NetworkEnvelope toProtoNetworkEnvelope() { protobuf.ChatMessage.Builder builder = protobuf.ChatMessage.newBuilder() .setType(SupportType.toProtoMessage(supportType)) .setTradeId(tradeId) @@ -216,14 +227,6 @@ public final class ChatMessage extends SupportMessage { .setWasDisplayed(wasDisplayed); Optional.ofNullable(sendMessageErrorProperty.get()).ifPresent(builder::setSendMessageError); Optional.ofNullable(ackErrorProperty.get()).ifPresent(builder::setAckError); - - return builder; - } - - // We cannot rename protobuf definition because it would break backward compatibility - @Override - public protobuf.NetworkEnvelope toProtoNetworkEnvelope() { - protobuf.ChatMessage.Builder builder = toProtoChatMessageBuilder(); return getNetworkEnvelopeBuilder() .setChatMessage(builder) .build(); @@ -296,6 +299,16 @@ public final class ChatMessage extends SupportMessage { notifyChangeListener(); } + // each chat message notifies the user if an ACK is not received in time + public void startAckTimer() { + UserThread.runAfter(() -> { + if (!this.getAcknowledgedProperty().get() && !this.getStoredInMailboxProperty().get()) { + this.setArrived(false); + this.setAckError(Res.get("support.errorTimeout")); + } + }, 60, TimeUnit.SECONDS); + } + public ReadOnlyBooleanProperty acknowledgedProperty() { return acknowledgedProperty; } @@ -327,12 +340,8 @@ public final class ChatMessage extends SupportMessage { return Utilities.getShortId(tradeId); } - public void addChangeListener(Listener listener) { - this.listener = listener; - } - - public void removeChangeListener() { - this.listener = null; + public void addWeakMessageStateListener(Listener listener) { + this.listener = new WeakReference<>(listener); } public boolean isResultMessage(Dispute dispute) { @@ -352,7 +361,10 @@ public final class ChatMessage extends SupportMessage { private void notifyChangeListener() { if (listener != null) { - listener.onMessageStateChanged(); + Listener listener = this.listener.get(); + if (listener != null) { + listener.onMessageStateChanged(); + } } } @@ -375,4 +387,4 @@ public final class ChatMessage extends SupportMessage { ",\n ackErrorProperty=" + ackErrorProperty + "\n} " + super.toString(); } -} +} \ No newline at end of file diff --git a/core/src/main/resources/i18n/displayStrings.properties b/core/src/main/resources/i18n/displayStrings.properties index 32c1bd9c26..c0aa44c8a1 100644 --- a/core/src/main/resources/i18n/displayStrings.properties +++ b/core/src/main/resources/i18n/displayStrings.properties @@ -1174,11 +1174,28 @@ support.chat=Chat support.requested=Requested support.closed=Closed support.open=Open +support.moreButton=MORE... +support.sendLogFiles=Send Log Files +support.uploadTraderChat=Upload Trader Chat support.process=Process support.buyerMaker=XMR Buyer/Maker support.sellerMaker=XMR Seller/Maker support.buyerTaker=XMR Buyer/Taker support.sellerTaker=XMR Seller/Taker +support.sendLogs.title=Send Log Files +support.sendLogs.backgroundInfo=When you experience a bug, arbitrators and support staff will often request copies of the your log files to diagnose the issue.\n\n\ + Upon pressing 'Send', your log files will be compressed and transmitted directly to the arbitrator. +support.sendLogs.step1=Create Zip Archive of Log Files +support.sendLogs.step2=Connection Request to Arbitrator +support.sendLogs.step3=Upload Archived Log Data +support.sendLogs.send=Send +support.sendLogs.cancel=Cancel +support.sendLogs.init=Initializing +support.sendLogs.retry=Retrying send +support.sendLogs.stopped=Transfer stopped +support.sendLogs.progress=Transfer progress: %.0f%% +support.sendLogs.finished=Transfer complete! +support.sendLogs.command=Press 'Send' to retry, or 'Stop' to abort support.txKeyImages=Key Images support.txHash=Transaction Hash support.txHex=Transaction Hex @@ -2312,6 +2329,7 @@ peerInfoIcon.tooltip.trade.traded={0} onion address: {1}\nYou have already trade peerInfoIcon.tooltip.trade.notTraded={0} onion address: {1}\nYou have not traded with that peer so far.\n{2} peerInfoIcon.tooltip.age=Payment account created {0} ago. peerInfoIcon.tooltip.unknownAge=Payment account age not known. +peerInfoIcon.tooltip.dispute={0}\nNumber of disputes: {1}.\n{2} tooltip.openPopupForDetails=Open popup for details tooltip.invalidTradeState.warning=This trade is in an invalid state. Open the details window for more information diff --git a/core/src/test/java/haveno/core/support/dispute/mediation/FileTransferSessionTest.java b/core/src/test/java/haveno/core/support/dispute/mediation/FileTransferSessionTest.java new file mode 100644 index 0000000000..b34e74e251 --- /dev/null +++ b/core/src/test/java/haveno/core/support/dispute/mediation/FileTransferSessionTest.java @@ -0,0 +1,241 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package haveno.core.support.dispute.mediation; + +import haveno.network.p2p.FileTransferPart; +import haveno.network.p2p.NodeAddress; +import haveno.network.p2p.network.NetworkNode; + +import haveno.common.config.Config; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class FileTransferSessionTest implements FileTransferSession.FtpCallback { + + double notedProgressPct = -1.0; + int progressInvocations = 0; + boolean ftpCompleteStatus = false; + String testTradeId = "foo"; + int testTraderId = 123; + String testClientId = "bar"; + NetworkNode networkNode; + NodeAddress counterpartyNodeAddress; + + @BeforeEach + public void setUp() throws Exception { + new Config(); // static methods like Config.appDataDir() require config to be created once + networkNode = mock(NetworkNode.class); + when(networkNode.getNodeAddress()).thenReturn(new NodeAddress("null:0000")); + counterpartyNodeAddress = new NodeAddress("null:0000"); + } + + @Test + public void testSendCreate() { + new FileTransferSender(networkNode, counterpartyNodeAddress, testTradeId, testTraderId, testClientId, true, this); + assertEquals(0.0, notedProgressPct, 0.0); + assertEquals(1, progressInvocations); + } + + @Test + public void testCreateZip() { + FileTransferSender sender = new FileTransferSender(networkNode, counterpartyNodeAddress, testTradeId, testTraderId, testClientId, true, this); + assertEquals(0.0, notedProgressPct, 0.0); + assertEquals(1, progressInvocations); + sender.createZipFileToSend(); + File file = new File(sender.zipFilePath); + assertTrue(file.getAbsoluteFile().exists()); + assertTrue(file.getAbsoluteFile().length() > 0); + file.deleteOnExit(); + } + + @Test + public void testSendInitialize() { + // checks that the initial send request packet contains correct information + try { + int testVerifyDataSize = 13; + FileTransferSender session = initializeSession(testVerifyDataSize); + session.initSend(); + FileTransferPart ftp = session.dataAwaitingAck.get(); + assertEquals(ftp.tradeId, testTradeId); + assertTrue(ftp.uid.length() > 0); + assertEquals(0, ftp.messageData.size()); + assertEquals(ftp.seqNumOrFileLength, testVerifyDataSize); + assertEquals(-1, session.currentBlockSeqNum); + return; + } catch (IOException e) { + e.printStackTrace(); + } + fail(); + } + + @Test + public void testSendSmallFile() { + try { + int testVerifyDataSize = 13; + FileTransferSender session = initializeSession(testVerifyDataSize); + // the first block contains zero data, as it is a "request to send" + session.initSend(); + simulateAckFromPeerAndVerify(session, 0, 0, 2); + // the second block contains all the test file data (because it is a small file) + session.sendNextBlock(); + simulateAckFromPeerAndVerify(session, testVerifyDataSize, 1, 3); + // the final invocation sends no data, and wraps up the session + session.sendNextBlock(); + assertEquals(1, session.currentBlockSeqNum); + assertEquals(3, progressInvocations); + assertEquals(1.0, notedProgressPct, 0.0); + assertTrue(ftpCompleteStatus); + } catch (IOException ioe) { + ioe.printStackTrace(); + fail(); + } + } + + @Test + public void testSendOneFullBlock() { + try { + int testVerifyDataSize = FileTransferSession.FILE_BLOCK_SIZE; + FileTransferSender session = initializeSession(testVerifyDataSize); + // the first block contains zero data, as it is a "request to send" + session.initSend(); + simulateAckFromPeerAndVerify(session, 0, 0, 2); + // the second block contains all the test file data (because it is a small file) + session.sendNextBlock(); + simulateAckFromPeerAndVerify(session, testVerifyDataSize, 1, 3); + // the final invocation sends no data, and wraps up the session + session.sendNextBlock(); + assertEquals(1, session.currentBlockSeqNum); + assertEquals(3, progressInvocations); + assertEquals(1.0, notedProgressPct, 0.0); + assertTrue(ftpCompleteStatus); + } catch (IOException ioe) { + ioe.printStackTrace(); + fail(); + } + } + + @Test + public void testSendTwoFullBlocks() { + try { + int testVerifyDataSize = FileTransferSession.FILE_BLOCK_SIZE * 2; + FileTransferSender session = initializeSession(testVerifyDataSize); + // the first block contains zero data, as it is a "request to send" + session.initSend(); + simulateAckFromPeerAndVerify(session, 0, 0, 2); + // the second block contains half of the test file data + session.sendNextBlock(); + simulateAckFromPeerAndVerify(session, testVerifyDataSize / 2, 1, 3); + // the third block contains half of the test file data + session.sendNextBlock(); + simulateAckFromPeerAndVerify(session, testVerifyDataSize / 2, 2, 4); + // the final invocation sends no data, and wraps up the session + session.sendNextBlock(); + assertEquals(2, session.currentBlockSeqNum); + assertEquals(4, progressInvocations); + assertEquals(1.0, notedProgressPct, 0.0); + assertTrue(ftpCompleteStatus); + } catch (IOException ioe) { + ioe.printStackTrace(); + fail(); + } + } + + @Test + public void testSendTwoFullBlocksPlusOneByte() { + try { + int testVerifyDataSize = 1 + FileTransferSession.FILE_BLOCK_SIZE * 2; + FileTransferSender session = initializeSession(testVerifyDataSize); + // the first block contains zero data, as it is a "request to send" + session.initSend(); + simulateAckFromPeerAndVerify(session, 0, 0, 2); + session.sendNextBlock(); + simulateAckFromPeerAndVerify(session, FileTransferSession.FILE_BLOCK_SIZE, 1, 3); + session.sendNextBlock(); + simulateAckFromPeerAndVerify(session, FileTransferSession.FILE_BLOCK_SIZE, 2, 4); + // the fourth block contains one byte + session.sendNextBlock(); + simulateAckFromPeerAndVerify(session, 1, 3, 5); + // the final invocation sends no data, and wraps up the session + session.sendNextBlock(); + assertEquals(3, session.currentBlockSeqNum); + assertEquals(5, progressInvocations); + assertEquals(1.0, notedProgressPct, 0.0); + assertTrue(ftpCompleteStatus); + } catch (IOException ioe) { + ioe.printStackTrace(); + fail(); + } + } + + private FileTransferSender initializeSession(int testSize) { + try { + FileTransferSender session = new FileTransferSender(networkNode, counterpartyNodeAddress, testTradeId, testTraderId, testClientId, true, this); + // simulate a file for sending + FileWriter fileWriter = new FileWriter(session.zipFilePath); + char[] buf = new char[testSize]; + for (int x = 0; x < testSize; x++) + buf[x] = 'A'; + fileWriter.write(buf); + fileWriter.close(); + assertFalse(ftpCompleteStatus); + assertEquals(1, progressInvocations); + assertEquals(0.0, notedProgressPct, 0.0); + assertFalse(session.processAckForFilePart("not_expected_uid")); + return session; + } catch (IOException e) { + e.printStackTrace(); + } + fail(); + return null; + } + + private void simulateAckFromPeerAndVerify(FileTransferSender session, int expectedDataSize, long expectedSeqNum, int expectedProgressInvocations) { + FileTransferPart ftp = session.dataAwaitingAck.get(); + assertEquals(expectedDataSize, ftp.messageData.size()); + assertTrue(session.processAckForFilePart(ftp.uid)); + assertEquals(expectedSeqNum, session.currentBlockSeqNum); + assertEquals(expectedProgressInvocations, progressInvocations); + } + + @Override + public void onFtpProgress(double progressPct) { + notedProgressPct = progressPct; + progressInvocations++; + } + + @Override + public void onFtpComplete(FileTransferSession session) { + ftpCompleteStatus = true; + } + + @Override + public void onFtpTimeout(String status, FileTransferSession session) { + } +} diff --git a/desktop/src/main/java/haveno/desktop/components/PeerInfoIcon.java b/desktop/src/main/java/haveno/desktop/components/PeerInfoIcon.java index 0e80885003..da8dcb4a70 100644 --- a/desktop/src/main/java/haveno/desktop/components/PeerInfoIcon.java +++ b/desktop/src/main/java/haveno/desktop/components/PeerInfoIcon.java @@ -17,40 +17,46 @@ package haveno.desktop.components; -import com.google.common.base.Charsets; +import haveno.desktop.main.overlays.editor.PeerInfoWithTagEditor; +import haveno.desktop.util.DisplayUtils; + import haveno.core.alert.PrivateNotificationManager; import haveno.core.locale.Res; import haveno.core.offer.Offer; import haveno.core.trade.Trade; import haveno.core.user.Preferences; -import haveno.desktop.main.overlays.editor.PeerInfoWithTagEditor; -import haveno.desktop.util.DisplayUtils; + import haveno.network.p2p.NodeAddress; -import javafx.geometry.Point2D; + +import com.google.common.base.Charsets; + import javafx.scene.Group; import javafx.scene.canvas.Canvas; import javafx.scene.canvas.GraphicsContext; import javafx.scene.control.Label; import javafx.scene.control.Tooltip; import javafx.scene.image.ImageView; +import javafx.scene.input.MouseButton; import javafx.scene.layout.Pane; import javafx.scene.paint.Color; -import lombok.Setter; + +import javafx.geometry.Point2D; + +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +import java.util.Map; + import lombok.extern.slf4j.Slf4j; import javax.annotation.Nullable; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.Map; @Slf4j public class PeerInfoIcon extends Group { - public interface notify { - void avatarTagUpdated(); - } - @Setter - private notify callback; protected Preferences preferences; protected final String fullAddress; protected String tooltipText; @@ -59,10 +65,12 @@ public class PeerInfoIcon extends Group { protected Pane tagPane; protected Pane numTradesPane; protected int numTrades = 0; + private final StringProperty tag; public PeerInfoIcon(NodeAddress nodeAddress, Preferences preferences) { this.preferences = preferences; this.fullAddress = nodeAddress != null ? nodeAddress.getFullAddress() : ""; + this.tag = new SimpleStringProperty(""); } protected void createAvatar(Color ringColor) { @@ -162,23 +170,24 @@ public class PeerInfoIcon extends Group { Res.get("peerInfo.unknownAge") : null; - setOnMouseClicked(e -> new PeerInfoWithTagEditor(privateNotificationManager, trade, offer, preferences, useDevPrivilegeKeys) - .fullAddress(fullAddress) - .numTrades(numTrades) - .accountAge(accountAgeFormatted) - .signAge(signAgeFormatted) - .accountAgeInfo(peersAccountAgeInfo) - .signAgeInfo(peersSignAgeInfo) - .accountSigningState(accountSigningState) - .position(localToScene(new Point2D(0, 0))) - .onSave(newTag -> { - preferences.setTagForPeer(fullAddress, newTag); - updatePeerInfoIcon(); - if (callback != null) { - callback.avatarTagUpdated(); - } - }) - .show()); + setOnMouseClicked(e -> { + if (e.getButton().equals(MouseButton.PRIMARY)) { + new PeerInfoWithTagEditor(privateNotificationManager, trade, offer, preferences, useDevPrivilegeKeys) + .fullAddress(fullAddress) + .numTrades(numTrades) + .accountAge(accountAgeFormatted) + .signAge(signAgeFormatted) + .accountAgeInfo(peersAccountAgeInfo) + .signAgeInfo(peersSignAgeInfo) + .accountSigningState(accountSigningState) + .position(localToScene(new Point2D(0, 0))) + .onSave(newTag -> { + preferences.setTagForPeer(fullAddress, newTag); + tag.set(newTag); + }) + .show(); + } + }); } protected double getScaleFactor() { @@ -192,20 +201,6 @@ public class PeerInfoIcon extends Group { } protected void updatePeerInfoIcon() { - String tag; - Map peerTagMap = preferences.getPeerTagMap(); - if (peerTagMap.containsKey(fullAddress)) { - tag = peerTagMap.get(fullAddress); - final String text = !tag.isEmpty() ? Res.get("peerInfoIcon.tooltip", tooltipText, tag) : tooltipText; - Tooltip.install(this, new Tooltip(text)); - } else { - tag = ""; - Tooltip.install(this, new Tooltip(tooltipText)); - } - - if (!tag.isEmpty()) - tagLabel.setText(tag.substring(0, 1)); - if (numTrades > 0) { numTradesLabel.setText(numTrades > 99 ? "*" : String.valueOf(numTrades)); @@ -216,9 +211,27 @@ public class PeerInfoIcon extends Group { numTradesLabel.relocate(scaleFactor * 5, scaleFactor * 1); } } - numTradesPane.setVisible(numTrades > 0); - tagPane.setVisible(!tag.isEmpty()); + refreshTag(); + } + + protected void refreshTag() { + Map peerTagMap = preferences.getPeerTagMap(); + if (peerTagMap.containsKey(fullAddress)) { + tag.set(peerTagMap.get(fullAddress)); + } + + Tooltip.install(this, new Tooltip(!tag.get().isEmpty() ? + Res.get("peerInfoIcon.tooltip", tooltipText, tag.get()) : tooltipText)); + + if (!tag.get().isEmpty()) { + tagLabel.setText(tag.get().substring(0, 1)); + } + tagPane.setVisible(!tag.get().isEmpty()); + } + + protected StringProperty tagProperty() { + return tag; } } diff --git a/desktop/src/main/java/haveno/desktop/components/PeerInfoIconDispute.java b/desktop/src/main/java/haveno/desktop/components/PeerInfoIconDispute.java index 81c8f2ede3..cb1e6fc7e3 100644 --- a/desktop/src/main/java/haveno/desktop/components/PeerInfoIconDispute.java +++ b/desktop/src/main/java/haveno/desktop/components/PeerInfoIconDispute.java @@ -40,8 +40,4 @@ public class PeerInfoIconDispute extends PeerInfoIcon { addMouseListener(numTrades, null, null, null, preferences, false, false, accountAge, 0L, null, null, null); } - - public void refreshTag() { - updatePeerInfoIcon(); - } } diff --git a/desktop/src/main/java/haveno/desktop/components/PeerInfoIconMap.java b/desktop/src/main/java/haveno/desktop/components/PeerInfoIconMap.java new file mode 100644 index 0000000000..e59fa538c3 --- /dev/null +++ b/desktop/src/main/java/haveno/desktop/components/PeerInfoIconMap.java @@ -0,0 +1,48 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package haveno.desktop.components; + +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; + +import java.util.HashMap; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class PeerInfoIconMap extends HashMap implements ChangeListener { + + @Override + public PeerInfoIcon put(String key, PeerInfoIcon icon) { + icon.tagProperty().addListener(this); + return super.put(key, icon); + } + + @Override + public void changed(ObservableValue o, String oldVal, String newVal) { + log.info("Updating avatar tags, the avatar map size is {}", size()); + forEach((key, icon) -> { + // We update all avatars, as some could be sharing the same tag. + // We also temporarily remove listeners to prevent firing of + // events while each icon's tagProperty is being reset. + icon.tagProperty().removeListener(this); + icon.refreshTag(); + icon.tagProperty().addListener(this); + }); + } +} diff --git a/desktop/src/main/java/haveno/desktop/main/overlays/windows/SendLogFilesWindow.java b/desktop/src/main/java/haveno/desktop/main/overlays/windows/SendLogFilesWindow.java new file mode 100644 index 0000000000..e5d20d5811 --- /dev/null +++ b/desktop/src/main/java/haveno/desktop/main/overlays/windows/SendLogFilesWindow.java @@ -0,0 +1,257 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package haveno.desktop.main.overlays.windows; + +import haveno.desktop.components.AutoTooltipButton; +import haveno.desktop.main.overlays.Overlay; +import haveno.desktop.main.portfolio.pendingtrades.steps.TradeWizardItem; +import haveno.desktop.main.portfolio.pendingtrades.steps.buyer.BuyerStep1View; +import haveno.desktop.main.portfolio.pendingtrades.steps.buyer.BuyerStep2View; +import haveno.desktop.main.portfolio.pendingtrades.steps.buyer.BuyerStep3View; +import haveno.desktop.util.Layout; + +import haveno.core.locale.Res; +import haveno.core.support.dispute.arbitration.ArbitrationManager; +import haveno.core.support.dispute.mediation.FileTransferSender; +import haveno.core.support.dispute.mediation.FileTransferSession; + +import haveno.common.UserThread; + +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.ProgressBar; +import javafx.scene.control.Separator; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Region; + +import javafx.geometry.HPos; +import javafx.geometry.Insets; +import javafx.geometry.Orientation; +import javafx.geometry.Pos; + +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.SimpleDoubleProperty; + +import java.io.IOException; + +import lombok.extern.slf4j.Slf4j; + +import static haveno.desktop.util.FormBuilder.addMultilineLabel; + +@Slf4j +public class SendLogFilesWindow extends Overlay implements FileTransferSession.FtpCallback { + + private final String tradeId; + private final int traderId; + private final ArbitrationManager arbitrationManager; + private Label statusLabel; + private Button sendButton, stopButton; + private final DoubleProperty ftpProgress = new SimpleDoubleProperty(-1); + TradeWizardItem step1, step2, step3; + private FileTransferSender fileTransferSender; + + public SendLogFilesWindow(String tradeId, int traderId, + ArbitrationManager arbitrationManager) { + this.tradeId = tradeId; + this.traderId = traderId; + this.arbitrationManager = arbitrationManager; + type = Type.Attention; + } + + public void show() { + headLine = Res.get("support.sendLogs.title"); + width = 668; + createGridPane(); + addHeadLine(); + addContent(); + addButtons(); + applyStyles(); + display(); + } + + @Override + protected void createGridPane() { + gridPane = new GridPane(); + gridPane.setHgap(5); + gridPane.setVgap(5); + gridPane.setPadding(new Insets(64, 64, 64, 64)); + gridPane.setPrefWidth(width); + } + + void addWizardsToGridPane(TradeWizardItem tradeWizardItem) { + GridPane.setRowIndex(tradeWizardItem, rowIndex++); + GridPane.setColumnIndex(tradeWizardItem, 0); + GridPane.setHalignment(tradeWizardItem, HPos.LEFT); + gridPane.getChildren().add(tradeWizardItem); + } + + void addLineSeparatorToGridPane() { + final Separator separator = new Separator(Orientation.VERTICAL); + separator.setMinHeight(22); + GridPane.setMargin(separator, new Insets(0, 0, 0, 13)); + GridPane.setHalignment(separator, HPos.LEFT); + GridPane.setRowIndex(separator, rowIndex++); + gridPane.getChildren().add(separator); + } + + void addRegionToGridPane() { + final Region region = new Region(); + region.setMinHeight(22); + GridPane.setMargin(region, new Insets(0, 0, 0, 13)); + GridPane.setRowIndex(region, rowIndex++); + gridPane.getChildren().add(region); + } + + private void addContent() { + this.hideCloseButton = true; + + addMultilineLabel(gridPane, ++rowIndex, Res.get("support.sendLogs.backgroundInfo"), 0); + addRegionToGridPane(); + + step1 = new TradeWizardItem(BuyerStep1View.class, Res.get("support.sendLogs.step1"), "1"); + step2 = new TradeWizardItem(BuyerStep2View.class, Res.get("support.sendLogs.step2"), "2"); + step3 = new TradeWizardItem(BuyerStep3View.class, Res.get("support.sendLogs.step3"), "3"); + + addRegionToGridPane(); + addRegionToGridPane(); + addWizardsToGridPane(step1); + addLineSeparatorToGridPane(); + addWizardsToGridPane(step2); + addLineSeparatorToGridPane(); + addWizardsToGridPane(step3); + addRegionToGridPane(); + + ProgressBar progressBar = new ProgressBar(); + progressBar.setMinHeight(19); + progressBar.setMaxHeight(19); + progressBar.setPrefWidth(9305); + progressBar.setVisible(false); + progressBar.progressProperty().bind(ftpProgress); + gridPane.add(progressBar, 0, ++rowIndex); + + statusLabel = addMultilineLabel(gridPane, ++rowIndex, "", -Layout.FLOATING_LABEL_DISTANCE); + statusLabel.getStyleClass().add("sub-info"); + addRegionToGridPane(); + + sendButton = new AutoTooltipButton(Res.get("support.sendLogs.send")); + stopButton = new AutoTooltipButton(Res.get("support.sendLogs.cancel")); + stopButton.setDisable(true); + closeButton = new AutoTooltipButton(Res.get("shared.close")); + sendButton.setOnAction(e -> { + try { + progressBar.setVisible(true); + if (fileTransferSender == null) { + setActiveStep(1); + statusLabel.setText(Res.get("support.sendLogs.init")); + fileTransferSender = arbitrationManager.initLogUpload(this, tradeId, traderId); + UserThread.runAfter(() -> { + fileTransferSender.createZipFileToSend(); + setActiveStep(2); + UserThread.runAfter(() -> { + setActiveStep(3); + try { + fileTransferSender.initSend(); + } catch (IOException ioe) { + log.error(ioe.toString()); + statusLabel.setText(ioe.toString()); + ioe.printStackTrace(); + } + }, 1); + }, 1); + sendButton.setDisable(true); + stopButton.setDisable(false); + } else { + // resend the latest block in the event of a timeout + statusLabel.setText(Res.get("support.sendLogs.retry")); + fileTransferSender.retrySend(); + sendButton.setDisable(true); + } + } catch (IOException ex) { + log.error(ex.toString()); + statusLabel.setText(ex.toString()); + ex.printStackTrace(); + } + }); + stopButton.setOnAction(e -> { + if (fileTransferSender != null) { + fileTransferSender.resetSession(); + statusLabel.setText(Res.get("support.sendLogs.stopped")); + stopButton.setDisable(true); + } + }); + closeButton.setOnAction(e -> { + hide(); + closeHandlerOptional.ifPresent(Runnable::run); + }); + HBox hBox = new HBox(); + hBox.setSpacing(10); + hBox.setAlignment(Pos.CENTER_RIGHT); + GridPane.setRowIndex(hBox, ++rowIndex); + GridPane.setColumnSpan(hBox, 2); + GridPane.setColumnIndex(hBox, 0); + hBox.getChildren().addAll(sendButton, stopButton, closeButton); + gridPane.getChildren().add(hBox); + GridPane.setMargin(hBox, new Insets(10, 0, 0, 0)); + } + + void setActiveStep(int step) { + if (step < 1) { + step1.setDisabled(); + step2.setDisabled(); + step3.setDisabled(); + } else if (step == 1) { + step1.setActive(); + } else if (step == 2) { + step1.setCompleted(); + step2.setActive(); + } else if (step == 3) { + step2.setCompleted(); + step3.setActive(); + } else { + step3.setCompleted(); + } + } + + @Override + public void onFtpProgress(double progressPct) { + UserThread.execute(() -> { + if (progressPct > 0.0) { + statusLabel.setText(String.format(Res.get("support.sendLogs.progress"), progressPct * 100)); + sendButton.setDisable(true); + } + ftpProgress.set(progressPct); + }); + } + @Override + public void onFtpComplete(FileTransferSession session) { + UserThread.execute(() -> { + setActiveStep(4); // all finished + statusLabel.setText(Res.get("support.sendLogs.finished")); + stopButton.setDisable(true); + }); + } + + @Override + public void onFtpTimeout(String statusMsg, FileTransferSession session) { + UserThread.execute(() -> { + statusLabel.setText(statusMsg + "\r\n" + Res.get("support.sendLogs.command")); + sendButton.setDisable(false); + }); + } +} diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/pendingtrades/PendingTradesView.java b/desktop/src/main/java/haveno/desktop/main/portfolio/pendingtrades/PendingTradesView.java index 5da38d8728..0bde7cfd70 100644 --- a/desktop/src/main/java/haveno/desktop/main/portfolio/pendingtrades/PendingTradesView.java +++ b/desktop/src/main/java/haveno/desktop/main/portfolio/pendingtrades/PendingTradesView.java @@ -443,7 +443,7 @@ public class PendingTradesView extends ActivatableViewAndModel disputeDirectMessageListListener; private Subscription inputTextAreaTextSubscription; private final List tempAttachments = new ArrayList<>(); - private ChangeListener storedInMailboxPropertyListener, arrivedPropertyListener; + private ChangeListener storedInMailboxPropertyListener, acknowledgedPropertyListener; private ChangeListener sendMessageErrorPropertyListener; - protected final CoinFormatter formatter; private EventHandler keyEventEventHandler; private SupportManager supportManager; private Optional optionalSupportSession = Optional.empty(); private String counterpartyName; - public ChatView(SupportManager supportManager, CoinFormatter formatter, String counterpartyName) { + public ChatView(SupportManager supportManager, String counterpartyName) { this.supportManager = supportManager; - this.formatter = formatter; this.counterpartyName = counterpartyName; allowAttachments = true; displayHeader = true; @@ -157,7 +171,7 @@ public class ChatView extends AnchorPane { } public void display(SupportSession supportSession, - @Nullable Button extraButton, + @Nullable Node extraButton, ReadOnlyDoubleProperty widthProperty) { optionalSupportSession = Optional.of(supportSession); removeListenersOnSessionChange(); @@ -201,6 +215,10 @@ public class ChatView extends AnchorPane { Button uploadButton = new AutoTooltipButton(Res.get("support.addAttachments")); uploadButton.setOnAction(e -> onRequestUpload()); + Button clipboardButton = new AutoTooltipButton(Res.get("shared.copyToClipboard")); + clipboardButton.setOnAction(e -> copyChatMessagesToClipboard(clipboardButton)); + uploadButton.setStyle("-fx-pref-width: 125; -fx-padding: 3 3 3 3;"); + clipboardButton.setStyle("-fx-pref-width: 125; -fx-padding: 3 3 3 3;"); sendMsgInfoLabel = new AutoTooltipLabel(); sendMsgInfoLabel.setVisible(false); @@ -216,12 +234,11 @@ public class ChatView extends AnchorPane { HBox buttonBox = new HBox(); buttonBox.setSpacing(10); if (allowAttachments) - buttonBox.getChildren().addAll(sendButton, uploadButton, sendMsgBusyAnimation, sendMsgInfoLabel); + buttonBox.getChildren().addAll(sendButton, uploadButton, clipboardButton, sendMsgBusyAnimation, sendMsgInfoLabel); else buttonBox.getChildren().addAll(sendButton, sendMsgBusyAnimation, sendMsgInfoLabel); if (extraButton != null) { - extraButton.setDefaultButton(true); Pane spacer = new Pane(); HBox.setHgrow(spacer, Priority.ALWAYS); buttonBox.getChildren().addAll(spacer, extraButton); @@ -329,7 +346,7 @@ public class ChatView extends AnchorPane { bg.setId("message-bubble-green"); messageLabel.getStyleClass().add("my-message"); copyIcon.getStyleClass().add("my-message"); - message.addChangeListener(() -> updateMsgState(message)); + message.addWeakMessageStateListener(() -> updateMsgState(message)); updateMsgState(message); } else if (isMyMsg) { headerLabel.getStyleClass().add("my-message-header"); @@ -350,7 +367,7 @@ public class ChatView extends AnchorPane { }; sendMsgBusyAnimation.isRunningProperty().addListener(sendMsgBusyAnimationListener); - message.addChangeListener(() -> updateMsgState(message)); + message.addWeakMessageStateListener(() -> updateMsgState(message)); updateMsgState(message); } else { headerLabel.getStyleClass().add("message-header"); @@ -401,13 +418,13 @@ public class ChatView extends AnchorPane { String metaData = DisplayUtils.formatDateTime(new Date(message.getDate())); if (!message.isSystemMessage()) metaData = (isMyMsg ? "Sent " : "Received ") + metaData - + (isMyMsg ? "" : " from " + counterpartyName); + + (isMyMsg ? "" : " from " + counterpartyName); headerLabel.setText(metaData); messageLabel.setText(message.getMessage()); attachmentsBox.getChildren().clear(); if (allowAttachments && message.getAttachments() != null && - !message.getAttachments().isEmpty()) { + message.getAttachments().size() > 0) { AnchorPane.setBottomAnchor(messageLabel, bottomBorder + attachmentsBoxHeight + 10); attachmentsBox.getChildren().add(new AutoTooltipLabel(Res.get("support.attachments") + " ") {{ setPadding(new Insets(0, 0, 3, 0)); @@ -466,6 +483,10 @@ public class ChatView extends AnchorPane { visible = true; icon = AwesomeIcon.OK_SIGN; text = Res.get("support.acknowledged"); + } else if (message.storedInMailboxProperty().get()) { + visible = true; + icon = AwesomeIcon.ENVELOPE; + text = Res.get("support.savedInMailbox"); } else if (message.ackErrorProperty().get() != null) { visible = true; icon = AwesomeIcon.EXCLAMATION_SIGN; @@ -474,17 +495,13 @@ public class ChatView extends AnchorPane { statusInfoLabel.getStyleClass().add("error-text"); } else if (message.arrivedProperty().get()) { visible = true; - icon = AwesomeIcon.OK; - text = Res.get("support.arrived"); - } else if (message.storedInMailboxProperty().get()) { - visible = true; - icon = AwesomeIcon.ENVELOPE; - text = Res.get("support.savedInMailbox"); + icon = AwesomeIcon.MAIL_REPLY; + text = Res.get("support.transient"); } else { visible = false; log.debug("updateMsgState called but no msg state available. message={}", message); } - + statusHBox.setVisible(visible); if (visible) { AwesomeDude.setIcon(statusIcon, icon, "14"); @@ -529,7 +546,7 @@ public class ChatView extends AnchorPane { int maxMsgSize = Connection.getPermittedMessageSize(); int maxSizeInKB = maxMsgSize / 1024; fileChooser.setTitle(Res.get("support.openFile", maxSizeInKB)); - /* if (Utilities.isUnix()) + /* if (Utilities.isUnix()) fileChooser.setInitialDirectory(new File(System.getProperty("user.home")));*/ File result = fileChooser.showOpenDialog(getScene().getWindow()); if (result != null) { @@ -561,13 +578,51 @@ public class ChatView extends AnchorPane { } } + public void onAttachText(String textAttachment, String name) { + if (!allowAttachments) + return; + try { + byte[] filesAsBytes = textAttachment.getBytes("UTF8"); + int size = filesAsBytes.length; + int maxMsgSize = Connection.getPermittedMessageSize(); + int maxSizeInKB = maxMsgSize / 1024; + if (size > maxMsgSize) { + new Popup().warning(Res.get("support.attachmentTooLarge", (size / 1024), maxSizeInKB)).show(); + } else { + tempAttachments.add(new Attachment(name, filesAsBytes)); + inputTextArea.setText(inputTextArea.getText() + "\n[" + Res.get("support.attachment") + " " + name + "]"); + } + } catch (Exception e) { + log.error(e.toString()); + e.printStackTrace(); + } + } + + private void copyChatMessagesToClipboard(Button sourceBtn) { + optionalSupportSession.ifPresent(session -> { + StringBuilder stringBuilder = new StringBuilder(); + chatMessages.forEach(i -> { + String metaData = DisplayUtils.formatDateTime(new Date(i.getDate())); + metaData = metaData + (i.isSystemMessage() ? " (System message)" : + (i.isSenderIsTrader() ? " (from Trader)" : " (from Agent)")); + stringBuilder.append(metaData).append("\n").append(i.getMessage()).append("\n\n"); + }); + Utilities.copyToClipboard(stringBuilder.toString()); + new Notification() + .notification(Res.get("shared.copiedToClipboard")) + .hideCloseButton() + .autoClose() + .show(); + }); + } + private void onOpenAttachment(Attachment attachment) { if (!allowAttachments) return; FileChooser fileChooser = new FileChooser(); fileChooser.setTitle(Res.get("support.save")); fileChooser.setInitialFileName(attachment.getFileName()); - /* if (Utilities.isUnix()) + /* if (Utilities.isUnix()) fileChooser.setInitialDirectory(new File(System.getProperty("user.home")));*/ File file = fileChooser.showSaveDialog(getScene().getWindow()); if (file != null) { @@ -582,7 +637,7 @@ public class ChatView extends AnchorPane { private void onSendMessage(String inputText) { if (chatMessage != null) { - chatMessage.arrivedProperty().removeListener(arrivedPropertyListener); + chatMessage.acknowledgedProperty().removeListener(acknowledgedPropertyListener); chatMessage.storedInMailboxProperty().removeListener(storedInMailboxPropertyListener); chatMessage.sendMessageErrorProperty().removeListener(sendMessageErrorPropertyListener); } @@ -594,6 +649,8 @@ public class ChatView extends AnchorPane { inputTextArea.setDisable(true); inputTextArea.clear(); + chatMessage.startAckTimer(); + Timer timer = UserThread.runAfter(() -> { sendMsgInfoLabel.setVisible(true); sendMsgInfoLabel.setManaged(true); @@ -602,8 +659,9 @@ public class ChatView extends AnchorPane { sendMsgBusyAnimation.play(); }, 500, TimeUnit.MILLISECONDS); - arrivedPropertyListener = (observable, oldValue, newValue) -> { + acknowledgedPropertyListener = (observable, oldValue, newValue) -> { if (newValue) { + sendMsgInfoLabel.setVisible(false); hideSendMsgInfo(timer); } }; @@ -624,7 +682,7 @@ public class ChatView extends AnchorPane { } }; if (chatMessage != null) { - chatMessage.arrivedProperty().addListener(arrivedPropertyListener); + chatMessage.acknowledgedProperty().addListener(acknowledgedPropertyListener); chatMessage.storedInMailboxProperty().addListener(storedInMailboxPropertyListener); chatMessage.sendMessageErrorProperty().addListener(sendMessageErrorPropertyListener); } @@ -697,15 +755,12 @@ public class ChatView extends AnchorPane { } private void removeListenersOnSessionChange() { - if (chatMessages != null) { - if (disputeDirectMessageListListener != null) chatMessages.removeListener(disputeDirectMessageListListener); - chatMessages.forEach(ChatMessage::removeChangeListener); - } + if (chatMessages != null && disputeDirectMessageListListener != null) + chatMessages.removeListener(disputeDirectMessageListListener); if (chatMessage != null) { - chatMessage.removeChangeListener(); - if (arrivedPropertyListener != null) - chatMessage.arrivedProperty().removeListener(arrivedPropertyListener); + if (acknowledgedPropertyListener != null) + chatMessage.arrivedProperty().removeListener(acknowledgedPropertyListener); if (storedInMailboxPropertyListener != null) chatMessage.storedInMailboxProperty().removeListener(storedInMailboxPropertyListener); } @@ -722,4 +777,4 @@ public class ChatView extends AnchorPane { inputTextAreaTextSubscription.unsubscribe(); } -} +} \ No newline at end of file diff --git a/desktop/src/main/java/haveno/desktop/main/support/dispute/DisputeChatPopup.java b/desktop/src/main/java/haveno/desktop/main/support/dispute/DisputeChatPopup.java index 41e0342539..50662ce750 100644 --- a/desktop/src/main/java/haveno/desktop/main/support/dispute/DisputeChatPopup.java +++ b/desktop/src/main/java/haveno/desktop/main/support/dispute/DisputeChatPopup.java @@ -1,5 +1,5 @@ /* - * This file is part of Bisq. + * This file is part of haveno. * * Bisq is free software: you can redistribute it and/or modify it * under the terms of the GNU Affero General Public License as published by @@ -12,56 +12,68 @@ * License for more details. * * You should have received a copy of the GNU Affero General Public License - * along with Bisq. If not, see . + * along with haveno. If not, see . */ package haveno.desktop.main.support.dispute; -import haveno.common.UserThread; +import haveno.desktop.components.AutoTooltipButton; +import haveno.desktop.main.MainView; +import haveno.desktop.main.shared.ChatView; +import haveno.desktop.util.CssTheme; +import haveno.desktop.util.DisplayUtils; + import haveno.core.locale.Res; import haveno.core.support.dispute.Dispute; import haveno.core.support.dispute.DisputeList; import haveno.core.support.dispute.DisputeManager; import haveno.core.support.dispute.DisputeSession; +import haveno.core.support.messages.ChatMessage; import haveno.core.user.Preferences; import haveno.core.util.coin.CoinFormatter; -import haveno.desktop.components.AutoTooltipButton; -import haveno.desktop.main.MainView; -import haveno.desktop.main.shared.ChatView; -import haveno.desktop.util.CssTheme; -import javafx.beans.value.ChangeListener; -import javafx.scene.Scene; -import javafx.scene.control.Button; -import javafx.scene.input.KeyCode; -import javafx.scene.input.KeyEvent; -import javafx.scene.layout.AnchorPane; -import javafx.scene.layout.StackPane; + +import haveno.common.UserThread; + import javafx.stage.Modality; import javafx.stage.Stage; import javafx.stage.StageStyle; import javafx.stage.Window; + +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.MenuButton; +import javafx.scene.control.MenuItem; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.layout.AnchorPane; +import javafx.scene.layout.StackPane; + +import javafx.beans.value.ChangeListener; + +import java.util.Date; +import java.util.List; + import lombok.Getter; public class DisputeChatPopup { public interface ChatCallback { void onCloseDisputeFromChatWindow(Dispute dispute); + void onSendLogsFromChatWindow(Dispute dispute); } private Stage chatPopupStage; protected final DisputeManager> disputeManager; protected final CoinFormatter formatter; protected final Preferences preferences; - private ChatCallback chatCallback; + private final ChatCallback chatCallback; private double chatPopupStageXPosition = -1; private double chatPopupStageYPosition = -1; - private ChangeListener xPositionListener; - private ChangeListener yPositionListener; @Getter private Dispute selectedDispute; DisputeChatPopup(DisputeManager> disputeManager, - CoinFormatter formatter, - Preferences preferences, - ChatCallback chatCallback) { + CoinFormatter formatter, + Preferences preferences, + ChatCallback chatCallback) { this.disputeManager = disputeManager; this.formatter = formatter; this.preferences = preferences; @@ -84,7 +96,7 @@ public class DisputeChatPopup { selectedDispute.getChatMessages().forEach(m -> m.setWasDisplayed(true)); disputeManager.requestPersistence(); - ChatView chatView = new ChatView(disputeManager, formatter, counterpartyName); + ChatView chatView = new ChatView(disputeManager, counterpartyName); chatView.setAllowAttachments(true); chatView.setDisplayHeader(false); chatView.initialize(); @@ -96,12 +108,27 @@ public class DisputeChatPopup { AnchorPane.setTopAnchor(chatView, -20d); AnchorPane.setBottomAnchor(chatView, 10d); pane.getStyleClass().add("dispute-chat-border"); - Button closeDisputeButton = null; - if (!selectedDispute.isClosed() && !disputeManager.isTrader(selectedDispute)) { - closeDisputeButton = new AutoTooltipButton(Res.get("support.closeTicket")); - closeDisputeButton.setOnAction(e -> chatCallback.onCloseDisputeFromChatWindow(selectedDispute)); + if (selectedDispute.isClosed()) { + chatView.display(concreteDisputeSession, null, pane.widthProperty()); + } else { + if (disputeManager.isAgent(selectedDispute)) { + Button closeDisputeButton = new AutoTooltipButton(Res.get("support.closeTicket")); + closeDisputeButton.setDefaultButton(true); + closeDisputeButton.setOnAction(e -> chatCallback.onCloseDisputeFromChatWindow(selectedDispute)); + chatView.display(concreteDisputeSession, closeDisputeButton, pane.widthProperty()); + } else { + MenuButton menuButton = new MenuButton(Res.get("support.moreButton")); + MenuItem menuItem1 = new MenuItem(Res.get("support.uploadTraderChat")); + MenuItem menuItem2 = new MenuItem(Res.get("support.sendLogFiles")); + menuItem1.setOnAction(e -> doTextAttachment(chatView)); + setChatUploadEnabledState(menuItem1); + menuItem2.setOnAction(e -> chatCallback.onSendLogsFromChatWindow(selectedDispute)); + menuButton.getItems().addAll(menuItem1, menuItem2); + menuButton.getStyleClass().add("jfx-button"); + menuButton.setStyle("-fx-padding: 0 10 0 10;"); + chatView.display(concreteDisputeSession, menuButton, pane.widthProperty()); + } } - chatView.display(concreteDisputeSession, closeDisputeButton, pane.widthProperty()); chatView.activate(); chatView.scrollToBottom(); chatPopupStage = new Stage(); @@ -132,9 +159,9 @@ public class DisputeChatPopup { chatPopupStage.setOpacity(0); chatPopupStage.show(); - xPositionListener = (observable, oldValue, newValue) -> chatPopupStageXPosition = (double) newValue; + ChangeListener xPositionListener = (observable, oldValue, newValue) -> chatPopupStageXPosition = (double) newValue; chatPopupStage.xProperty().addListener(xPositionListener); - yPositionListener = (observable, oldValue, newValue) -> chatPopupStageYPosition = (double) newValue; + ChangeListener yPositionListener = (observable, oldValue, newValue) -> chatPopupStageYPosition = (double) newValue; chatPopupStage.yProperty().addListener(yPositionListener); if (chatPopupStageXPosition == -1) { @@ -149,8 +176,33 @@ public class DisputeChatPopup { // Delay display to next render frame to avoid that the popup is first quickly displayed in default position // and after a short moment in the correct position - UserThread.execute(() -> { - if (chatPopupStage != null) chatPopupStage.setOpacity(1); + UserThread.execute(() -> chatPopupStage.setOpacity(1)); + } + + private void doTextAttachment(ChatView chatView) { + disputeManager.findTrade(selectedDispute).ifPresent(t -> { + List chatMessages = t.getChatMessages(); + if (chatMessages.size() > 0) { + StringBuilder stringBuilder = new StringBuilder(); + chatMessages.forEach(i -> { + boolean isMyMsg = i.isSenderIsTrader(); + String metaData = DisplayUtils.formatDateTime(new Date(i.getDate())); + if (!i.isSystemMessage()) + metaData = (isMyMsg ? "Sent " : "Received ") + metaData + + (isMyMsg ? "" : " from Trader"); + stringBuilder.append(metaData).append("\n").append(i.getMessage()).append("\n\n"); + }); + String fileName = selectedDispute.getShortTradeId() + "_" + selectedDispute.getRoleStringForLogFile() + "_TraderChat.txt"; + chatView.onAttachText(stringBuilder.toString(), fileName); + } + }); + } + + private void setChatUploadEnabledState(MenuItem menuItem) { + disputeManager.findTrade(selectedDispute).ifPresentOrElse(t -> { + menuItem.setDisable(t.getChatMessages().size() == 0); + }, () -> { + menuItem.setDisable(true); }); } } diff --git a/desktop/src/main/java/haveno/desktop/main/support/dispute/DisputeView.java b/desktop/src/main/java/haveno/desktop/main/support/dispute/DisputeView.java index 2c41884563..f05409ec11 100644 --- a/desktop/src/main/java/haveno/desktop/main/support/dispute/DisputeView.java +++ b/desktop/src/main/java/haveno/desktop/main/support/dispute/DisputeView.java @@ -51,6 +51,7 @@ import haveno.core.support.dispute.DisputeManager; import haveno.core.support.dispute.DisputeResult; import haveno.core.support.dispute.DisputeSession; import haveno.core.support.dispute.agent.DisputeAgentLookupMap; +import haveno.core.support.dispute.arbitration.ArbitrationManager; import haveno.core.support.dispute.arbitration.arbitrator.ArbitratorManager; import haveno.core.support.dispute.mediation.MediationManager; import haveno.core.support.messages.ChatMessage; @@ -67,9 +68,12 @@ import haveno.desktop.components.AutoTooltipLabel; import haveno.desktop.components.AutoTooltipTableColumn; import haveno.desktop.components.HyperlinkWithIcon; import haveno.desktop.components.InputTextField; +import haveno.desktop.components.PeerInfoIconDispute; +import haveno.desktop.components.PeerInfoIconMap; import haveno.desktop.main.overlays.popups.Popup; import haveno.desktop.main.overlays.windows.ContractWindow; import haveno.desktop.main.overlays.windows.DisputeSummaryWindow; +import haveno.desktop.main.overlays.windows.SendLogFilesWindow; import haveno.desktop.main.overlays.windows.SendPrivateNotificationWindow; import haveno.desktop.main.overlays.windows.TradeDetailsWindow; import haveno.desktop.main.overlays.windows.VerifyDisputeResultSignatureWindow; @@ -119,7 +123,7 @@ import java.util.concurrent.atomic.AtomicReference; import static haveno.desktop.util.FormBuilder.getIconForLabel; import static haveno.desktop.util.FormBuilder.getRegularIconButton; -public abstract class DisputeView extends ActivatableView { +public abstract class DisputeView extends ActivatableView implements DisputeChatPopup.ChatCallback { public enum FilterResult { NO_MATCH("No Match"), NO_FILTER("No filter text"), @@ -181,6 +185,8 @@ public abstract class DisputeView extends ActivatableView { private Map chatButtonByDispute = new HashMap<>(); private Map chatBadgeByDispute = new HashMap<>(); private Map newBadgeByDispute = new HashMap<>(); + @SuppressWarnings("MismatchedQueryAndUpdateOfCollection") + private final PeerInfoIconMap avatarMap = new PeerInfoIconMap(); protected DisputeChatPopup chatPopup; @@ -212,8 +218,7 @@ public abstract class DisputeView extends ActivatableView { this.accountAgeWitnessService = accountAgeWitnessService; this.arbitratorManager = arbitratorManager; this.useDevPrivilegeKeys = useDevPrivilegeKeys; - DisputeChatPopup.ChatCallback chatCallback = this::handleOnProcessDispute; - chatPopup = new DisputeChatPopup(disputeManager, formatter, preferences, chatCallback); + chatPopup = new DisputeChatPopup(disputeManager, formatter, preferences, this); } @Override @@ -223,6 +228,7 @@ public abstract class DisputeView extends ActivatableView { HBox.setHgrow(label, Priority.NEVER); filterTextField = new InputTextField(); + filterTextField.setPromptText(Res.get("support.filter.prompt")); Tooltip tooltip = new Tooltip(); tooltip.setShowDelay(Duration.millis(100)); tooltip.setShowDuration(Duration.seconds(10)); @@ -382,7 +388,9 @@ public abstract class DisputeView extends ActivatableView { ObservableList chatMessages = dispute.getChatMessages(); // If last message is not a result message we re-open as we might have received a new message from the // trader/mediator/arbitrator who has reopened the case - if (!chatMessages.isEmpty() && !chatMessages.get(chatMessages.size() - 1).isResultMessage(dispute)) { + if (!chatMessages.isEmpty() && + !chatMessages.get(chatMessages.size() - 1).isResultMessage(dispute) && + dispute.unreadMessageCount(senderFlag()) > 0) { onSelectDispute(dispute); reOpenDispute(); } @@ -428,7 +436,8 @@ public abstract class DisputeView extends ActivatableView { // For open filter we do not want to continue further as json data would cause a match if (filter.equalsIgnoreCase("open")) { - return !dispute.isClosed() ? FilterResult.OPEN_DISPUTES : FilterResult.NO_MATCH; + return !dispute.isClosed() || dispute.unreadMessageCount(senderFlag()) > 0 ? + FilterResult.OPEN_DISPUTES : FilterResult.NO_MATCH; } if (dispute.getTradeId().toLowerCase().contains(filter)) { @@ -1083,7 +1092,8 @@ public abstract class DisputeView extends ActivatableView { private TableColumn getDateColumn() { TableColumn column = new AutoTooltipTableColumn<>(Res.get("shared.date")) { { - setMinWidth(180); + setMinWidth(100); + setPrefWidth(150); } }; column.setCellValueFactory((dispute) -> new ReadOnlyObjectWrapper<>(dispute.getValue())); @@ -1109,7 +1119,8 @@ public abstract class DisputeView extends ActivatableView { private TableColumn getTradeIdColumn() { TableColumn column = new AutoTooltipTableColumn<>(Res.get("shared.tradeId")) { { - setMinWidth(110); + setMinWidth(50); + setPrefWidth(100); } }; column.setCellValueFactory((dispute) -> new ReadOnlyObjectWrapper<>(dispute.getValue())); @@ -1167,10 +1178,14 @@ public abstract class DisputeView extends ActivatableView { @Override public void updateItem(final Dispute item, boolean empty) { super.updateItem(item, empty); - if (item != null && !empty) + if (item != null && !empty) { setText(getBuyerOnionAddressColumnLabel(item)); - else + PeerInfoIconDispute peerInfoIconDispute = createAvatar(tableRowProperty().get().getIndex(), item, true); + setGraphic(peerInfoIconDispute); + } else { setText(""); + setText(null); + } } }; } @@ -1193,10 +1208,14 @@ public abstract class DisputeView extends ActivatableView { @Override public void updateItem(final Dispute item, boolean empty) { super.updateItem(item, empty); - if (item != null && !empty) + if (item != null && !empty) { setText(getSellerOnionAddressColumnLabel(item)); - else + PeerInfoIconDispute peerInfoIconDispute = createAvatar(tableRowProperty().get().getIndex(), item, false); + setGraphic(peerInfoIconDispute); + } else { setText(""); + setGraphic(null); + } } }; } @@ -1314,8 +1333,8 @@ public abstract class DisputeView extends ActivatableView { return; } - String keyBaseUserName = DisputeAgentLookupMap.getMatrixUserName(agentNodeAddress.getFullAddress()); - setText(keyBaseUserName); + String MatrixUserName = DisputeAgentLookupMap.getMatrixUserName(agentNodeAddress.getFullAddress()); + setText(MatrixUserName); } else { setText(""); } @@ -1448,4 +1467,36 @@ public abstract class DisputeView extends ActivatableView { return (disputeManager instanceof MediationManager) ? Res.get("shared.mediator") : Res.get("shared.refundAgent"); } } + + private PeerInfoIconDispute createAvatar(Integer tableRowId, Dispute dispute, boolean isBuyer) { + NodeAddress nodeAddress = isBuyer ? dispute.getContract().getBuyerNodeAddress() : dispute.getContract().getSellerNodeAddress(); + String key = tableRowId + nodeAddress.getHostNameWithoutPostFix() + (isBuyer ? "BUYER" : "SELLER"); + Long accountAge = isBuyer ? + accountAgeWitnessService.getAccountAge(dispute.getBuyerPaymentAccountPayload(), dispute.getContract().getBuyerPubKeyRing()) : + accountAgeWitnessService.getAccountAge(dispute.getSellerPaymentAccountPayload(), dispute.getContract().getSellerPubKeyRing()); + PeerInfoIconDispute peerInfoIcon = new PeerInfoIconDispute( + nodeAddress, + disputeManager.getNrOfDisputes(isBuyer, dispute.getContract()), + accountAge, + preferences); + avatarMap.put(key, peerInfoIcon); // TODO + return peerInfoIcon; + } + + @Override + public void onCloseDisputeFromChatWindow(Dispute dispute) { + if (dispute.getDisputeState() == Dispute.State.NEW || dispute.getDisputeState() == Dispute.State.OPEN) { + handleOnProcessDispute(dispute); + } else { + closeDisputeFromButton(); + } + } + + @Override + public void onSendLogsFromChatWindow(Dispute dispute) { + if (!(disputeManager instanceof ArbitrationManager)) + return; + ArbitrationManager arbitrationManager = (ArbitrationManager) disputeManager; + new SendLogFilesWindow(dispute.getTradeId(), dispute.getTraderId(), arbitrationManager).show(); + } } diff --git a/p2p/src/main/java/haveno/network/p2p/AckMessageSourceType.java b/p2p/src/main/java/haveno/network/p2p/AckMessageSourceType.java index d74c09ebad..acea619f4d 100644 --- a/p2p/src/main/java/haveno/network/p2p/AckMessageSourceType.java +++ b/p2p/src/main/java/haveno/network/p2p/AckMessageSourceType.java @@ -24,5 +24,6 @@ public enum AckMessageSourceType { ARBITRATION_MESSAGE, MEDIATION_MESSAGE, TRADE_CHAT_MESSAGE, - REFUND_MESSAGE + REFUND_MESSAGE, + LOG_TRANSFER } diff --git a/p2p/src/main/java/haveno/network/p2p/FileTransferPart.java b/p2p/src/main/java/haveno/network/p2p/FileTransferPart.java new file mode 100644 index 0000000000..624b782d28 --- /dev/null +++ b/p2p/src/main/java/haveno/network/p2p/FileTransferPart.java @@ -0,0 +1,106 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package haveno.network.p2p; + +import haveno.common.app.Version; +import haveno.common.proto.network.NetworkEnvelope; + +import com.google.protobuf.ByteString; + +import lombok.EqualsAndHashCode; +import lombok.Value; + +@EqualsAndHashCode(callSuper = true) +@Value +public class FileTransferPart extends NetworkEnvelope implements ExtendedDataSizePermission, SendersNodeAddressMessage { + NodeAddress senderNodeAddress; + public String uid; + public String tradeId; + public int traderId; + public long seqNumOrFileLength; + public ByteString messageData; // if message_data is empty it is the first message, requesting file upload permission + + public FileTransferPart(NodeAddress senderNodeAddress, + String tradeId, + int traderId, + String uid, + long seqNumOrFileLength, + ByteString messageData) { + this(senderNodeAddress, tradeId, traderId, uid, seqNumOrFileLength, messageData, Version.getP2PMessageVersion()); + } + + public boolean isInitialRequest() { + return messageData.size() == 0; + } + + /////////////////////////////////////////////////////////////////////////////////////////// + // PROTO BUFFER + /////////////////////////////////////////////////////////////////////////////////////////// + + private FileTransferPart(NodeAddress senderNodeAddress, + String tradeId, + int traderId, + String uid, + long seqNumOrFileLength, + ByteString messageData, + String messageVersion) { + super(messageVersion); + this.senderNodeAddress = senderNodeAddress; + this.tradeId = tradeId; + this.traderId = traderId; + this.uid = uid; + this.seqNumOrFileLength = seqNumOrFileLength; + this.messageData = messageData; + } + + @Override + public protobuf.NetworkEnvelope toProtoNetworkEnvelope() { + return getNetworkEnvelopeBuilder() + .setFileTransferPart(protobuf.FileTransferPart.newBuilder() + .setSenderNodeAddress(senderNodeAddress.toProtoMessage()) + .setTradeId(tradeId) + .setTraderId(traderId) + .setUid(uid) + .setSeqNumOrFileLength(seqNumOrFileLength) + .setMessageData(messageData) + .build()) + .build(); + } + + public static FileTransferPart fromProto(protobuf.FileTransferPart proto, String messageVersion) { + return new FileTransferPart( + NodeAddress.fromProto(proto.getSenderNodeAddress()), + proto.getTradeId(), + proto.getTraderId(), + proto.getUid(), + proto.getSeqNumOrFileLength(), + proto.getMessageData(), + messageVersion); + } + + @Override + public String toString() { + return "FileTransferPart{" + + "\n senderNodeAddress='" + senderNodeAddress.getHostNameForDisplay() + '\'' + + ",\n uid='" + uid + '\'' + + ",\n tradeId='" + tradeId + '\'' + + ",\n traderId='" + traderId + '\'' + + ",\n seqNumOrFileLength=" + seqNumOrFileLength + + "\n} " + super.toString(); + } +} diff --git a/proto/src/main/proto/pb.proto b/proto/src/main/proto/pb.proto index 558e40052d..daf9c26934 100644 --- a/proto/src/main/proto/pb.proto +++ b/proto/src/main/proto/pb.proto @@ -64,6 +64,8 @@ message NetworkEnvelope { MediatedPayoutTxSignatureMessage mediated_payout_tx_signature_message = 37; MediatedPayoutTxPublishedMessage mediated_payout_tx_published_message = 38; + + FileTransferPart file_transfer_part = 39; } } @@ -100,6 +102,15 @@ message GetUpdatedDataRequest { string version = 4; } +message FileTransferPart { + NodeAddress sender_node_address = 1; + string uid = 2; + string trade_id = 3; + int32 trader_id = 4; + int64 seq_num_or_file_length = 5; + bytes message_data = 6; +} + message GetPeersRequest { NodeAddress sender_node_address = 1; int32 nonce = 2;