From 755d5e29f005ce320e61dff9de6b661b6d6c97e2 Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Wed, 21 Feb 2024 18:19:59 +0100 Subject: [PATCH] Implement fallback for text messages larger than the max message size to sent them in chunks via the send files API --- public/lang/en.json | 2 + public/scripts/network.js | 159 +++++++++++++++++++++++++++++--------- public/scripts/ui.js | 126 +++++++++++++++--------------- 3 files changed, 190 insertions(+), 97 deletions(-) diff --git a/public/lang/en.json b/public/lang/en.json index 4a1bd7b..976d1d7 100644 --- a/public/lang/en.json +++ b/public/lang/en.json @@ -93,6 +93,7 @@ "file-other-description-file": "and 1 other file", "file-other-description-image-plural": "and {{count}} other images", "file-other-description-file-plural": "and {{count}} other files", + "text-message-description": "A large text message", "title-image": "Image", "title-file": "File", "title-image-plural": "Images", @@ -171,6 +172,7 @@ "file-received-plural": "{{count}} Files Received", "file-transfer-requested": "File Transfer Requested", "image-transfer-requested": "Image Transfer Requested", + "message-transfer-requested": "Message Transfer Requested", "message-received": "Message Received", "message-received-plural": "{{count}} Messages Received" }, diff --git a/public/scripts/network.js b/public/scripts/network.js index af4900c..d606884 100644 --- a/public/scripts/network.js +++ b/public/scripts/network.js @@ -331,6 +331,8 @@ class Peer { this._isCaller = isCaller; this._peerId = peerId; + this._maxMessageSize = 65536; // 64 KB + this._roomIds = {}; this._updateRoomIds(roomType, roomId); @@ -352,14 +354,15 @@ class Peer { // tidy up sender this._filesRequested = null; + this._requestSent = null; this._chunker = null; // tidy up receiver - this._pendingRequest = null; - this._acceptedRequest = null; + this._requestPending = null; + this._requestAccepted = null; this._totalBytesReceived = 0; this._digester = null; - this._filesReceived = []; + this._filesReceived = null; // disable NoSleep if idle Events.fire('evaluate-no-sleep'); @@ -497,7 +500,7 @@ class Peer { await this._onState(message.state); break; case 'transfer-request': - await this._onTransferRequest(message); + await this._onTransferRequest(message.request); break; case 'transfer-request-response': this._onTransferRequestResponse(message); @@ -624,7 +627,7 @@ class Peer { } // File Sender Only - async _sendFileTransferRequest(files) { + async _sendFileTransferRequest(files, fileIsMessage = false) { this._state = Peer.STATE_PREPARE; Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'prepare'}); @@ -642,6 +645,13 @@ class Peer { if (files[i].type.split('/')[0] !== 'image') imagesOnly = false; } + // request type 'images', 'files' or 'message + const filesType = fileIsMessage + ? 'message' + : imagesOnly + ? 'images' + : 'files'; + let dataUrl = ""; if (files[0].type.split('/')[0] === 'image') { try { @@ -654,13 +664,18 @@ class Peer { this._state = Peer.STATE_TRANSFER_REQUEST_SENT; Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'wait'}); - this._filesRequested = files; - - this._sendMessage({type: 'transfer-request', + const request = { header: header, totalSize: totalSize, - imagesOnly: imagesOnly, + filesType: filesType, thumbnailDataUrl: dataUrl + }; + + this._filesRequested = files; + this._requestSent = request; + + this._sendMessage({type: 'transfer-request', + request: request }); } @@ -675,7 +690,7 @@ class Peer { if (message.reason === 'ram-exceed-ios') { Events.fire('notify-user', Localization.getTranslation('notifications.ram-exceed-ios')); } - Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: null}); + Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'idle'}); this._reset(); return; } @@ -748,7 +763,7 @@ class Peer { if (!message.success) { Logger.warn('File could not be sent'); - Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: null}); + Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'idle'}); this._reset(); return; } @@ -760,6 +775,9 @@ class Peer { return; } + // If files sent was message -> abort and wait for text-received message + if (this._requestSent.filesType === 'message') return; + // No more files in queue. Transfer is complete this._reset(); Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'transfer-complete'}); @@ -770,7 +788,7 @@ class Peer { // File Receiver Only async _onTransferRequest(request) { // Only accept one request at a time per peer - if (this._pendingRequest) { + if (this._requestPending) { this._sendTransferRequestResponse(false); return; } @@ -790,7 +808,7 @@ class Peer { } this._state = Peer.STATE_TRANSFER_REQUEST_RECEIVED; - this._pendingRequest = request; + this._requestPending = request; // Automatically accept request if auto-accept is set to true via the Edit Paired Devices Dialog if (this._autoAccept) { @@ -827,7 +845,7 @@ class Peer { if (accepted) { this._state = Peer.STATE_RECEIVE_PROCEEDING; this._busy = true; - this._acceptedRequest = this._pendingRequest; + this._requestAccepted = this._requestPending; this._lastProgress = 0; this._totalBytesReceived = 0; this._filesReceived = []; @@ -892,7 +910,7 @@ class Peer { // While transferring -> round progress to 4th digit. After transferring, set it to 1. let progress = this._digester - ? Math.floor(1e4 * (this._totalBytesReceived + this._digester._bytesReceived) / this._acceptedRequest.totalSize) / 1e4 + ? Math.floor(1e4 * (this._totalBytesReceived + this._digester._bytesReceived) / this._requestAccepted.totalSize) / 1e4 : 1; Events.fire('set-progress', {peerId: this._peerId, progress: progress, status: 'receive'}); @@ -913,26 +931,34 @@ class Peer { this._singleFileReceiveComplete(file); // If less files received than header accepted -> wait for next file - if (this._filesReceived.length < this._acceptedRequest.header.length) return; + if (this._filesReceived.length < this._requestAccepted.header.length) return; // We are done receiving Events.fire('set-progress', {peerId: this._peerId, progress: 1, status: 'receive'}); + + // If filesType is 'message' evaluate files as text + if (this._requestAccepted.filesType === 'message') { + this._textReceivedAsFile(); + return; + } + + // fileType is 'images' or 'files' this._allFilesReceiveComplete(); } _fitsAcceptedHeader(header) { - if (!this._acceptedRequest) { + if (!this._requestAccepted) { return false; } const positionFile = this._filesReceived.length; - if (positionFile > this._acceptedRequest.header.length - 1) { + if (positionFile > this._requestAccepted.header.length - 1) { return false; } // Check if file header fits - const acceptedHeader = this._acceptedRequest.header[positionFile]; + const acceptedHeader = this._requestAccepted.header[positionFile]; const sameSize = header.size === acceptedHeader.size; const sameType = header.mime === acceptedHeader.mime; @@ -955,8 +981,11 @@ class Peer { // Log speed from request to receive Logger.log(`File received.\n\nSize: ${size} MB\tDuration: ${duration} s\tSpeed: ${speed} MB/s`); - // include for compatibility with 'Snapdrop & PairDrop for Android' app - Events.fire('file-received', file); + // Prevent App from downloading message txt file + if (this._requestAccepted.filesType !== 'message') { + // include for compatibility with 'Snapdrop & PairDrop for Android' app + Events.fire('file-received', file); + } this._filesReceived.push(file); @@ -967,39 +996,88 @@ class Peer { Events.fire('files-received', { peerId: this._peerId, files: this._filesReceived, - imagesOnly: this._acceptedRequest.imagesOnly, - totalSize: this._acceptedRequest.totalSize + filesType: this._requestAccepted.filesType, + totalSize: this._requestAccepted.totalSize }); this._reset(); } // Message Sender Only - _sendText(text) { + + _base64encode(text) { + return btoa(unescape(encodeURIComponent(text))); + } + + async _sendText(text) { this._state = Peer.STATE_TEXT_SENT; - const unescaped = btoa(unescape(encodeURIComponent(text))); - this._sendMessage({ type: 'text', text: unescaped }); + + // Send text base64 encoded + const base64encoded = this._base64encode(text); + const message = {type: 'text', text: base64encoded}; + + // If text too big for connection -> send as file instead + if (JSON.stringify(message).length > this._maxMessageSize) { + await this._sendTextAsFile(text); + return; + } + + this._sendMessage(message); + } + + async _sendTextAsFile(text) { + // send text in chunks by using the file transfer api + const file = new File([text], "pairdrop-message.txt", { type: 'text/plain' }); + await this._sendFileTransferRequest([file], true); } _onTextReceiveComplete() { - if (this._state !== Peer.STATE_TEXT_SENT) { + if (this._state !== Peer.STATE_TEXT_SENT && this._state !== Peer.STATE_TRANSFER_PROCEEDING) { this._sendState(); return; } this._reset(); + Events.fire('set-progress', { peerId: this._peerId, progress: 0, status: 'idle' }); Events.fire('notify-user', Localization.getTranslation("notifications.message-transfer-completed")); } // Message Receiver Only - _onText(message) { - if (!message.text) return; + _base64decodeMessage(base64encoded){ + let decoded = ""; try { - const escaped = decodeURIComponent(escape(atob(message.text))); - Events.fire('text-received', { text: escaped, peerId: this._peerId }); - this._sendMessage({ type: 'text-receive-complete' }); + decoded = decodeURIComponent(escape(atob(base64encoded))); } catch (e) { Logger.error(e); } + return decoded; + } + + _onText(message) { + if (this._state !== Peer.STATE_IDLE) { + this._abortTransfer(); + return; + } + + if (!message.text) return; + + const text = this._base64decodeMessage(message.text); + + Events.fire('text-received', { text: text, peerId: this._peerId }); + this._sendMessage({ type: 'text-receive-complete' }); + } + + _textReceivedAsFile() { + // Use FileReader to unpack text from file + const reader = new FileReader(); + reader.addEventListener("load", _ => { + Events.fire('text-received', { text: reader.result, peerId: this._peerId }); + }); + + reader.readAsText(this._filesReceived[0]); + + Events.fire('set-progress', { peerId: this._peerId, progress: 1, status: 'idle' }); + this._sendMessage({ type: 'text-receive-complete' }); + this._reset(); } } @@ -1114,6 +1192,9 @@ class RTCPeer extends Peer { _onConnectionStateChange() { Logger.debug('RTC: Connection state changed:', this._conn.connectionState); switch (this._conn.connectionState) { + case 'connected': + this._setMaxMessageSize(); + break; case 'disconnected': this._refresh(); break; @@ -1331,6 +1412,12 @@ class RTCPeer extends Peer { this._server.send(message); } + _setMaxMessageSize() { + this._maxMessageSize = this._conn && this._conn.sctp + ? this._conn.sctp.maxMessageSize + : 262144; // 256 kB + } + async _sendFile(file) { this._chunker = new FileChunkerRTC( file, @@ -1389,6 +1476,8 @@ class WSPeer extends Peer { this.rtcSupported = false; this.signalSuccessful = false; + this._maxMessageSize = 65536; // 64 KB + if (!this._isCaller) return; // we will listen for a caller this._sendSignal(); @@ -1607,8 +1696,8 @@ class PeersManager { await this.peers[message.to]._sendFileTransferRequest(files); } - _onSendText(message) { - this.peers[message.to]._sendText(message.text); + async _onSendText(message) { + await this.peers[message.to]._sendText(message.text); } _onPeerLeft(message) { @@ -1813,7 +1902,7 @@ class FileChunkerRTC extends FileChunker { this._chunkSize = peerConnection && peerConnection.sctp ? Math.min(peerConnection.sctp.maxMessageSize, 1048576) // 1 MB max - : 262144; // 256 KB + : 262144; // 256 kB this._peerConnection = peerConnection; this._dataChannel = dataChannel; diff --git a/public/scripts/ui.js b/public/scripts/ui.js index 10b8f46..0b188a4 100644 --- a/public/scripts/ui.js +++ b/public/scripts/ui.js @@ -423,8 +423,8 @@ class PeerUI { this._connected = false; this._currentProgress = 0; - this._currentStatus = null - this._oldStatus = null; + this._currentStatus = 'idle'; + this._oldStatus = 'idle'; this._progressQueue = []; @@ -604,14 +604,14 @@ class PeerUI { // on reconnect this.setStatus(this._oldStatus); - this._oldStatus = null; + this._oldStatus = 'idle'; this._connectionHash = connectionHash; } else { this._connected = false; - if (!this._oldStatus && this._currentStatus !== "connect") { + if (this._oldStatus === 'idle' && this._currentStatus !== "connect") { // save old status when reconnecting this._oldStatus = this._currentStatus; } @@ -787,10 +787,10 @@ class PeerUI { clearTimeout(this.statusTimeout); - if (!status) { + if (status === 'idle') { this.$el.removeAttribute('status'); this.$el.querySelector('.status').innerHTML = ''; - this._currentStatus = null; + this._currentStatus = 'idle'; return; } @@ -812,7 +812,7 @@ class PeerUI { if (["transfer-complete", "receive-complete", "error"].includes(status)) { this.statusTimeout = setTimeout(() => { - this.setProgress(0, null); + this.setProgress(0, 'idle'); }, 10000); } } @@ -1031,30 +1031,35 @@ class ReceiveDialog extends Dialog { } } - _parseFileData(displayName, connectionHash, files, imagesOnly, totalSize, badgeClassName) { - let fileOther = ""; - if (files.length === 2) { - fileOther = imagesOnly - ? Localization.getTranslation("dialogs.file-other-description-image") - : Localization.getTranslation("dialogs.file-other-description-file"); + _parseFileData(displayName, connectionHash, files, filesType, totalSize, badgeClassName) { + if (filesType === 'message') { + this.$fileOther.innerText = Localization.getTranslation("dialogs.text-message-description"); } - else if (files.length > 2) { - fileOther = imagesOnly - ? Localization.getTranslation("dialogs.file-other-description-image-plural", null, {count: files.length - 1}) - : Localization.getTranslation("dialogs.file-other-description-file-plural", null, {count: files.length - 1}); + else { + let fileOther = ""; + if (files.length === 2) { + fileOther = filesType === 'images' + ? Localization.getTranslation("dialogs.file-other-description-image") + : Localization.getTranslation("dialogs.file-other-description-file"); + } + else if (files.length > 2) { + fileOther = filesType === 'images' + ? Localization.getTranslation("dialogs.file-other-description-image-plural", null, {count: files.length - 1}) + : Localization.getTranslation("dialogs.file-other-description-file-plural", null, {count: files.length - 1}); + } + + const fileName = files[0].name; + const fileNameSplit = fileName.split('.'); + const fileExtension = '.' + fileNameSplit[fileNameSplit.length - 1]; + const fileStem = fileName.substring(0, fileName.length - fileExtension.length); + + this.$fileOther.innerText = fileOther; + this.$fileStem.innerText = fileStem; + this.$fileExtension.innerText = fileExtension; } - const fileName = files[0].name; - const fileNameSplit = fileName.split('.'); - const fileExtension = '.' + fileNameSplit[fileNameSplit.length - 1]; - const fileStem = fileName.substring(0, fileName.length - fileExtension.length); + this.$fileSize.innerText = this._formatFileSize(totalSize); - const fileSize = this._formatFileSize(totalSize); - - this.$fileOther.innerText = fileOther; - this.$fileStem.innerText = fileStem; - this.$fileExtension.innerText = fileExtension; - this.$fileSize.innerText = fileSize; this.$displayName.innerText = displayName; this.$displayName.title = connectionHash; this.$displayName.classList.remove("badge-room-ip", "badge-room-secret", "badge-room-public-id"); @@ -1070,12 +1075,12 @@ class ReceiveFileDialog extends ReceiveDialog { this.$downloadBtn = this.$el.querySelector('#download-btn'); this.$shareBtn = this.$el.querySelector('#share-btn'); - Events.on('files-received', e => this._onFilesReceived(e.detail.peerId, e.detail.files, e.detail.imagesOnly, e.detail.totalSize)); + Events.on('files-received', e => this._onFilesReceived(e.detail.peerId, e.detail.files, e.detail.filesType, e.detail.totalSize)); this._filesDataQueue = []; } - async _onFilesReceived(peerId, files, imagesOnly, totalSize) { - const descriptor = this._getDescriptor(files, imagesOnly); + async _onFilesReceived(peerId, files, filesType, totalSize) { + const descriptor = this._getDescriptor(files, filesType); const displayName = $(peerId).ui._displayName(); const connectionHash = $(peerId).ui._connectionHash; const badgeClassName = $(peerId).ui._badgeClassName(); @@ -1083,7 +1088,7 @@ class ReceiveFileDialog extends ReceiveDialog { this._filesDataQueue.push({ peerId: peerId, files: files, - imagesOnly: imagesOnly, + filesType: filesType, totalSize: totalSize, descriptor: descriptor, displayName: displayName, @@ -1126,7 +1131,7 @@ class ReceiveFileDialog extends ReceiveDialog { this._data.displayName, this._data.connectionHash, this._data.files, - this._data.imagesOnly, + this._data.filesType, this._data.totalSize, this._data.badgeClassName ); @@ -1152,15 +1157,15 @@ class ReceiveFileDialog extends ReceiveDialog { return window.iOS && this._data.totalSize > 250000000; } - _getDescriptor(files, imagesOnly) { + _getDescriptor(files, filesType) { let descriptor; if (files.length === 1) { - descriptor = imagesOnly + descriptor = filesType === 'images' ? Localization.getTranslation("dialogs.title-image") : Localization.getTranslation("dialogs.title-file"); } else { - descriptor = imagesOnly + descriptor = filesType === 'images' ? Localization.getTranslation("dialogs.title-image-plural") : Localization.getTranslation("dialogs.title-file-plural"); } @@ -1499,15 +1504,17 @@ class ReceiveRequestDialog extends ReceiveDialog { _showRequestDialog(request, peerId) { this.correspondingPeerId = peerId; - const transferRequestTitleTranslation = request.imagesOnly - ? Localization.getTranslation('document-titles.image-transfer-requested') - : Localization.getTranslation('document-titles.file-transfer-requested'); + const transferRequestTitleTranslation = request.filesType === 'message' + ? Localization.getTranslation('document-titles.message-transfer-requested') + : request.filesType === 'images' + ? Localization.getTranslation('document-titles.image-transfer-requested') + : Localization.getTranslation('document-titles.file-transfer-requested'); const displayName = $(peerId).ui._displayName(); const connectionHash = $(peerId).ui._connectionHash; const badgeClassName = $(peerId).ui._badgeClassName(); - this._parseFileData(displayName, connectionHash, request.header, request.imagesOnly, request.totalSize, badgeClassName); + this._parseFileData(displayName, connectionHash, request.header, request.filesType, request.totalSize, badgeClassName); this._addThumbnailToPreviewBox(request.thumbnailDataUrl); this.$receiveTitle.innerText = transferRequestTitleTranslation; @@ -2739,7 +2746,7 @@ class Notifications { Events.on('text-received', e => this._messageNotification(e.detail.text, e.detail.peerId)); - Events.on('files-received', e => this._downloadNotification(e.detail.files, e.detail.imagesOnly)); + Events.on('files-received', e => this._downloadNotification(e.detail.files, e.detail.filesType)); Events.on('files-transfer-request', e => this._requestNotification(e.detail.request, e.detail.peerId)); // Todo on 'files-transfer-request-abort' remove notification } @@ -2823,31 +2830,26 @@ class Notifications { } _requestNotification(request, peerId) { - if (document.visibilityState !== 'visible') { - let imagesOnly = request.header.every(header => header.mime.split('/')[0] === 'image'); - let displayName = $(peerId).querySelector('.name').textContent; + // Do not notify user if page is visible + if (document.visibilityState === 'visible') return; - let descriptor; - if (request.header.length === 1) { - descriptor = imagesOnly - ? Localization.getTranslation("dialogs.title-image") - : Localization.getTranslation("dialogs.title-file"); - } - else { - descriptor = imagesOnly - ? Localization.getTranslation("dialogs.title-image-plural") - : Localization.getTranslation("dialogs.title-file-plural"); - } + const clickToShowTranslation = Localization.getTranslation("notifications.click-to-show"); + const displayName = $(peerId).querySelector('.name').textContent; - let title = Localization - .getTranslation("notifications.request-title", null, { - name: displayName, - count: request.header.length, - descriptor: descriptor.toLowerCase() - }); + const transferRequestTitleTranslation = request.filesType === 'message' + ? Localization.getTranslation('document-titles.message-transfer-requested') + : request.filesType === 'images' + ? Localization.getTranslation('document-titles.image-transfer-requested') + : Localization.getTranslation('document-titles.file-transfer-requested'); - const notification = this._notify(title, Localization.getTranslation("notifications.click-to-show")); - } + let title = Localization + .getTranslation("notifications.request-title", null, { + name: displayName, + count: request.header.length, + descriptor: transferRequestTitleTranslation.toLowerCase() + }); + + this._notify(title, clickToShowTranslation); } _download(notification) {