/* API Summary: stopSendingFile(in DOMString aDeviceAddress); confirmReceivingFile(in DOMString aDeviceAddress, in bool aConfirmation); */ 'use strict'; /* global MimeMapper, Service, LazyLoader, MozActivity, NotificationHelper, UtilityTray */ /* exported BluetoothTransfer */ (function(exports) { var BluetoothTransfer = { name: 'BluetoothTransfer', // The first-in-first-out queue maintain each scheduled sending task. // Each element is a object for scheduled sending tasks. _sendingFilesQueue: [], _bindOnCancelTransferTask: null, /** * Debug message. * * @private * @type {Boolean} turn on/off the console log */ onDebug: false, bluetoothIcon: 'style/bluetooth_transfer/images/icon_bluetooth.png', get _deviceStorage() { return navigator.getDeviceStorage('sdcard'); }, get transferStatusList() { var transferStatusList = document.getElementById('bluetooth-transfer-status-list'); delete this.transferStatusList; this.transferStatusList = transferStatusList; return transferStatusList; }, /** * Initialize BluetoothTransfer module. * * Service request handleSystemMessageNotification is used to filter * and relay BluetoothTransfer related notifications handle event after * reboot. Now only `_onTransferComplete` method generate notifications * that need to be handled after reboot. * * @public */ start: function bt_start() { // Bind message handler for sending files from Bluetooth app window.addEventListener('iac-bluetoothTransfercomms', this._onFilesSending.bind(this) ); // Bind message handler for transferring file callback navigator.mozSetMessageHandler( 'bluetooth-opp-receiving-file-confirmation', this.onReceivingFileConfirmation.bind(this) ); // Listen to 'bluetooth-opp-transfer-start' from bluetooth.js window.addEventListener('bluetooth-opp-transfer-start', this._onUpdateProgress.bind(this, 'start') ); navigator.mozSetMessageHandler('bluetooth-opp-update-progress', this._onUpdateProgress.bind(this, 'progress') ); // Listen to 'bluetooth-opp-transfer-complete' from bluetooth.js window.addEventListener('bluetooth-opp-transfer-complete', this._onTransferComplete.bind(this)); window.addEventListener('bluetooth-sendfile-via-handover', this.sendFileViaHandover.bind(this)); Service.registerState('isSendFileQueueEmpty', this); Service.registerState('isFileTransferInProgress', this); Service.request('handleSystemMessageNotification', 'BluetoothTransfer', this); this._bindOnCancelTransferTask = this.onCancelTransferTask.bind(this); }, getDeviceName: function bt_getDeviceName(address) { return new Promise((resolve) => { var adapter = Service.query('Bluetooth.getAdapter'); if (adapter === null) { var msg = 'Since cannot get Bluetooth adapter, ' + 'resolve with an unknown device.'; this.debug(msg); navigator.mozL10n.formatValue('unknown-device').then(resolve); return; } // Service Class Name: OBEXObjectPush, UUID: 0x1105 // Specification: Object Push Profile (OPP) // NOTE: Used as both Service Class Identifier and Profile. // Allowed Usage: Service Class/Profile // https://www.bluetooth.org/en-us/specification/assigned-numbers/ // service-discovery var serviceUuid = '0x1105'; var req = adapter.getConnectedDevices(serviceUuid); req.onsuccess = () => { if (req.result) { this.debug('got connectedList'); var connectedList = req.result; var length = connectedList.length; for (var i = 0; i < length; i++) { if (connectedList[i].address == address) { resolve(connectedList[i].name); } } } else { navigator.mozL10n.formatValue('unknown-device').then(resolve); } }; req.onerror = () => { var msg = 'Can not check is device connected from adapter.'; this.debug(msg); navigator.mozL10n.formatValue('unknown-device').then(resolve); }; }); }, debug: function bt_debug(msg) { if (!this.onDebug) { return; } console.log('[System Bluetooth Transfer]: ' + msg); }, humanizeSize: function bt_humanizeSize(bytes) { var _ = navigator.mozL10n.get; var units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; var size, e; if (bytes) { e = Math.floor(Math.log(bytes) / Math.log(1024)); size = (bytes / Math.pow(1024, e)).toFixed(2); } else { e = 0; size = '0.00'; } return _('fileSize', { size: size, unit: _('byteUnit-' + units[e]) }); }, _onFilesSending: function bt__onFilesSending(evt) { // Notify user that we are sending files var icon = 'style/bluetooth_transfer/images/transfer.png'; NotificationHelper.send('transferHasStartedTitle', { 'bodyL10n': 'transferHasStartedDescription', 'icon': icon }).then(function(notification) { notification.addEventListener('click', UtilityTray.show.bind(UtilityTray)); }); // Push sending files request in queue var sendingFilesSchedule = evt.detail; this._sendingFilesQueue.push(sendingFilesSchedule); var msg = 'push sending files request in queue, queued length = ' + this._sendingFilesQueue.length; this.debug(msg); }, onReceivingFileConfirmation: function bt_onReceivingFileConfirmation(evt) { if (Service.query('NfcHandoverManager.isHandoverInProgress')) { // Bypassing confirm dialog while incoming file transfer via // NFC Handover this.debug('Incoming file via NFC Handover. Bypassing confirm dialog'); window.dispatchEvent(new CustomEvent('nfc-transfer-started')); this.acceptReceive(evt); return; } // Prompt appears when a transfer request from a paired device is // received. this.debug('show receive confirm dialog'); var address = evt.address; this.getDeviceName(address).then((deviceName) => { var title = { id: 'transfer-confirmation-title', args: { deviceName: deviceName } }; NotificationHelper.send(title, { 'bodyL10n': 'transfer-confirmation-desc', 'icon': this.bluetoothIcon, 'mozbehavior': {noclear: true} }).then((notification) => { notification.addEventListener('click', () => { UtilityTray.hide(); this.showReceivePrompt(evt); }); }); }); }, showReceivePrompt: function bt_showReceivePrompt(evt) { var address = evt.address; var fileName = evt.fileName; var fileSize = this.humanizeSize(evt.fileLength); var cancel = { title: 'deny', callback: this.declineReceive.bind(this, address) }; var confirm = { title: 'transfer', callback: this.acceptReceive.bind(this, evt), recommend: true }; this.getDeviceName(address).then(function(deviceName) { Service.request('showCustomDialog', 'acceptFileTransfer', { id: 'want-to-receive-file', args: { deviceName: deviceName, fileName: fileName, fileSize: fileSize } }, cancel, confirm ); }); }, declineReceive: function bt_declineReceive(address) { Service.request('hideCustomDialog'); var adapter = Service.query('Bluetooth.getAdapter'); if (adapter != null) { adapter.confirmReceivingFile(address, false); } else { var msg = 'Cannot get adapter from system Bluetooth monitor.'; this.debug(msg); } }, acceptReceive: function bt_acceptReceive(evt) { this.debug('accepted the file transfer'); Service.request('hideCustomDialog'); // Check storage is available or not before confirm receiving file var address = evt.address; var fileSize = evt.fileLength; this.checkStorageSpace(fileSize, (isStorageAvailable, errorMessage) => { var option = (isStorageAvailable) ? true : false; var adapter = Service.query('Bluetooth.getAdapter'); if (adapter) { adapter.confirmReceivingFile(address, option); } else { var msg = 'Cannot get adapter from system Bluetooth monitor.'; this.debug(msg); } // Storage is not available, then pop out a prompt with the reason if (!isStorageAvailable) { this.showStorageUnavaliablePrompt(errorMessage); } }); }, showStorageUnavaliablePrompt: function bt_showStorageUnavaliablePrompt(msg) { var confirm = { title: 'confirm', callback: function() { Service.request('hideCustomDialog'); } }; Service.request('showCustomDialog', 'cannotReceiveFile', msg, confirm, null); }, checkStorageSpace: function bt_checkStorageSpace(fileSize, callback) { if (!callback) { return; } var storage = this._deviceStorage; var availreq = storage.available(); availreq.onsuccess = function(e) { switch (availreq.result) { case 'available': // skip down to the code below break; case 'unavailable': callback(false, 'sdcard-not-exist2'); return; case 'shared': callback(false, 'sdcard-in-use'); return; default: callback(false, 'unknown-error'); return; } // If we get here, then the sdcard is available, so we need to find out // if there is enough free space on it var freereq = storage.freeSpace(); freereq.onsuccess = function() { if (freereq.result >= fileSize) { callback(true, ''); } else { callback(false, 'sdcard-no-space2'); } }; freereq.onerror = function() { callback(false, 'cannotGetStorageState'); }; }; availreq.onerror = function(e) { callback(false, 'cannotGetStorageState'); }; }, isSendFileQueueEmpty: function() { return this._sendingFilesQueue.length === 0; }, isFileTransferInProgress: function() { var jobs = this.transferStatusList.querySelector('div'); return jobs != null; }, sendFileViaHandover: function bt_sendFileViaHandover(evt) { var mac = evt.detail.mac; var blob = evt.detail.blob; var adapter = Service.query('Bluetooth.getAdapter'); if (adapter !== null) { var sendingFilesSchedule = { viaHandover: true, numberOfFiles: 1, numSuccessful: 0, numUnsuccessful: 0 }; this._onFilesSending({detail: sendingFilesSchedule}); // XXX: Bug 915602 - [Bluetooth] Call sendFile api will crash // the system while device is just paired. // The paired device is ready to send file. // Since above issue is existed, we use a setTimeout with 3 secs delay var waitConnectionReadyTimeoutTime = 3000; setTimeout(() => { adapter.sendFile(mac, blob); }, waitConnectionReadyTimeoutTime); } else { var msg = 'Cannot get adapter from system Bluetooth monitor.'; this.debug(msg); } }, _onUpdateProgress: function bt__onUpdateProgress(mode, evt) { switch (mode) { case 'start': var transferInfo = evt.detail.transferInfo; this.initProgress(transferInfo); break; case 'progress': var processedLength = evt.processedLength; var fileLength = evt.fileLength; var progress = 0; if (fileLength === 0) { //XXX: May need to handle unknow progress } else if (processedLength > fileLength) { // According Bluetooth spec., // the processed length is a referenced value only. // XXX: If processed length is bigger than file length, // show an unknown progress } else { progress = processedLength / fileLength; } this.updateProgress(progress, evt); break; } }, initProgress: function bt_initProgress(evt) { // Create progress dynamically in notification center var address = evt.address; var transferMode = (evt.received === true) ? 'bluetooth-receiving-progress2' : 'bluetooth-sending-progress2'; var content = `
`; var transferTask = document.createElement('div'); transferTask.id = 'bluetooth-transfer-status'; transferTask.className = 'fake-notification'; transferTask.setAttribute('data-id', address); transferTask.setAttribute('role', 'link'); transferTask.innerHTML = content; transferTask.addEventListener('click', this._bindOnCancelTransferTask); this.transferStatusList.appendChild(transferTask); }, updateProgress: function bt_updateProgress(value, evt) { var address = evt.address; var id = 'div[data-id="' + address + '"] progress'; var progressEl = this.transferStatusList.querySelector(id); progressEl.value = value; }, removeProgress: function bt_removeProgress(evt) { var address = evt.address; var id = 'div[data-id="' + address + '"]'; var finishedTask = this.transferStatusList.querySelector(id); // If we decline receiving file, Bluetooth won't callback // 'bluetooth-opp-transfer-start', 'bluetooth-opp-update-progress' event. // So that there is no progress element which was created on notification. // There is only 'bluetooth-opp-transfer-complete' event to notify Gaia // the transferring request in failed case. if (finishedTask == null) { return; } finishedTask.removeEventListener('click', this._bindOnCancelTransferTask); this.transferStatusList.removeChild(finishedTask); }, onCancelTransferTask: function bt_onCancelTransferTask(evt) { var address = evt.currentTarget.dataset.id; // Show confirm dialog for user to cancel transferring task UtilityTray.hide(); this.showCancelTransferPrompt(address); }, showCancelTransferPrompt: function bt_showCancelTransferPrompt(address) { var cancel = { title: 'no', callback: this.continueTransfer.bind(this) }; var confirm = { title: 'yes', callback: this.cancelTransfer.bind(this, address) }; Service.request('showCustomDialog', 'cancelFileTransfer', 'cancelFileTransfer', cancel, confirm ); }, continueTransfer: function bt_continueTransfer() { Service.request('hideCustomDialog'); }, cancelTransfer: function bt_cancelTransfer(address) { Service.request('hideCustomDialog'); var adapter = Service.query('Bluetooth.getAdapter'); if (adapter !== null) { adapter.stopSendingFile(address); } else { var msg = 'Cannot get adapter from system Bluetooth monitor.'; this.debug(msg); } }, /** * Create notification when transfer complete. * * @param {Object} evt bluetooth-opp-transfer-complete event */ _onTransferComplete: function bt__onTransferComplete(evt) { var transferInfo = evt.detail.transferInfo; // Remove transferring progress this.removeProgress(transferInfo); // Show notification var nData = { titleL10n: null, icon: this.bluetoothIcon, transferInfo: null, onclick: null }; if (transferInfo.success === true) { if (transferInfo.received) { // Received file can be opened only nData.titleL10n = 'transferFinished-receivedSuccessful-title'; nData.transferInfo = transferInfo; nData.onclick = this.openReceivedFile.bind(this, transferInfo); } else { nData.titleL10n = 'transferFinished-sentSuccessful-title'; } } else { if (transferInfo.received) { nData.titleL10n = 'transferFinished-receivedFailed-title'; } else { nData.titleL10n = 'transferFinished-canceled'; } } var l10nArgs = { icon: nData.icon, data: { systemMessageTarget: 'BluetoothTransfer' } }; if (transferInfo.fileName) { l10nArgs.body = transferInfo.fileName; // put original message in data for reference l10nArgs.data.transferInfo = nData.transferInfo; } else { l10nArgs.bodyL10n = 'unknown-file'; } var promise = NotificationHelper.send(nData.titleL10n, l10nArgs); if (nData.onclick) { promise.then(function(notification) { notification.addEventListener('click', nData.onclick); }); } var viaHandover = false; if (this._sendingFilesQueue.length > 0) { viaHandover = this._sendingFilesQueue[0].viaHandover || false; } // Have a report notification for sending multiple files. this.summarizeSentFilesReport(transferInfo); // Inform NfcHandoverManager that the transfer completed var details = {received: transferInfo.received, success: transferInfo.success, viaHandover: viaHandover}; window.dispatchEvent(new CustomEvent('nfc-transfer-completed', { detail: details})); }, /** * Handle related system notification message operation after reboot. * Need close notification message by ourself. * * @param {Object} message system notification message */ handleSystemMessageNotification: function(message) { this.debug('Received system message: ' + JSON.stringify(message)); if (message.data && message.data.transferInfo) { var evt = { fileName: message.data.transferInfo.fileName, contentType: message.data.transferInfo.contentType }; this.openReceivedFile(evt); } else { console.error('The notification does not carry correct data'); } this.closeSystemMessageNotification(message); }, /** * Close the system notification message. * * @param {Object} message system notification message */ closeSystemMessageNotification: function(message) { Notification.get({ tag: message.tag }).then(notifs => { notifs.forEach(notif => { // check on the body if (notif.body === message.body) { notif.close && notif.close(); } }); }); }, summarizeSentFilesReport: function bt_summarizeSentFilesReport(transferInfo) { // Ignore received files if (transferInfo.received) { return; } // Consumer: System app consume each sending file request from Bluetooth // app var msg = 'remove the finished sending task from queue, ' + 'queue length = '; var successful = transferInfo.success; var sendingFilesSchedule = this._sendingFilesQueue[0]; var numberOfFiles = sendingFilesSchedule.numberOfFiles; if (numberOfFiles == 1) { // for sent one file only. // We don't need to summarize a report for sent one file only. // Remove the finished sending task from the queue this._sendingFilesQueue.shift(); msg += this._sendingFilesQueue.length; this.debug(msg); } else { // The scheduled task is for sent multiple files. // Create a report in notification. // Record each transferring report. if (successful) { this._sendingFilesQueue[0].numSuccessful++; } else { this._sendingFilesQueue[0].numUnsuccessful++; } var numSuccessful = this._sendingFilesQueue[0].numSuccessful; var numUnsuccessful = this._sendingFilesQueue[0].numUnsuccessful; if ((numSuccessful + numUnsuccessful) == numberOfFiles) { // In this item of queue, all files were sent completely. NotificationHelper.send('transferReportTitle', { 'bodyL10n': { id: 'transferReport-description', args: { numSuccessful: numSuccessful, numUnsuccessful: numUnsuccessful } }, 'icon': this.bluetoothIcon }); // Remove the finished sending task from the queue this._sendingFilesQueue.shift(); msg += this._sendingFilesQueue.length; this.debug(msg); } } }, openReceivedFile: function bt_openReceivedFile(evt) { // Launch the gallery with an open activity to view this specific photo // XXX: Bug 897434 - Save received/downloaded files in one specific folder // with meaningful path and filename var filePath = 'Download/Bluetooth/' + evt.fileName; var contentType = evt.contentType; var storageType = 'sdcard'; var storage = navigator.getDeviceStorage(storageType); var getreq = storage.get(filePath); var loader = LazyLoader.load(['shared/js/mime_mapper.js']); Promise.all([getreq, loader]).then(() => { var file = getreq.result; // When we got the file by storage type of "sdcard" // use the file.type to replace the empty fileType which is given by API var fileName = file.name; var extension = fileName.split('.').pop(); var originalType = file.type || contentType; var mappedType = (MimeMapper.isSupportedType(originalType)) ? originalType : MimeMapper.guessTypeFromExtension(extension); var a = new MozActivity({ name: mappedType == 'text/vcard' ? 'import' : 'open', data: { type: mappedType, blob: file, // XXX: https://bugzilla.mozilla.org/show_bug.cgi?id=812098 // Pass the file name for Music APP since it can not open blob filename: fileName } }); a.onerror = (e) => { var msg = 'open activity error:' + a.error.name; this.debug(msg); switch (a.error.name) { case 'NO_PROVIDER': UtilityTray.hide(); // Cannot identify MIMETYPE // So, show cannot open file dialog with unknow media type this.showUnknownMediaPrompt(fileName); return; case 'ActivityCanceled': return; case 'USER_ABORT': return; default: return; } }; a.onsuccess = (e) => { var msg = 'open activity onsuccess'; this.debug(msg); }; }, (err) => { if (getreq.readyState === 'done' && getreq.error) { // DeviceStorage Error var msg = 'failed to get file:' + filePath + ' ' + err.name; this.debug(msg); } else { // LazyLoader Error throw err; } }).catch((err) => { console.error(err); }); }, showUnknownMediaPrompt: function bt_showUnknownMediaPrompt(fileName) { var confirm = { title: 'confirm', callback: function() { Service.request('hideCustomDialog'); } }; var body = {id: 'unknownMediaTypeToOpenFile', args: {fileName: fileName}}; Service.request('showCustomDialog', 'cannotOpenFile', body, confirm, null); } }; exports.BluetoothTransfer = BluetoothTransfer; })(window);