'use strict'; /* global InputAppList, ManifestHelper */ /** * Helper object to find all installed keyboard apps and layouts. * * (Need mozApps.mgmt and settings permission) */ (function(exports) { /* jshint validthis: true */ /** * The set of "basic keyboard" types */ var BASE_TYPES = new Set([ 'text', 'url', 'email', 'password', 'number', 'option' ]); /** * The keys stored in the settings database */ var SETTINGS_KEYS = { ENABLED: 'keyboard.enabled-layouts', DEFAULT: 'keyboard.default-layouts' }; var DEPRECATE_KEYBOARD_SETTINGS = { en: 'keyboard.layouts.english', 'en-Dvorak': 'keyboard.layouts.dvorak', cs: 'keyboard.layouts.czech', fr: 'keyboard.layouts.french', de: 'keyboard.layouts.german', hu: 'keyboard.layouts.hungarian', nb: 'keyboard.layouts.norwegian', my: 'keyboard.layouts.myanmar', sl: 'keyboard.layouts.slovak', tr: 'keyboard.layouts.turkish', ro: 'keyboard.layouts.romanian', ru: 'keyboard.layouts.russian', ar: 'keyboard.layouts.arabic', he: 'keyboard.layouts.hebrew', 'zh-Hant-Zhuyin': 'keyboard.layouts.zhuyin', 'zh-Hans-Pinyin': 'keyboard.layouts.pinyin', el: 'keyboard.layouts.greek', 'jp-kanji': 'keyboard.layouts.japanese', pl: 'keyboard.layouts.polish', 'pt-BR': 'keyboard.layouts.portuguese', sr: 'keyboard.layouts.serbian', es: 'keyboard.layouts.spanish', ca: 'keyboard.layouts.catalan' }; var MULTI_LAYOUT_MAP = { sr: ['sr-Cyrl', 'sr-Latn'] }; // In order to provide default defaults, we need to know the default keyboard var defaultKeyboardManifestURL = 'app://keyboard.gaiamobile.org/manifest.webapp'; // Stores a local copy of whatever is in the settings database var currentSettings = { defaultLayouts: {} }; // until we read otherwise, asssume the default keyboards are en and number currentSettings.defaultLayouts[defaultKeyboardManifestURL] = { en: true, number: true }; // and also assume that the defaults are the enabled currentSettings.enabledLayouts = map2dClone(currentSettings.defaultLayouts); /** * helper function for reading a value in one of the currentSettings */ function map2dIs(manifestURL, layoutId) { // force boolean true or false return !!(this[manifestURL] && this[manifestURL][layoutId]); } /** * helper function for setting a value to true in one of the currentSettings */ function map2dSet(manifestURL, layoutId) { if (!this[manifestURL]) { this[manifestURL] = {}; } this[manifestURL][layoutId] = true; } /** * helper function for setting a value to false in one of the currentSettings * deletes the appropriate keys */ function map2dUnset(manifestURL, layoutId) { if (!this[manifestURL]) { return; } delete this[manifestURL][layoutId]; if (!Object.keys(this[manifestURL]).length) { delete this[manifestURL]; } } /** * helper function for cloning one of the currentSettings */ function map2dClone(obj) { var result = {}; for (var key1 in obj) { result[key1] = {}; for (var key2 in obj[key1]) { result[key1][key2] = obj[key1][key2]; } } return result; } // callbacks when something changes var watchQueries = []; // holds the result of keyboard_layouts.json var defaultLayoutConfig; /** * Sends any watchers the result of their query. */ function kh_updateWatchers(reason) { if (watchQueries.length) { // we do a getApps in order to make sure that we have the most recent // app data before calling getLayouts KeyboardHelper.getApps(function withApps() { watchQueries.forEach(function eachWatchQuery(watch) { KeyboardHelper.getLayouts(watch.query, function withLayouts(layouts) { watch.callback(layouts, reason); }); }); }); } } // callbacks waiting to know when settings are loaded var waitingForSettings = []; var loadedSettings = new Set(); /** * Tracks the number of settings loaded and calls the callbacks */ function kh_loadedSetting(setting) { loadedSettings.add(setting); if (loadedSettings.size >= Object.keys(SETTINGS_KEYS).length) { waitingForSettings.forEach(function(callback) { callback(); }); waitingForSettings = []; kh_updateWatchers({ settings: true }); } } /** * Call a callback when the settings have loaded, or immediately if already * loaded. */ function kh_withSettings(callback) { if (loadedSettings.size >= Object.keys(SETTINGS_KEYS).length) { callback(); } else { waitingForSettings.push(callback); } } /** * Internal helper to read the settings again */ function kh_getSettings() { loadedSettings = new Set(); var lock = window.navigator.mozSettings.createLock(); lock.get(SETTINGS_KEYS.DEFAULT).onsuccess = kh_parseDefault; lock.get(SETTINGS_KEYS.ENABLED).onsuccess = kh_parseEnabled; } /** * Parse the result from the settings query for default layouts */ function kh_parseDefault() { var value = this.result[SETTINGS_KEYS.DEFAULT]; if (value) { currentSettings.defaultLayouts = value; } kh_loadedSetting(SETTINGS_KEYS.DEFAULT); } /** * Parse the result from the settings query for enabled layouts */ function kh_parseEnabled() { var value = this.result[SETTINGS_KEYS.ENABLED]; var needsSave = false; if (!value) { currentSettings.enabledLayouts = map2dClone(currentSettings.defaultLayouts); needsSave = true; } else { var type = typeof value; // deal with the old string format - might be able to be removed once test // devices have been living with this new format for a bit. if (type === 'string') { try { currentSettings.enabledLayouts = {}; var oldSettings = JSON.parse(value); oldSettings.forEach(function(layout) { if (layout.enabled) { var manifestURL = layout.manifestURL; if (!manifestURL) { manifestURL = layout.appOrigin + '/manifest.webapp'; } map2dSet.call(currentSettings.enabledLayouts, manifestURL, layout.layoutId); } }); } catch (e) { currentSettings.enabledLayouts = map2dClone(currentSettings.defaultLayouts); needsSave = true; } } else { // if it wasn't a string, assume its our object currentSettings.enabledLayouts = value; } } kh_loadedSetting(SETTINGS_KEYS.ENABLED); if (needsSave) { KeyboardHelper.saveToSettings(); } } /** * Parse and migrate the result for the deprecated settings (v1.1) for enabled * layouts. */ function kh_migrateDeprecatedSettings(deprecatedSettings) { /* jshint loopfunc: true */ var settingEntry = DEPRECATE_KEYBOARD_SETTINGS.en; // No need to do migration if the deprecated settings are not available if (deprecatedSettings[settingEntry] === undefined) { return; } // reset the enabled layouts currentSettings.enabledLayouts[defaultKeyboardManifestURL] = { number: true }; var hasEnabledLayout = false; for (var key in DEPRECATE_KEYBOARD_SETTINGS) { settingEntry = DEPRECATE_KEYBOARD_SETTINGS[key]; // this layout was set as enabled in the old settings if (deprecatedSettings[settingEntry]) { hasEnabledLayout = true; if (key in MULTI_LAYOUT_MAP) { MULTI_LAYOUT_MAP[key].forEach(function enableLayout(layoutId) { map2dSet.call(currentSettings.enabledLayouts, defaultKeyboardManifestURL, layoutId); }); } else { map2dSet.call(currentSettings.enabledLayouts, defaultKeyboardManifestURL, key); } } } // None of the layouts has been set enabled, so enable English by default if (!hasEnabledLayout) { map2dSet.call(currentSettings.enabledLayouts, defaultKeyboardManifestURL, 'en'); } // Clean up all the deprecated settings var deprecatedSettingsQuery = {}; for (key in DEPRECATE_KEYBOARD_SETTINGS) { // the deprecated setting entry, e.g. keyboard.layout.english settingEntry = DEPRECATE_KEYBOARD_SETTINGS[key]; // Set the default value for each entry deprecatedSettingsQuery[settingEntry] = null; } window.navigator.mozSettings.createLock().set(deprecatedSettingsQuery); KeyboardHelper.saveToSettings(); } /** * JSON loader */ function kh_loadJSON(href, callback) { if (!callback) { return; } var xhr = new XMLHttpRequest(); xhr.onerror = function() { console.error('Failed to fetch file: ' + href, xhr.statusText); }; xhr.onload = function() { callback(xhr.response); }; xhr.open('GET', href, true); // async xhr.responseType = 'json'; xhr.send(); } // // getSettings: Query the value of multiple settings at once. // // settings is an object whose property names are the settings to query // and whose property values are the default values to use if no such // setting is found. This function will create a setting lock and // request the value of each of the specified settings. Once it receives // a response to all of the queries, it passes all the settings values to // the specified callback function. The argument to the callback function // is an object just like the settings object, where the property name is // the setting name and the property value is the setting value (or the // default value if the setting was not found). // function kh_getMultiSettings(settings, callback) { var results = {}; try { var lock = navigator.mozSettings.createLock(); } catch (e) { // If settings is broken, just return the default values console.warn('Exception in mozSettings.createLock():', e, '\nUsing default values'); for (var p in settings) { results[p] = settings[p]; } callback(results); } var settingNames = Object.keys(settings); var numSettings = settingNames.length; var numResults = 0; for (var i = 0; i < numSettings; i++) { requestSetting(settingNames[i]); } function requestSetting(name) { var request; try { request = lock.get(name); } catch (e) { console.warn('Exception querying setting', name, ':', e, '\nUsing default value'); recordResult(name, settings[name]); return; } request.onsuccess = function() { var value = request.result[name]; if (value === undefined) { value = settings[name]; // Use the default value } recordResult(name, value); }; request.onerror = function(evt) { console.warn('Error querying setting', name, ':', evt.error); recordResult(name, settings[name]); }; } function recordResult(name, value) { results[name] = value; numResults++; if (numResults === numSettings) { callback(results); } } } /** * Creates an object that describes a keyboard layout. The objects prototype * will give it accessors for 'default' and 'enabled' that will read or change * the proper setting from currentSettings. * * @constructor */ function KeyboardLayout(options) { for (var key in options) { this[key] = options[key]; } } Object.defineProperties(KeyboardLayout.prototype, { 'default': { get: function kh_getLayoutIsDefault() { return map2dIs.call( currentSettings.defaultLayouts, this.app.manifestURL, this.layoutId ); } }, enabled: { get: function kh_getLayoutIsEnabled() { return map2dIs.call( currentSettings.enabledLayouts, this.app.manifestURL, this.layoutId ); }, set: function kh_setLayoutIsDefault(value) { var method = value ? map2dSet : map2dUnset; method.call(currentSettings.enabledLayouts, this.app.manifestURL, this.layoutId); } } }); /** * Exposed as KeyboardHelper.settings this gives us a fairly safe way to read * and write to the settings data structures directly. * XXX: is this really used anywhere? */ var kh_SettingsHelper = {}; Object.defineProperties(kh_SettingsHelper, { 'default': { get: function() { return map2dClone(currentSettings.defaultLayouts); }, set: function(value) { currentSettings.defaultLayouts = map2dClone(value); }, enumerable: true }, 'enabled': { get: function() { return map2dClone(currentSettings.enabledLayouts); }, set: function(value) { currentSettings.enabledLayouts = map2dClone(value); }, enumerable: true } }); var KeyboardHelper = exports.KeyboardHelper = { settings: kh_SettingsHelper, // bug 1035117: define the fallback layout for a group if no layout has been // selected for that group (if it's not enforced in settings) // please see the bug and its related UX spec for the sense of 'fallback' fallbackLayoutNames: { password: 'en' }, fallbackLayouts: {}, // InputAppList manages the current input apps for us. inputAppList: null, /** * Listen for changes in settings or apps and read the deafault settings */ init: function kh_init() { watchQueries = []; this.inputAppList = new InputAppList(); this.inputAppList.onupdate = kh_updateWatchers.bind(undefined, { apps: true }); this.inputAppList.start(); // load the current settings, and watch for changes to settings var settings = window.navigator.mozSettings; if (settings) { kh_getSettings(); settings.addObserver(SETTINGS_KEYS.ENABLED, kh_getSettings); settings.addObserver(SETTINGS_KEYS.DEFAULT, kh_getSettings); // read deprecated settings var deprecatedSettingsQuery = {}; for (var key in DEPRECATE_KEYBOARD_SETTINGS) { // the deprecated setting entry, e.g. keyboard.layout.english var settingEntry = DEPRECATE_KEYBOARD_SETTINGS[key]; // Set the default value for each entry deprecatedSettingsQuery[settingEntry] = undefined; } kh_getMultiSettings(deprecatedSettingsQuery, kh_migrateDeprecatedSettings); } window.addEventListener('applicationinstallsuccess', this); }, /** * Handles the application changed events. Clears the cache and updates * any listening watchers. */ handleEvent: function(event) { kh_updateWatchers({ apps: true }); }, /** * If this is a basic keyboard type */ isKeyboardType: function kh_isKeyboardType(type) { return BASE_TYPES.has(type); }, /** * Enables or disables a layout based on manifest URL and layoutId */ setLayoutEnabled: function kh_setLayoutEnabled(manifestURL, layoutId, enabled) { var method = enabled ? map2dSet : map2dUnset; method.call(currentSettings.enabledLayouts, manifestURL, layoutId); }, /** * Returns true if the layout specified by manifest URL and layoutId is * enabled. */ getLayoutEnabled: function kh_getLayoutEnabled(manifestURL, layoutId) { return map2dIs.call(currentSettings.enabledLayouts, manifestURL, layoutId); }, /** * set/unset a layout as default based on manifest URL and layoutId */ setLayoutIsDefault: function kh_setLayoutIsDefault(manifestURL, layoutId, enabled) { var method = enabled ? map2dSet : map2dUnset; method.call(currentSettings.defaultLayouts, manifestURL, layoutId); }, /** * Returns true if the layout specified by manifest URL and layoutId * is default. */ getLayoutIsDefault: function kh_getLayoutIsDefault(manifestURL, layoutId) { return map2dIs.call(currentSettings.defaultLayouts, manifestURL, layoutId); }, /** * Checks the currently enabled layouts to ensure there is at least one * 'text', 'url' and 'number' keyboard enabled. If there is not, it will * enable the corresponding default keyboard. * * Only calls the callback if a default was enabled, and passes an array * of layouts that were enabled. */ checkDefaults: function kh_checkDefaults(callback) { var layoutsEnabled = []; var missingTypes = []; ['text', 'url', 'number'].forEach(function eachType(type) { // getLayouts is sync when we already have data var enabled; this.getLayouts({ type: type, enabled: true }, function(layouts) { enabled = layouts.length; }); if (!enabled) { missingTypes.push(type); this.getLayouts({ type: type, 'default': true }, function(layouts) { if (layouts[0]) { layouts[0].enabled = true; layoutsEnabled.push(layouts[0]); } }); } }, this); if (layoutsEnabled.length) { if (callback) { callback(layoutsEnabled, missingTypes); } } }, /** * Saves the current state to settings. None of the changes made will be * saved automatically by KeyboardHelper. This allows someone to batch * whatever settings changes are nessecary, then call save. */ saveToSettings: function ke_saveToSettings() { var toSet = {}; toSet[SETTINGS_KEYS.ENABLED] = currentSettings.enabledLayouts; toSet[SETTINGS_KEYS.DEFAULT] = currentSettings.defaultLayouts; window.navigator.mozSettings.createLock().set(toSet); }, /** * Get a list of current keyboard applications. */ getApps: function kh_getApps(callback) { // every time we get a list of apps, clean up the settings var cleanupSettings = function(inputApps) { Object.keys(currentSettings.enabledLayouts) .concat(Object.keys(currentSettings.defaultLayouts)) .forEach(function(manifestURL) { // if the manifestURL doesn't exist in the list of apps, delete it // from the settings maps if (!inputApps.some(function(inputApp) { return (inputApp.domApp.manifestURL === manifestURL); })) { delete currentSettings.enabledLayouts[manifestURL]; delete currentSettings.defaultLayouts[manifestURL]; } }); }; // XXX We have to preserve the original getApps() behavior here, // i.e. call the sync callback when we already have the data. if (this.inputAppList.ready) { var inputApps = this.inputAppList.getListSync(); cleanupSettings(inputApps); callback(inputApps); return; } this.inputAppList.getList().then(function(inputApps) { cleanupSettings(inputApps); callback(inputApps); })['catch'](function(e) { // workaround gjslint error console.error(e); }); }, /** * Requests an array of layouts matching an optional set of search params. * Calls the callback when data is received, or immediately if already * cached locally. * * options.default - When true, only default layouts are returned. * options.enabled - When true, only enabled layouts are returned. * options.type - A string, or array of strings. Layouts filtered to type. */ getLayouts: function kh_getLayouts(options, callback) { if (typeof options === 'function') { callback = options; options = {}; } function withApps(inputApps) { /* jshint loopfunc: true */ // we'll delete keys in this active copy (= the purpose of copying) var fallbackLayoutNames = {}; for (var group in this.fallbackLayoutNames) { fallbackLayoutNames[group] = this.fallbackLayoutNames[group]; } this.fallbackLayouts = {}; var layouts = inputApps.reduce(function eachApp(result, inputApp) { var domApp = inputApp.domApp; var manifest = new ManifestHelper(domApp.manifest); var inputs = inputApp.getInputs(); for (var layoutId in inputs) { var inputManifest = inputs[layoutId]; if (!inputManifest.types) { console.warn(domApp.manifestURL, layoutId, 'did not declare type.'); continue; } var layout = new KeyboardLayout({ app: domApp, inputApp: inputApp, manifest: manifest, inputManifest: inputManifest, layoutId: layoutId }); // bug 1035117: insert a fallback layout regardless of its // and enabledness // XXX: we only do this for built-in keyboard? if (domApp.manifestURL === defaultKeyboardManifestURL) { for (var group in fallbackLayoutNames) { if (layoutId === fallbackLayoutNames[group]) { this.fallbackLayouts[group] = layout; delete fallbackLayoutNames[group]; } } } if (options['default'] && !layout['default']) { continue; } if (options.enabled && !layout.enabled) { continue; } if (options.type) { options.type = [].concat(options.type); // check for any type in our options to be any type in the layout if (!options.type.some(function(type) { return inputManifest.types.indexOf(type) !== -1; })) { continue; } } result.push(layout); } // sort the default query by most specific keyboard if (options['default']) { result.sort(function(a, b) { return a.inputManifest.types.length - b.inputManifest.types.length; }); } return result; }.bind(this), []); kh_withSettings(callback.bind(null, layouts)); } this.getApps(withApps.bind(this)); }, /** * Clears all watch queries */ stopWatching: function() { watchQueries = []; }, /** * Calls the callback whenever settings or apps are changed. The query is * passed onto getLayouts. The callback will be called as soon as the current * settings and apps are loaded as well. * * The callback will be passed an array of layouts, and a reason object. * I.E. function callback(layouts, reason) {} * reason.apps will be true when apps have changed, and reason.settings * will be true if settings changed. Both will be true on the first * callback. */ watchLayouts: function(query, callback) { if (typeof query === 'function') { callback = query; query = {}; } var watch = { query: query, callback: callback }; watchQueries.push(watch); this.getLayouts(query, function initialCall(layouts) { watch.layouts = layouts; callback(layouts, { apps: true, settings: true }); }); }, // Read keyboard_layouts for language -> layouts mapping getDefaultLayoutConfig: function kh_getDefaultLayoutConfig(callback) { if (!callback) { return; } if (defaultLayoutConfig) { callback(defaultLayoutConfig); } else { var KEYBOARDS = '/shared/resources/keyboard_layouts.json'; kh_loadJSON(KEYBOARDS, function loadKeyboardLayouts(data) { if (data) { defaultLayoutConfig = data; callback(defaultLayoutConfig); } }); } }, // Change the default layouts set according to the language // language: the current system language, used to look up the mapping table // reset: if set as true, will reset the current enabled layouts changeDefaultLayouts: function kh_changeDefaultLayouts(language, reset) { this.getDefaultLayoutConfig(function gotDefaultLayouts(keyboards) { var newKbLayouts = keyboards.layout[language]; // XXX: change this so that it could support multiple built-in // keyboard apps var kbManifestURL = defaultKeyboardManifestURL; // reset the set of default layouts currentSettings.defaultLayouts = {}; // set the language-independent default layouts var langIndependentLayouts = keyboards.langIndependentLayouts; for (var i = langIndependentLayouts.length - 1; i >= 0; i--) { this.setLayoutIsDefault(kbManifestURL, langIndependentLayouts[i].layoutId, true); } if (reset) { // reset the set of default layouts currentSettings.enabledLayouts = map2dClone(currentSettings.defaultLayouts); } // Enable the language specific keyboard layout group for (i = newKbLayouts.length - 1; i >= 0; i--) { this.setLayoutIsDefault(kbManifestURL, newKbLayouts[i].layoutId, true); this.setLayoutEnabled(kbManifestURL, newKbLayouts[i].layoutId, true); } this.saveToSettings(); // save changes to settings }.bind(this)); } }; KeyboardHelper.init(); }(window));