diff -r 000000000000 -r 6474c204b198 accessible/src/jsat/AccessFu.jsm --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/accessible/src/jsat/AccessFu.jsm Wed Dec 31 06:09:35 2014 +0100 @@ -0,0 +1,1075 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict'; + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; +const Cr = Components.results; + +this.EXPORTED_SYMBOLS = ['AccessFu']; + +Cu.import('resource://gre/modules/Services.jsm'); + +Cu.import('resource://gre/modules/accessibility/Utils.jsm'); + +const ACCESSFU_DISABLE = 0; +const ACCESSFU_ENABLE = 1; +const ACCESSFU_AUTO = 2; + +const SCREENREADER_SETTING = 'accessibility.screenreader'; + +this.AccessFu = { + /** + * Initialize chrome-layer accessibility functionality. + * If accessibility is enabled on the platform, then a special accessibility + * mode is started. + */ + attach: function attach(aWindow) { + Utils.init(aWindow); + + try { + Services.androidBridge.handleGeckoMessage( + { type: 'Accessibility:Ready' }); + Services.obs.addObserver(this, 'Accessibility:Settings', false); + } catch (x) { + // Not on Android + if (aWindow.navigator.mozSettings) { + let lock = aWindow.navigator.mozSettings.createLock(); + let req = lock.get(SCREENREADER_SETTING); + req.addEventListener('success', () => { + this._systemPref = req.result[SCREENREADER_SETTING]; + this._enableOrDisable(); + }); + aWindow.navigator.mozSettings.addObserver( + SCREENREADER_SETTING, this.handleEvent.bind(this)); + } + } + + this._activatePref = new PrefCache( + 'accessibility.accessfu.activate', this._enableOrDisable.bind(this)); + + this._enableOrDisable(); + }, + + /** + * Shut down chrome-layer accessibility functionality from the outside. + */ + detach: function detach() { + // Avoid disabling twice. + if (this._enabled) { + this._disable(); + } + if (Utils.MozBuildApp === 'mobile/android') { + Services.obs.removeObserver(this, 'Accessibility:Settings'); + } else if (Utils.win.navigator.mozSettings) { + Utils.win.navigator.mozSettings.removeObserver( + SCREENREADER_SETTING, this.handleEvent.bind(this)); + } + delete this._activatePref; + Utils.uninit(); + }, + + /** + * Start AccessFu mode, this primarily means controlling the virtual cursor + * with arrow keys. + */ + _enable: function _enable() { + if (this._enabled) + return; + this._enabled = true; + + Cu.import('resource://gre/modules/accessibility/Utils.jsm'); + Cu.import('resource://gre/modules/accessibility/PointerAdapter.jsm'); + Cu.import('resource://gre/modules/accessibility/Presentation.jsm'); + + Logger.info('Enabled'); + + for each (let mm in Utils.AllMessageManagers) { + this._addMessageListeners(mm); + this._loadFrameScript(mm); + } + + // Add stylesheet + let stylesheetURL = 'chrome://global/content/accessibility/AccessFu.css'; + let stylesheet = Utils.win.document.createProcessingInstruction( + 'xml-stylesheet', 'href="' + stylesheetURL + '" type="text/css"'); + Utils.win.document.insertBefore(stylesheet, Utils.win.document.firstChild); + this.stylesheet = Cu.getWeakReference(stylesheet); + + + // Populate quicknav modes + this._quicknavModesPref = + new PrefCache( + 'accessibility.accessfu.quicknav_modes', + (aName, aValue) => { + this.Input.quickNavMode.updateModes(aValue); + }, true); + + // Check for output notification + this._notifyOutputPref = + new PrefCache('accessibility.accessfu.notify_output'); + + + this.Input.start(); + Output.start(); + PointerAdapter.start(); + + Services.obs.addObserver(this, 'remote-browser-shown', false); + Services.obs.addObserver(this, 'inprocess-browser-shown', false); + Services.obs.addObserver(this, 'Accessibility:NextObject', false); + Services.obs.addObserver(this, 'Accessibility:PreviousObject', false); + Services.obs.addObserver(this, 'Accessibility:Focus', false); + Services.obs.addObserver(this, 'Accessibility:ActivateObject', false); + Services.obs.addObserver(this, 'Accessibility:LongPress', false); + Services.obs.addObserver(this, 'Accessibility:MoveByGranularity', false); + Utils.win.addEventListener('TabOpen', this); + Utils.win.addEventListener('TabClose', this); + Utils.win.addEventListener('TabSelect', this); + + if (this.readyCallback) { + this.readyCallback(); + delete this.readyCallback; + } + + if (Utils.MozBuildApp !== 'mobile/android') { + this.announce( + Utils.stringBundle.GetStringFromName('screenReaderStarted')); + } + }, + + /** + * Disable AccessFu and return to default interaction mode. + */ + _disable: function _disable() { + if (!this._enabled) + return; + + this._enabled = false; + + Logger.info('Disabled'); + + Utils.win.document.removeChild(this.stylesheet.get()); + + if (Utils.MozBuildApp !== 'mobile/android') { + this.announce( + Utils.stringBundle.GetStringFromName('screenReaderStopped')); + } + + for each (let mm in Utils.AllMessageManagers) { + mm.sendAsyncMessage('AccessFu:Stop'); + this._removeMessageListeners(mm); + } + + this.Input.stop(); + Output.stop(); + PointerAdapter.stop(); + + Utils.win.removeEventListener('TabOpen', this); + Utils.win.removeEventListener('TabClose', this); + Utils.win.removeEventListener('TabSelect', this); + + Services.obs.removeObserver(this, 'remote-browser-shown'); + Services.obs.removeObserver(this, 'inprocess-browser-shown'); + Services.obs.removeObserver(this, 'Accessibility:NextObject'); + Services.obs.removeObserver(this, 'Accessibility:PreviousObject'); + Services.obs.removeObserver(this, 'Accessibility:Focus'); + Services.obs.removeObserver(this, 'Accessibility:ActivateObject'); + Services.obs.removeObserver(this, 'Accessibility:LongPress'); + Services.obs.removeObserver(this, 'Accessibility:MoveByGranularity'); + + delete this._quicknavModesPref; + delete this._notifyOutputPref; + + if (this.doneCallback) { + this.doneCallback(); + delete this.doneCallback; + } + }, + + _enableOrDisable: function _enableOrDisable() { + try { + if (!this._activatePref) { + return; + } + let activatePref = this._activatePref.value; + if (activatePref == ACCESSFU_ENABLE || + this._systemPref && activatePref == ACCESSFU_AUTO) + this._enable(); + else + this._disable(); + } catch (x) { + dump('Error ' + x.message + ' ' + x.fileName + ':' + x.lineNumber); + } + }, + + receiveMessage: function receiveMessage(aMessage) { + Logger.debug(() => { + return ['Recieved', aMessage.name, JSON.stringify(aMessage.json)]; + }); + + switch (aMessage.name) { + case 'AccessFu:Ready': + let mm = Utils.getMessageManager(aMessage.target); + if (this._enabled) { + mm.sendAsyncMessage('AccessFu:Start', + {method: 'start', buildApp: Utils.MozBuildApp}); + } + break; + case 'AccessFu:Present': + this._output(aMessage.json, aMessage.target); + break; + case 'AccessFu:Input': + this.Input.setEditState(aMessage.json); + break; + case 'AccessFu:ActivateContextMenu': + this.Input.activateContextMenu(aMessage.json); + break; + case 'AccessFu:DoScroll': + this.Input.doScroll(aMessage.json); + break; + } + }, + + _output: function _output(aPresentationData, aBrowser) { + for each (let presenter in aPresentationData) { + if (!presenter) + continue; + + try { + Output[presenter.type](presenter.details, aBrowser); + } catch (x) { + Logger.logException(x); + } + } + + if (this._notifyOutputPref.value) { + Services.obs.notifyObservers(null, 'accessfu-output', + JSON.stringify(aPresentationData)); + } + }, + + _loadFrameScript: function _loadFrameScript(aMessageManager) { + if (this._processedMessageManagers.indexOf(aMessageManager) < 0) { + aMessageManager.loadFrameScript( + 'chrome://global/content/accessibility/content-script.js', true); + this._processedMessageManagers.push(aMessageManager); + } else if (this._enabled) { + // If the content-script is already loaded and AccessFu is enabled, + // send an AccessFu:Start message. + aMessageManager.sendAsyncMessage('AccessFu:Start', + {method: 'start', buildApp: Utils.MozBuildApp}); + } + }, + + _addMessageListeners: function _addMessageListeners(aMessageManager) { + aMessageManager.addMessageListener('AccessFu:Present', this); + aMessageManager.addMessageListener('AccessFu:Input', this); + aMessageManager.addMessageListener('AccessFu:Ready', this); + aMessageManager.addMessageListener('AccessFu:ActivateContextMenu', this); + aMessageManager.addMessageListener('AccessFu:DoScroll', this); + }, + + _removeMessageListeners: function _removeMessageListeners(aMessageManager) { + aMessageManager.removeMessageListener('AccessFu:Present', this); + aMessageManager.removeMessageListener('AccessFu:Input', this); + aMessageManager.removeMessageListener('AccessFu:Ready', this); + aMessageManager.removeMessageListener('AccessFu:ActivateContextMenu', this); + aMessageManager.removeMessageListener('AccessFu:DoScroll', this); + }, + + _handleMessageManager: function _handleMessageManager(aMessageManager) { + if (this._enabled) { + this._addMessageListeners(aMessageManager); + } + this._loadFrameScript(aMessageManager); + }, + + observe: function observe(aSubject, aTopic, aData) { + switch (aTopic) { + case 'Accessibility:Settings': + this._systemPref = JSON.parse(aData).enabled; + this._enableOrDisable(); + break; + case 'Accessibility:NextObject': + this.Input.moveCursor('moveNext', 'Simple', 'gesture'); + break; + case 'Accessibility:PreviousObject': + this.Input.moveCursor('movePrevious', 'Simple', 'gesture'); + break; + case 'Accessibility:ActivateObject': + this.Input.activateCurrent(JSON.parse(aData)); + break; + case 'Accessibility:LongPress': + this.Input.sendContextMenuMessage(); + break; + case 'Accessibility:Focus': + this._focused = JSON.parse(aData); + if (this._focused) { + this.autoMove({ forcePresent: true, noOpIfOnScreen: true }); + } + break; + case 'Accessibility:MoveByGranularity': + this.Input.moveByGranularity(JSON.parse(aData)); + break; + case 'remote-browser-shown': + case 'inprocess-browser-shown': + { + // Ignore notifications that aren't from a BrowserOrApp + let frameLoader = aSubject.QueryInterface(Ci.nsIFrameLoader); + if (!frameLoader.ownerIsBrowserOrAppFrame) { + return; + } + this._handleMessageManager(frameLoader.messageManager); + break; + } + } + }, + + handleEvent: function handleEvent(aEvent) { + switch (aEvent.type) { + case 'TabOpen': + { + let mm = Utils.getMessageManager(aEvent.target); + this._handleMessageManager(mm); + break; + } + case 'TabClose': + { + let mm = Utils.getMessageManager(aEvent.target); + let mmIndex = this._processedMessageManagers.indexOf(mm); + if (mmIndex > -1) { + this._removeMessageListeners(mm); + this._processedMessageManagers.splice(mmIndex, 1); + } + break; + } + case 'TabSelect': + { + if (this._focused) { + // We delay this for half a second so the awesomebar could close, + // and we could use the current coordinates for the content item. + // XXX TODO figure out how to avoid magic wait here. + this.autoMove({ + delay: 500, + forcePresent: true, + noOpIfOnScreen: true, + moveMethod: 'moveFirst' }); + } + break; + } + default: + { + // A settings change, it does not have an event type + if (aEvent.settingName == SCREENREADER_SETTING) { + this._systemPref = aEvent.settingValue; + this._enableOrDisable(); + } + break; + } + } + }, + + autoMove: function autoMove(aOptions) { + let mm = Utils.getMessageManager(Utils.CurrentBrowser); + mm.sendAsyncMessage('AccessFu:AutoMove', aOptions); + }, + + announce: function announce(aAnnouncement) { + this._output(Presentation.announce(aAnnouncement), Utils.CurrentBrowser); + }, + + // So we don't enable/disable twice + _enabled: false, + + // Layerview is focused + _focused: false, + + // Keep track of message managers tha already have a 'content-script.js' + // injected. + _processedMessageManagers: [], + + /** + * Adjusts the given bounds relative to the given browser. Converts from screen + * or device pixels to either device or CSS pixels. + * @param {Rect} aJsonBounds the bounds to adjust + * @param {browser} aBrowser the browser we want the bounds relative to + * @param {bool} aToCSSPixels whether to convert to CSS pixels (as opposed to + * device pixels) + * @param {bool} aFromDevicePixels whether to convert from device pixels (as + * opposed to screen pixels) + */ + adjustContentBounds: function(aJsonBounds, aBrowser, aToCSSPixels, aFromDevicePixels) { + let bounds = new Rect(aJsonBounds.left, aJsonBounds.top, + aJsonBounds.right - aJsonBounds.left, + aJsonBounds.bottom - aJsonBounds.top); + let win = Utils.win; + let dpr = win.devicePixelRatio; + let vp = Utils.getViewport(win); + let offset = { left: -win.mozInnerScreenX, top: -win.mozInnerScreenY }; + + if (!aBrowser.contentWindow) { + // OOP browser, add offset of browser. + // The offset of the browser element in relation to its parent window. + let clientRect = aBrowser.getBoundingClientRect(); + let win = aBrowser.ownerDocument.defaultView; + offset.left += clientRect.left + win.mozInnerScreenX; + offset.top += clientRect.top + win.mozInnerScreenY; + } + + // Here we scale from screen pixels to layout device pixels by dividing by + // the resolution (caused by pinch-zooming). The resolution is the viewport + // zoom divided by the devicePixelRatio. If there's no viewport, then we're + // on a platform without pinch-zooming and we can just ignore this. + if (!aFromDevicePixels && vp) { + bounds = bounds.scale(vp.zoom / dpr, vp.zoom / dpr); + } + + // Add the offset; the offset is in CSS pixels, so multiply the + // devicePixelRatio back in before adding to preserve unit consistency. + bounds = bounds.translate(offset.left * dpr, offset.top * dpr); + + // If we want to get to CSS pixels from device pixels, this needs to be + // further divided by the devicePixelRatio due to widget scaling. + if (aToCSSPixels) { + bounds = bounds.scale(1 / dpr, 1 / dpr); + } + + return bounds.expandToIntegers(); + } +}; + +var Output = { + brailleState: { + startOffset: 0, + endOffset: 0, + text: '', + selectionStart: 0, + selectionEnd: 0, + + init: function init(aOutput) { + if (aOutput && 'output' in aOutput) { + this.startOffset = aOutput.startOffset; + this.endOffset = aOutput.endOffset; + // We need to append a space at the end so that the routing key corresponding + // to the end of the output (i.e. the space) can be hit to move the caret there. + this.text = aOutput.output + ' '; + this.selectionStart = typeof aOutput.selectionStart === 'number' ? + aOutput.selectionStart : this.selectionStart; + this.selectionEnd = typeof aOutput.selectionEnd === 'number' ? + aOutput.selectionEnd : this.selectionEnd; + + return { text: this.text, + selectionStart: this.selectionStart, + selectionEnd: this.selectionEnd }; + } + + return null; + }, + + adjustText: function adjustText(aText) { + let newBraille = []; + let braille = {}; + + let prefix = this.text.substring(0, this.startOffset).trim(); + if (prefix) { + prefix += ' '; + newBraille.push(prefix); + } + + newBraille.push(aText); + + let suffix = this.text.substring(this.endOffset).trim(); + if (suffix) { + suffix = ' ' + suffix; + newBraille.push(suffix); + } + + this.startOffset = braille.startOffset = prefix.length; + this.text = braille.text = newBraille.join('') + ' '; + this.endOffset = braille.endOffset = braille.text.length - suffix.length; + braille.selectionStart = this.selectionStart; + braille.selectionEnd = this.selectionEnd; + + return braille; + }, + + adjustSelection: function adjustSelection(aSelection) { + let braille = {}; + + braille.startOffset = this.startOffset; + braille.endOffset = this.endOffset; + braille.text = this.text; + this.selectionStart = braille.selectionStart = aSelection.selectionStart + this.startOffset; + this.selectionEnd = braille.selectionEnd = aSelection.selectionEnd + this.startOffset; + + return braille; + } + }, + + speechHelper: { + EARCONS: ['virtual_cursor_move.ogg', + 'virtual_cursor_key.ogg', + 'clicked.ogg'], + + earconBuffers: {}, + + inited: false, + + webspeechEnabled: false, + + deferredOutputs: [], + + init: function init() { + let window = Utils.win; + this.webspeechEnabled = !!window.speechSynthesis && + !!window.SpeechSynthesisUtterance; + + let settingsToGet = 2; + let settingsCallback = (aName, aSetting) => { + if (--settingsToGet > 0) { + return; + } + + this.inited = true; + + for (let actions of this.deferredOutputs) { + this.output(actions); + } + }; + + this._volumeSetting = new SettingCache( + 'accessibility.screenreader-volume', settingsCallback, + { defaultValue: 1, callbackNow: true, callbackOnce: true }); + this._rateSetting = new SettingCache( + 'accessibility.screenreader-rate', settingsCallback, + { defaultValue: 0, callbackNow: true, callbackOnce: true }); + + for (let earcon of this.EARCONS) { + let earconName = /(^.*)\..*$/.exec(earcon)[1]; + this.earconBuffers[earconName] = new WeakMap(); + this.earconBuffers[earconName].set( + window, new window.Audio('chrome://global/content/accessibility/' + earcon)); + } + }, + + uninit: function uninit() { + if (this.inited) { + delete this._volumeSetting; + delete this._rateSetting; + } + this.inited = false; + }, + + output: function output(aActions) { + if (!this.inited) { + this.deferredOutputs.push(aActions); + return; + } + + for (let action of aActions) { + let window = Utils.win; + Logger.debug('tts.' + action.method, '"' + action.data + '"', + JSON.stringify(action.options)); + + if (!action.options.enqueue && this.webspeechEnabled) { + window.speechSynthesis.cancel(); + } + + if (action.method === 'speak' && this.webspeechEnabled) { + let utterance = new window.SpeechSynthesisUtterance(action.data); + let requestedRate = this._rateSetting.value; + utterance.volume = this._volumeSetting.value; + utterance.rate = requestedRate >= 0 ? + requestedRate + 1 : 1 / (Math.abs(requestedRate) + 1); + window.speechSynthesis.speak(utterance); + } else if (action.method === 'playEarcon') { + let audioBufferWeakMap = this.earconBuffers[action.data]; + if (audioBufferWeakMap) { + let node = audioBufferWeakMap.get(window).cloneNode(false); + node.volume = this._volumeSetting.value; + node.play(); + } + } + } + } + }, + + start: function start() { + Cu.import('resource://gre/modules/Geometry.jsm'); + this.speechHelper.init(); + }, + + stop: function stop() { + if (this.highlightBox) { + Utils.win.document.documentElement.removeChild(this.highlightBox.get()); + delete this.highlightBox; + } + + if (this.announceBox) { + Utils.win.document.documentElement.removeChild(this.announceBox.get()); + delete this.announceBox; + } + + this.speechHelper.uninit(); + }, + + Speech: function Speech(aDetails, aBrowser) { + this.speechHelper.output(aDetails.actions); + }, + + Visual: function Visual(aDetails, aBrowser) { + switch (aDetails.method) { + case 'showBounds': + { + let highlightBox = null; + if (!this.highlightBox) { + // Add highlight box + highlightBox = Utils.win.document. + createElementNS('http://www.w3.org/1999/xhtml', 'div'); + Utils.win.document.documentElement.appendChild(highlightBox); + highlightBox.id = 'virtual-cursor-box'; + + // Add highlight inset for inner shadow + let inset = Utils.win.document. + createElementNS('http://www.w3.org/1999/xhtml', 'div'); + inset.id = 'virtual-cursor-inset'; + + highlightBox.appendChild(inset); + this.highlightBox = Cu.getWeakReference(highlightBox); + } else { + highlightBox = this.highlightBox.get(); + } + + let padding = aDetails.padding; + let r = AccessFu.adjustContentBounds(aDetails.bounds, aBrowser, true); + + // First hide it to avoid flickering when changing the style. + highlightBox.style.display = 'none'; + highlightBox.style.top = (r.top - padding) + 'px'; + highlightBox.style.left = (r.left - padding) + 'px'; + highlightBox.style.width = (r.width + padding*2) + 'px'; + highlightBox.style.height = (r.height + padding*2) + 'px'; + highlightBox.style.display = 'block'; + + break; + } + case 'hideBounds': + { + let highlightBox = this.highlightBox ? this.highlightBox.get() : null; + if (highlightBox) + highlightBox.style.display = 'none'; + break; + } + case 'showAnnouncement': + { + let announceBox = this.announceBox ? this.announceBox.get() : null; + if (!announceBox) { + announceBox = Utils.win.document. + createElementNS('http://www.w3.org/1999/xhtml', 'div'); + announceBox.id = 'announce-box'; + Utils.win.document.documentElement.appendChild(announceBox); + this.announceBox = Cu.getWeakReference(announceBox); + } + + announceBox.innerHTML = '