'use strict'; /* global applications, InputWindow, SettingsListener, KeyboardManager, Service, Promise */ (function(exports) { /** * For some flow diagrams related to input management, please refer to * https://wiki.mozilla.org/Gaia/System/InputManagement#Flow_Diagrams . * * InputWindowManager manages all the InputWindow-related operations. It shows * /hides/preloads/kills InputWindows for KeyboardManager as specified by * keyboard layouts, and internally book-keeps those InputWindows and the * currently displayed one. * * An input app may specify different layouts. A layout passed from * KeyboardManager may specify its manifestURL (mapping to the input app), and * its path (i.e. the Input Window frame src). * * The path is like: /index.html#en. Different layouts of the same input app * may differ in only the hash part of the path, or may differ in the whole * path. * * Currently, when we are to display a keyboard layout, we may reuse * an InputWindow of the same (manifestURL, path_without_hash) of that layout, * and only replaces the hash part of the src of that InputWindow frame. * To say it another way, We have different InputWindows for different * (manifestURL, path_without_hash) of different layouts. * * * There are several scenarios to be considered when we show a layout: * * - There is currently no displayed InputWindow: * We show the InputWindow for that layout, with animation. * * - There is currently a displayed InputWindow, and we can reuse it for that * layout: * InputWindow will know to change its hash only, and does not re-animate * for showing. * * - There is currently a displayed InputWindow, and it is closing, and we'll * re-use it: * InputWindow will know to show itself without animation (and may change) * its frame src hash) * * - There is currently a displayed InputWindow, and we can't re-use it: * We show the new InputWindow without animation, and hide the old * InputWindow, without animation, when the new InputWIndow is ready. */ var InputWindowManager = function() { this.isOutOfProcessEnabled = false; this._totalMemory = 0; this._getMemory(); this._oopSettingCallbackBind = null; /* * The collection of loaded InputWindows. * This is a two-level map, from input app manifestURL to path_without_hash * to an InputWindow. By this structure we fulfill the need to reuse * an InputWindow for the same (manifestURL, path_without_hash) of layouts * as said above. * * { * 'keyboard.gaiamobile.org/manifest.webapp' : { * '/index.html': inputWindow1, * '/complex.html': inputWindow2, * }, * 'demo-keyboard.gaiamobile.org/manifest.webapp' : { * '/ime1.html': inputWindow3, * '/ime2.html': inputWindow4, * } * } * */ this._inputWindows = {}; // The InputWindow that's being displayed this._currentWindow = null; // This points to an InputWindow that should be closed but displayed during // the timeout. this._windowToClose = null; // The switched-out InputWindow that we need to deactivate when the // switched-in InputWindow finishes activation. this._lastWindow = null; this._onDebug = false; }; InputWindowManager.prototype.name = 'InputWindowManager'; InputWindowManager.prototype._debug = function iwm__debug(msg) { if (this._onDebug) { console.log('[InputWindowManager] ' + msg); } }; InputWindowManager.prototype.start = function iwm_start() { // 3rd-party keyboard apps must be run out-of-process. this._oopSettingCallbackBind = this._oopSettingCallback.bind(this); SettingsListener.observe('keyboard.3rd-party-app.enabled', true, this._oopSettingCallbackBind); window.addEventListener('input-appopened', this); window.addEventListener('input-appclosed', this); window.addEventListener('input-apprequestclose', this); window.addEventListener('input-appready', this); window.addEventListener('input-appheightchanged', this); window.addEventListener('input-appterminated', this); // For Bug 812115: hide the keyboard when the app is closed here, // since it would take a longer round-trip to receive focuschange // Also in Bug 856692 we realise that we need to close the keyboard // when an inline activity goes away. window.addEventListener('actionmenuopening', this); window.addEventListener('actionmenuhide', this); window.addEventListener('activityrequesting', this); window.addEventListener('activitycanceled', this); window.addEventListener('activitychoosen', this); window.addEventListener('activityopening', this); window.addEventListener('activityclosing', this); window.addEventListener('attentionrequestopen', this); window.addEventListener('attentionrecovering', this); window.addEventListener('attentionopening', this); window.addEventListener('attentionopened', this); window.addEventListener('attentionclosing', this); window.addEventListener('attentionclosed', this); window.addEventListener('notification-clicked', this); window.addEventListener('applicationsetupdialogshow', this); window.addEventListener('sheets-gesture-begin', this); window.addEventListener('cardviewprepare', this); window.addEventListener('lockscreen-appopened', this); window.addEventListener('mozmemorypressure', this); Service.registerState('getHeight', this); Service.registerState('isOutOfProcessEnabled', this); Service.register('hideInputWindow', this); Service.register('hideInputWindowImmediately', this); }; InputWindowManager.prototype.stop = function iwm_stop() { SettingsListener.unobserve('keyboard.3rd-party-app.enabled', this._oopSettingCallbackBind); this._oopSettingCallbackBind = null; window.removeEventListener('input-appopened', this); window.removeEventListener('input-appclosed', this); window.removeEventListener('input-apprequestclose', this); window.removeEventListener('input-appready', this); window.removeEventListener('input-appheightchanged', this); window.removeEventListener('input-appterminated', this); window.removeEventListener('activityopening', this); window.removeEventListener('activityclosing', this); window.removeEventListener('attentionrequestopen', this); window.removeEventListener('attentionrecovering', this); window.removeEventListener('attentionopening', this); window.removeEventListener('attentionopened', this); window.removeEventListener('attentionclosing', this); window.removeEventListener('attentionclosed', this); window.removeEventListener('notification-clicked', this); window.removeEventListener('applicationsetupdialogshow', this); window.removeEventListener('sheets-gesture-begin', this); window.removeEventListener('cardviewprepare', this); window.removeEventListener('lockscreen-appopened', this); window.removeEventListener('mozmemorypressure', this); }; InputWindowManager.prototype.handleEvent = function iwm_handleEvent(evt) { var inputWindow; var manifestURL; if (evt.type.startsWith('input-app')) { inputWindow = evt.detail; } this._debug('handleEvent: ' + evt.type); switch (evt.type) { case 'input-appopened': case 'input-appheightchanged': // for opened/ready/heightchanged events, make sure the originating // InputWindow is the displayed one if (inputWindow === this._currentWindow) { this._kbPublish('keyboardchange', inputWindow.height); } break; case 'input-appready': if (inputWindow === this._currentWindow) { KeyboardManager._onKeyboardReady(); } // don't bother close the last window if it's been killed // (happens when last window was replaced due to OOM-kill) if (this._lastWindow) { if (!this._lastWindow.isDead()) { this._lastWindow.close('immediate'); } this._lastWindow = null; } break; case 'input-appclosed': // bug 1112416: when blur and focus successively fire, there is a racing // where we may close an inputWindow while its input app is still busy // to become ready. If we were to set that inputWindow as inactive // input, it would abort that input app's becoming-ready progress and // we'll get stuck. Thus, do not really set the inactiveness when we // know the inputWindow is waiting to be ready. this._debug('inputWindow pendingReady: ' + inputWindow._pendingReady); if (inputWindow._pendingReady) { return; } inputWindow._setAsActiveInput(false); if (!this._currentWindow) { this._kbPublish('keyboardhidden', undefined); } break; case 'input-appterminated': // input app is OOM-killed... manifestURL = inputWindow.manifestURL; // We always destroy the reference of all current InputWindows for that // input app. this._removeInputApp(manifestURL); // if the showing window is the killed window, // we need to notify KeyboardManager to relaunch something. if (this._currentWindow && this._currentWindow.manifestURL === manifestURL) { KeyboardManager._onKeyboardKilled(manifestURL); } break; // We want to avoid the keyboard showing while opening // an actionmenu/attention. These are the event // pairs: // actionmenuopening - actionmenuhide // attentionopening - attentionopened // activityrequesting - activitycanceled/activitychoosen case 'actionmenuopening': case 'attentionopening': case 'activityrequesting': this.blockInput = true; this.hideInputWindowImmediately(); navigator.mozInputMethod.removeFocus(); break; case 'actionmenuhide': case 'activityclosing': case 'activitychoosen': case 'activitycanceled': case 'attentionrequestopen': case 'attentionrecovering': case 'activityopening': case 'attentionclosing': case 'attentionopened': case 'attentionclosed': case 'notification-clicked': case 'applicationsetupdialogshow': this.blockInput = false; this.hideInputWindowImmediately(); // Ensure the actual focus is lost too in case the dispatcher of // these events didn't remove the focus. navigator.mozInputMethod.removeFocus(); break; case 'lockscreen-appopened': case 'sheets-gesture-begin': case 'cardviewprepare': if (this._hasActiveInputApp()) { // Instead of hideInputWindow(), we should removeFocus() here. // (and removing the focus cause Gecko to ask us to hideInputWindow()) // We need to blur the app to prevent the keyboard from refocusing // right away. // See: https://bugzilla.mozilla.org/show_bug.cgi?id=1138977 var app = Service.query('getTopMostWindow'); app && app.blur(); navigator.mozInputMethod.removeFocus(); } break; case 'mozmemorypressure': // Memory pressure event. If input apps are loaded but not active, // get rid of them. // We only do that when we don't run input apps OOP. this._debug('mozmemorypressure event'); if (!this.isOutOfProcessEnabled && !this._hasActiveInputApp()) { this.getLoadedManifestURLs().forEach(manifestURL => { this._removeInputApp(manifestURL); }); this._debug('mozmemorypressure event; keyboards removed'); } break; } }; InputWindowManager.prototype._getMemory = function iwm_getMemory() { Service.request('getDeviceMemory').then((mem) => { this._totalMemory = mem; }); }; InputWindowManager.prototype._oopSettingCallback = function iwm_oopSettingCallback(value) { this.isOutOfProcessEnabled = value; }; // returns true if the currentWindow is being removed // we can't use Array.prototype.every() or some() because we'll anyway have // to loop through the whole array InputWindowManager.prototype._onInputLayoutsRemoved = function iwm_onInputLayoutsRemoved(manifestURLs) { var currentWindowRemoved = false; manifestURLs.forEach(manifestURL => { if (this._currentWindow && this._currentWindow.manifestURL === manifestURL) { this.hideInputWindow(); currentWindowRemoved = true; } this._removeInputApp(manifestURL); }); return currentWindowRemoved; }; InputWindowManager.prototype._removeInputApp = function iwm_removeInputApp(manifestURL) { if (!this._inputWindows[manifestURL]) { return; } for (var pathInitial in this._inputWindows[manifestURL]) { this._inputWindows[manifestURL][pathInitial].destroy(); delete this._inputWindows[manifestURL][pathInitial]; } delete this._inputWindows[manifestURL]; }; InputWindowManager.prototype.getHeight = function iwm_getHeight() { return this._currentWindow ? this._currentWindow.height : 0; }; InputWindowManager.prototype._hasActiveInputApp = function iwm_hasActiveInputApp() { return !!this._currentWindow; }; // Extract some re-usable configs from the layout // pathInitial is |path| without the hash part // hash is the hash part of the |path|, including #, may be empty string // XXX: this should be done at KeyboardManager or KeyboardHelper // when we normalize the layouts there InputWindowManager.prototype._extractLayoutConfigs = function iwm_extractLayoutConfigs(layout){ var manifestURL = layout.manifestURL; var path = layout.path; var id = layout.id; var origin = layout.origin; var pathInitial; var hash; if (path.indexOf('#') === -1) { pathInitial = path; hash = ''; } else { pathInitial = path.substring(0, path.indexOf('#')); hash = path.substring(path.indexOf('#')); } var app = applications.getByManifestURL(manifestURL); return { manifest: app.manifest, manifestURL: manifestURL, path: path, id: id, pathInitial: pathInitial, hash: hash, origin: origin }; }; InputWindowManager.prototype._makeInputWindow = function iwm_makeInputWindow(configs){ var isCertifiedApp = (configs.manifest.type === 'certified'); if (0 === this._totalMemory){ console.warn('InputWindowManager: totalMemory is 0'); } // oop is always enabled for non-certified app, // and optionally enabled to certified apps if // available memory is more than 512MB. if (this.isOutOfProcessEnabled && (!isCertifiedApp || this._totalMemory >= 512)) { this._debug('=== Enable keyboard: ' + configs.origin + ' run as OOP ==='); configs.oop = true; } else { configs.oop = false; } var inputWindow = new InputWindow(configs); this._inputWindows[configs.manifestURL] = this._inputWindows[configs.manifestURL] || {}; this._inputWindows[configs.manifestURL][configs.pathInitial] = inputWindow; return inputWindow; }; InputWindowManager.prototype.preloadInputWindow = function iwm_preloadInputWindow(layout) { var configs = this._extractLayoutConfigs(layout); configs.stayBackground = true; this._makeInputWindow(configs); }; InputWindowManager.prototype.showInputWindow = function iwm_showInputWindow(layout) { if (this.blockInput) { navigator.mozInputMethod.removeFocus(); return; } var configs = this._extractLayoutConfigs(layout); // see if we can reuse an InputWindow... var nextWindow = this._inputWindows[configs.manifestURL] ? this._inputWindows[configs.manifestURL][configs.pathInitial] : undefined; if (!nextWindow){ // no, we can't reuse. make a new one. nextWindow = this._makeInputWindow(configs); } if (this._currentWindow && nextWindow !== this._currentWindow) { // we have some displayed InputWindow and the new window is a different IW // let the new one show immediately, and this._lastWindow will also be // closed immediately after the new one is ready this._lastWindow = this._currentWindow; configs.immediateOpen = true; nextWindow.open(configs); } else { // we don't have any displayed InputWindow, or the currently displayed // one is what we'd like to open, so just show it normally. nextWindow.open(configs); } this._currentWindow = nextWindow; }; InputWindowManager.prototype.HIDE_INPUT_WINDOW_TIMEOUT = 200; InputWindowManager.prototype.hideInputWindow = function iwm_hideInputWindow() { if (!this._currentWindow){ return; } var windowToClose = this._windowToClose = this._currentWindow; this._currentWindow = null; // If the focus is regain within a short time, we would not want to resize // the forground app viewport, which creates suboptimal experiences // (see bug 1176926 and bug 1176771). // // Previously we tried to remove this timeout by delay the blurring message // with a next tick from forms.js (bug 1057898), but this is suboptimal // for the following reasons: While the focus is regain before next tick // when switching between two inputs (see the case in bug 1057898), // That will not work when the focus is removed by tapping a button and // regain at the click event of the button (which is the case of Message app // composer). In this case, the focus is removed at mousedown/touchstart // event; next tick of the blur would happen *before* touchend and click // events of the button. // // This timeout also happen to absorb the event order differences between // oop/inproc environment (see bug 1171950 comment 2). return new Promise(function(resolve) { setTimeout(resolve, this.HIDE_INPUT_WINDOW_TIMEOUT); }.bind(this)) .then(function iwm_publishKeyboardHideEventSync() { // We should not close ourselves if we are being set back to become // the currentWindow again, which implies we have already regain the // focus. if (this._currentWindow === windowToClose) { this._windowToClose = null; return; } // We also should abort if _windowToClose is already null, indicating // the keyboardhide event is already dispatched. if (this._windowToClose === null) { return; } // Set this flag to null so that keyboardhide event will not be // dispatched again in another call. this._windowToClose = null; // Publish an keyboardhide event that would cause the // foreground app to resize. // The promise will resolve when all the promises passed to waitUntil() // have resolved. return this._kbPublish('keyboardhide', undefined); }.bind(this)) .then(function iwm_hideInputWindowSync() { // We should not close ourselves if we are being set back to become // the currentWindow again. if (this._currentWindow === windowToClose) { return; } windowToClose.close(); }.bind(this)) .catch((e) => { console.error(e); }); }; InputWindowManager.prototype.hideInputWindowImmediately = function iwm_hideInputWindowImmediately() { if (!this._currentWindow && !this._windowToClose){ return; } this._kbPublish('keyboardhide', undefined); var windowToClose = this._currentWindow || this._windowToClose; this._currentWindow = this._windowToClose = null; windowToClose.close('immediate'); }; InputWindowManager.prototype.getLoadedManifestURLs = function iwm_getLoadedManifestURLs() { return Object.keys(this._inputWindows); }; // As per bug 952441, we want to use a special way to broadcast system-wide // keyboard-related events // This function returns a promise and it will only resolves when all // promises passed into evt.detail.waitUntil() is resolved. InputWindowManager.prototype._kbPublish = function iwm_kbPublish(type, height) { var chainedPromise = Promise.resolve(); var returned = false; var eventInitDict = { bubbles: true, cancelable: true, detail: { height: height, waitUntil: function(p) { // Need an extra protection here since waitUntil will be an no-op // when chainedPromise is already returned. if (returned) { throw new Error('InputWindowManager: You must call waitUntil()' + ' within the event handling loop.'); } // No point to put it into the queue if it's not a then-able. if (!p || typeof p.then !== 'function') { return; } chainedPromise = chainedPromise .then(function() { return p; }) .catch((e) => { console.error(e); }); } } }; // We dispatch the events at the body level so we are able to intercept // them and prevent page resizing where desired. var evt = new CustomEvent(type, eventInitDict); document.body.dispatchEvent(evt); returned = true; return chainedPromise; }; exports.InputWindowManager = InputWindowManager; })(window);