diff options
Diffstat (limited to 'apps/system/js')
60 files changed, 15816 insertions, 0 deletions
diff --git a/apps/system/js/accessibility.js b/apps/system/js/accessibility.js new file mode 100644 index 0000000..c2fa111 --- /dev/null +++ b/apps/system/js/accessibility.js @@ -0,0 +1,19 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +SettingsListener.observe('accessibility.invert', false, function(value) { + var screen = document.getElementById('screen'); + if (value) + screen.classList.add('accessibility-invert'); + else + screen.classList.remove('accessibility-invert'); +}); + +SettingsListener.observe('accessibility.screenreader', false, function(value) { + var event = document.createEvent('CustomEvent'); + event.initCustomEvent('mozContentEvent', true, true, + {type: 'accessibility-screenreader', enabled: value}); + window.dispatchEvent(event); +}); diff --git a/apps/system/js/activities.js b/apps/system/js/activities.js new file mode 100644 index 0000000..f4db861 --- /dev/null +++ b/apps/system/js/activities.js @@ -0,0 +1,86 @@ +/* -*- Mode: js; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +var Activities = { + init: function act_init() { + window.addEventListener('mozChromeEvent', this); + }, + + handleEvent: function act_handleEvent(evt) { + switch (evt.type) { + case 'mozChromeEvent': + var detail = evt.detail; + switch (detail.type) { + case 'activity-choice': + this.chooseActivity(detail); + break; + } + break; + } + }, + + chooseActivity: function chooseActivity(detail) { + this._id = detail.id; + + var choices = detail.choices; + if (choices.length === 1) { + this.choose('0'); + } else { + // Since the mozChromeEvent could be triggered by a 'click', and gecko + // event are synchronous make sure to exit the event loop before + // showing the list. + setTimeout((function nextTick() { + var activityName = navigator.mozL10n.get('activity-' + detail.name); + ListMenu.request(this._listItems(choices), activityName, + this.choose.bind(this), this.cancel.bind(this)); + }).bind(this)); + } + }, + + choose: function act_choose(choice) { + var returnedChoice = { + id: this._id, + type: 'activity-choice', + value: choice + }; + + this._sendEvent(returnedChoice); + delete this._id; + }, + + cancel: function act_cancel(value) { + var returnedChoice = { + id: this._id, + type: 'activity-choice', + value: -1 + }; + + this._sendEvent(returnedChoice); + delete this._id; + }, + + _sendEvent: function act_sendEvent(value) { + var event = document.createEvent('CustomEvent'); + event.initCustomEvent('mozContentEvent', true, true, value); + window.dispatchEvent(event); + }, + + _listItems: function act_listItems(choices) { + var items = []; + + choices.forEach(function(choice, index) { + var app = Applications.getByManifestURL(choice.manifest); + items.push({ + label: new ManifestHelper(app.manifest).name, + icon: choice.icon, + value: index + }); + }); + + return items; + } +}; + +Activities.init(); diff --git a/apps/system/js/airplane_mode.js b/apps/system/js/airplane_mode.js new file mode 100644 index 0000000..781a53f --- /dev/null +++ b/apps/system/js/airplane_mode.js @@ -0,0 +1,138 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +var AirplaneMode = { + enabled: false, + + init: function apm_init() { + if (!window.navigator.mozSettings) + return; + + var mobileDataEnabled = false; + SettingsListener.observe('ril.data.enabled', false, function(value) { + mobileDataEnabled = value; + }); + + var bluetoothEnabled = false; + SettingsListener.observe('bluetooth.enabled', false, function(value) { + bluetoothEnabled = value; + }); + + var wifiEnabled = false; + SettingsListener.observe('wifi.enabled', false, function(value) { + wifiEnabled = value; + }); + + var geolocationEnabled = false; + SettingsListener.observe('geolocation.enabled', false, function(value) { + geolocationEnabled = value; + }); + + var bluetooth = window.navigator.mozBluetooth; + var wifiManager = window.navigator.mozWifiManager; + var mobileData = window.navigator.mozMobileConnection && + window.navigator.mozMobileConnection.data; + var fmRadio = window.navigator.mozFMRadio; + + var restoreMobileData = false; + var restoreBluetooth = false; + var restoreWifi = false; + var restoreGeolocation = false; + // Note that we don't restore Wifi tethering when leaving airplane mode + // because Wifi tethering can't be switched on before data connection is + // established. + + var self = this; + SettingsListener.observe('ril.radio.disabled', false, function(value) { + if (value) { + // Entering airplane mode. + self.enabled = true; + + // Turn off mobile data + // We toggle the mozSettings value here just for the sake of UI, + // platform ril disconnects mobile data when + // 'ril.radio.disabled' is true. + if (mobileData) { + restoreMobileData = mobileDataEnabled; + if (mobileDataEnabled) { + SettingsListener.getSettingsLock().set({ + 'ril.data.enabled': false + }); + } + } + + // Turn off Bluetooth. + if (bluetooth) { + restoreBluetooth = bluetoothEnabled; + if (bluetoothEnabled) { + SettingsListener.getSettingsLock().set({ + 'bluetooth.enabled': false + }); + } + } + + // Turn off Wifi. + if (wifiManager) { + restoreWifi = wifiEnabled; + if (wifiEnabled) { + SettingsListener.getSettingsLock().set({ + 'wifi.enabled': false + }); + } + + // Turn off Wifi tethering. + SettingsListener.getSettingsLock().set({ + 'tethering.wifi.enabled': false + }); + } + + // Turn off Geolocation. + restoreGeolocation = geolocationEnabled; + if (geolocationEnabled) { + SettingsListener.getSettingsLock().set({ + 'geolocation.enabled': false + }); + } + + // Turn off FM Radio. + if (fmRadio && fmRadio.enabled) + fmRadio.disable(); + + } else { + self.enabled = false; + // Don't attempt to turn on mobile data if it's already on + if (mobileData && !mobileDataEnabled && restoreMobileData) { + SettingsListener.getSettingsLock().set({ + 'ril.data.enabled': true + }); + } + + // Don't attempt to turn on Bluetooth if it's already on + if (bluetooth && !bluetooth.enabled && restoreBluetooth) { + SettingsListener.getSettingsLock().set({ + 'bluetooth.enabled': true + }); + } + + // Don't attempt to turn on Wifi if it's already on + if (wifiManager && !wifiManager.enabled && restoreWifi) { + SettingsListener.getSettingsLock().set({ + 'wifi.enabled': true + }); + } + + // Don't attempt to turn on Geolocation if it's already on + if (!geolocationEnabled && restoreGeolocation) { + SettingsListener.getSettingsLock().set({ + 'geolocation.enabled': true + }); + } + } + }); + } +}; + +AirplaneMode.init(); + diff --git a/apps/system/js/app_install_manager.js b/apps/system/js/app_install_manager.js new file mode 100644 index 0000000..146a01b --- /dev/null +++ b/apps/system/js/app_install_manager.js @@ -0,0 +1,443 @@ +'use strict'; + +var AppInstallManager = { + mapDownloadErrorsToMessage: { + 'NETWORK_ERROR': 'download-failed', + 'DOWNLOAD_ERROR': 'download-failed', + 'MISSING_MANIFEST': 'install-failed', + 'INVALID_MANIFEST': 'install-failed', + 'INSTALL_FROM_DENIED': 'install-failed', + 'INVALID_SECURITY_LEVEL': 'install-failed', + 'INVALID_PACKAGE': 'install-failed', + 'APP_CACHE_DOWNLOAD_ERROR': 'download-failed' + }, + + init: function ai_init() { + this.dialog = document.getElementById('app-install-dialog'); + this.msg = document.getElementById('app-install-message'); + this.size = document.getElementById('app-install-size'); + this.authorName = document.getElementById('app-install-author-name'); + this.authorUrl = document.getElementById('app-install-author-url'); + this.installButton = document.getElementById('app-install-install-button'); + this.cancelButton = document.getElementById('app-install-cancel-button'); + this.installCancelDialog = + document.getElementById('app-install-cancel-dialog'); + this.downloadCancelDialog = + document.getElementById('app-download-cancel-dialog'); + this.confirmCancelButton = + document.getElementById('app-install-confirm-cancel-button'); + this.resumeButton = document.getElementById('app-install-resume-button'); + + this.notifContainer = + document.getElementById('install-manager-notification-container'); + this.appInfos = {}; + + window.addEventListener('mozChromeEvent', + (function ai_handleChromeEvent(e) { + if (e.detail.type == 'webapps-ask-install') { + this.handleAppInstallPrompt(e.detail); + } + }).bind(this)); + + window.addEventListener('applicationinstall', + this.handleApplicationInstall.bind(this)); + + + this.installButton.onclick = this.handleInstall.bind(this); + this.cancelButton.onclick = this.showInstallCancelDialog.bind(this); + this.confirmCancelButton.onclick = this.handleInstallCancel.bind(this); + this.resumeButton.onclick = this.hideInstallCancelDialog.bind(this); + this.notifContainer.onclick = this.showDownloadCancelDialog.bind(this); + + this.downloadCancelDialog.querySelector('.confirm').onclick = + this.handleConfirmDownloadCancel.bind(this); + this.downloadCancelDialog.querySelector('.cancel').onclick = + this.handleCancelDownloadCancel.bind(this); + + // bind these handlers so that we can have only one instance and check + // them later on + ['handleDownloadSuccess', + 'handleDownloadError', + 'handleProgress', + 'handleApplicationReady' + ].forEach(function(name) { + this[name] = this[name].bind(this); + }, this); + + window.addEventListener('applicationready', + this.handleApplicationReady); + }, + + handleApplicationReady: function ai_handleApplicationReady(e) { + window.removeEventListener('applicationready', + this.handleApplicationReady); + + var apps = e.detail.applications; + + Object.keys(apps) + .filter(function(key) { return apps[key].installState === 'pending'; }) + .map(function(key) { return apps[key]; }) + .forEach(this.prepareForDownload, this); + }, + + handleApplicationInstall: function ai_handleApplicationInstallEvent(e) { + var app = e.detail.application; + + if (app.installState === 'installed') { + this.showInstallSuccess(app); + return; + } + + this.prepareForDownload(app); + }, + + handleAppInstallPrompt: function ai_handleInstallPrompt(detail) { + var _ = navigator.mozL10n.get; + var app = detail.app; + // updateManifest is used by packaged apps until they are installed + var manifest = app.manifest ? app.manifest : app.updateManifest; + + if (!manifest) + return; + + this.dialog.classList.add('visible'); + + var id = detail.id; + + if (manifest.size) { + this.size.textContent = this.humanizeSize(manifest.size); + } else { + this.size.textContent = _('unknown'); + } + + // Wrap manifest to get localized properties + manifest = new ManifestHelper(manifest); + var msg = _('install-app', {'name': manifest.name}); + this.msg.textContent = msg; + + if (manifest.developer) { + this.authorName.textContent = manifest.developer.name || _('unknown'); + this.authorUrl.textContent = manifest.developer.url || ''; + } else { + this.authorName.textContent = _('unknown'); + this.authorUrl.textContent = ''; + } + + this.installCallback = (function ai_installCallback() { + this.dispatchResponse(id, 'webapps-install-granted'); + }).bind(this); + + this.installCancelCallback = (function ai_cancelCallback() { + this.dispatchResponse(id, 'webapps-install-denied'); + }).bind(this); + + }, + + handleInstall: function ai_handleInstall(evt) { + if (evt) + evt.preventDefault(); + if (this.installCallback) + this.installCallback(); + this.installCallback = null; + this.dialog.classList.remove('visible'); + }, + + prepareForDownload: function ai_prepareForDownload(app) { + var manifestURL = app.manifestURL; + this.appInfos[manifestURL] = {}; + + // these methods are already bound to |this| + app.ondownloadsuccess = this.handleDownloadSuccess; + app.ondownloaderror = this.handleDownloadError; + app.onprogress = this.handleProgress; + }, + + showInstallSuccess: function ai_showInstallSuccess(app) { + var manifest = app.manifest || app.updateManifest; + var name = new ManifestHelper(manifest).name; + var _ = navigator.mozL10n.get; + var msg = _('app-install-success', { appName: name }); + SystemBanner.show(msg); + }, + + handleDownloadSuccess: function ai_handleDownloadSuccess(evt) { + var app = evt.application; + this.showInstallSuccess(app); + this.onDownloadStop(app); + this.onDownloadFinish(app); + }, + + handleDownloadError: function ai_handleDownloadError(evt) { + var app = evt.application; + var _ = navigator.mozL10n.get; + var manifest = app.manifest || app.updateManifest; + var name = new ManifestHelper(manifest).name; + + var errorName = app.downloadError.name; + + switch (errorName) { + case 'INSUFFICIENT_STORAGE': + var title = _('not-enough-space'), + buttonText = _('ok'), + message = _('not-enough-space-message'); + + ModalDialog.alert(title, message, {title: buttonText}); + break; + default: + // showing the real error to a potential developer + console.info('downloadError event, error code is', errorName); + + var key = this.mapDownloadErrorsToMessage[errorName] || 'generic-error'; + var msg = _('app-install-' + key, { appName: name }); + SystemBanner.show(msg); + } + + this.onDownloadStop(app); + }, + + onDownloadStart: function ai_onDownloadStart(app) { + if (! this.hasNotification(app)) { + StatusBar.incSystemDownloads(); + this.addNotification(app); + this.requestWifiLock(app); + } + }, + + onDownloadStop: function ai_onDownloadStop(app) { + if (this.hasNotification(app)) { + StatusBar.decSystemDownloads(); + this.removeNotification(app); + this.releaseWifiLock(app); + } + }, + + hasNotification: function ai_hasNotification(app) { + var appInfo = this.appInfos[app.manifestURL]; + return appInfo && !!appInfo.installNotification; + }, + + onDownloadFinish: function ai_onDownloadFinish(app) { + delete this.appInfos[app.manifestURL]; + + // check if these are our handlers before removing them + if (app.ondownloadsuccess === this.handleDownloadSuccess) { + app.ondownloadsuccess = null; + } + + if (app.ondownloaderror === this.handleDownloadError) { + app.ondownloaderror = null; + } + + if (app.onprogress === this.handleProgress) { + app.onprogress = null; + } + }, + + addNotification: function ai_addNotification(app) { + // should be unique (this is used already in applications.js) + var manifestURL = app.manifestURL, + manifest = app.manifest || app.updateManifest, + appInfo = this.appInfos[manifestURL]; + + if (appInfo.installNotification) { + return; + } + + var newNotif = + '<div class="fake-notification">' + + '<div class="message"></div>' + + '<progress></progress>' + + '</div>'; + + this.notifContainer.insertAdjacentHTML('afterbegin', newNotif); + + var newNode = this.notifContainer.firstElementChild; + newNode.dataset.manifest = manifestURL; + + var _ = navigator.mozL10n.get; + + var message = _('downloadingAppMessage', { + appName: new ManifestHelper(manifest).name + }); + + newNode.querySelector('.message').textContent = message; + + var size = manifest.size, + progressNode = newNode.querySelector('progress'); + if (size) { + progressNode.max = size; + appInfo.hasMax = true; + } + + appInfo.installNotification = newNode; + NotificationScreen.incExternalNotifications(); + }, + + getNotificationProgressNode: function ai_getNotificationProgressNode(app) { + var appInfo = this.appInfos[app.manifestURL]; + var progressNode = appInfo && + appInfo.installNotification && + appInfo.installNotification.querySelector('progress'); + return progressNode || null; + }, + + handleProgress: function ai_handleProgress(evt) { + var app = evt.application, + appInfo = this.appInfos[app.manifestURL]; + + this.onDownloadStart(app); + + + var progressNode = this.getNotificationProgressNode(app); + var message; + var _ = navigator.mozL10n.get; + + if (isNaN(app.progress) || app.progress == null) { + // now we get NaN if there is no progress information but let's + // handle the null and undefined cases as well + message = _('downloadingAppProgressIndeterminate'); + progressNode.value = undefined; // switch to indeterminate state + } else if (appInfo.hasMax) { + message = _('downloadingAppProgress', + { + progress: this.humanizeSize(app.progress), + max: this.humanizeSize(progressNode.max) + }); + progressNode.value = app.progress; + } else { + message = _('downloadingAppProgressNoMax', + { progress: this.humanizeSize(app.progress) }); + } + progressNode.textContent = message; + }, + + removeNotification: function ai_removeNotification(app) { + var manifestURL = app.manifestURL, + appInfo = this.appInfos[manifestURL], + node = appInfo.installNotification; + + if (!node) { + return; + } + + node.parentNode.removeChild(node); + delete appInfo.installNotification; + NotificationScreen.decExternalNotifications(); + }, + + requestWifiLock: function ai_requestWifiLock(app) { + var appInfo = this.appInfos[app.manifestURL]; + if (! appInfo.wifiLock) { + // we don't want 2 locks for the same app + appInfo.wifiLock = navigator.requestWakeLock('wifi'); + } + }, + + releaseWifiLock: function ai_releaseWifiLock(app) { + var appInfo = this.appInfos[app.manifestURL]; + + if (appInfo.wifiLock) { + try { + appInfo.wifiLock.unlock(); + } catch (e) { + // this can happen if the lock is already unlocked + console.error('error during unlock', e); + } + + delete appInfo.wifiLock; + } + }, + + dispatchResponse: function ai_dispatchResponse(id, type) { + var event = document.createEvent('CustomEvent'); + + event.initCustomEvent('mozContentEvent', true, true, { + id: id, + type: type + }); + + window.dispatchEvent(event); + }, + + humanizeSize: function ai_humanizeSize(bytes) { + var _ = navigator.mozL10n.get; + var units = ['bytes', 'kB', 'MB', 'GB', 'TB', 'PB']; + + if (!bytes) + return '0.00 ' + _(units[0]); + + var e = Math.floor(Math.log(bytes) / Math.log(1024)); + return (bytes / Math.pow(1024, Math.floor(e))).toFixed(2) + ' ' + + _(units[e]); + }, + + showInstallCancelDialog: function ai_showInstallCancelDialog(evt) { + if (evt) + evt.preventDefault(); + this.installCancelDialog.classList.add('visible'); + this.dialog.classList.remove('visible'); + }, + + hideInstallCancelDialog: function ai_hideInstallCancelDialog(evt) { + if (evt) + evt.preventDefault(); + this.dialog.classList.add('visible'); + this.installCancelDialog.classList.remove('visible'); + }, + + showDownloadCancelDialog: function ai_showDownloadCancelDialog(e) { + var currentNode = e.target; + + if (!currentNode.classList.contains('fake-notification')) { + // tapped outside of a notification + return; + } + + var manifestURL = currentNode.dataset.manifest, + app = Applications.getByManifestURL(manifestURL), + manifest = app.manifest || app.updateManifest, + dialog = this.downloadCancelDialog; + + var title = dialog.querySelector('h1'); + + title.textContent = navigator.mozL10n.get('stopDownloading', { + app: new ManifestHelper(manifest).name + }); + + dialog.classList.add('visible'); + dialog.dataset.manifest = manifestURL; + UtilityTray.hide(); + }, + + handleInstallCancel: function ai_handleInstallCancel() { + if (this.installCancelCallback) + this.installCancelCallback(); + this.installCancelCallback = null; + this.installCancelDialog.classList.remove('visible'); + }, + + handleConfirmDownloadCancel: function ai_handleConfirmDownloadCancel(e) { + e && e.preventDefault(); + var dialog = this.downloadCancelDialog, + manifestURL = dialog.dataset.manifest; + if (manifestURL) { + var app = Applications.getByManifestURL(manifestURL); + app && app.cancelDownload(); + } + + this.hideDownloadCancelDialog(); + }, + + handleCancelDownloadCancel: function ai_handleCancelDownloadCancel(e) { + e && e.preventDefault(); + this.hideDownloadCancelDialog(); + }, + + hideDownloadCancelDialog: function() { + var dialog = this.downloadCancelDialog; + dialog.classList.remove('visible'); + delete dialog.dataset.manifest; + } +}; + +AppInstallManager.init(); diff --git a/apps/system/js/applications.js b/apps/system/js/applications.js new file mode 100644 index 0000000..f1e286f --- /dev/null +++ b/apps/system/js/applications.js @@ -0,0 +1,99 @@ +/* -*- Mode: js; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +// Application module handles the information of apps on behalf of other +// modules. + +var Applications = { + installedApps: {}, + ready: false, + init: function a_init() { + var self = this; + var apps = navigator.mozApps; + + var getAllApps = function getAllApps() { + navigator.mozApps.mgmt.getAll().onsuccess = function mozAppGotAll(evt) { + var apps = evt.target.result; + apps.forEach(function(app) { + self.installedApps[app.manifestURL] = app; + // TODO Followup for retrieving homescreen & comms app + }); + + self.ready = true; + self.fireApplicationReadyEvent(); + }; + }; + + // We need to wait for the chrome shell to let us know when it's ok to + // launch activities. This prevents race conditions. + // The event does not fire again when we reload System app in on + // B2G Desktop, so we save the information into sessionStorage. + if (window.sessionStorage.getItem('webapps-registry-ready')) { + getAllApps(); + } else { + window.addEventListener('mozChromeEvent', function mozAppReady(event) { + if (event.detail.type != 'webapps-registry-ready') + return; + + window.sessionStorage.setItem('webapps-registry-ready', 'yes'); + window.removeEventListener('mozChromeEvent', mozAppReady); + + getAllApps(); + }); + } + + apps.mgmt.oninstall = function a_install(evt) { + var newapp = evt.application; + self.installedApps[newapp.manifestURL] = newapp; + + self.fireApplicationInstallEvent(newapp); + }; + + apps.mgmt.onuninstall = function a_uninstall(evt) { + var deletedapp = evt.application; + delete self.installedApps[deletedapp.manifestURL]; + + self.fireApplicationUninstallEvent(deletedapp); + }; + }, + + getByManifestURL: function a_getByManifestURL(manifestURL) { + if (manifestURL in this.installedApps) { + return this.installedApps[manifestURL]; + } + + return null; + }, + + fireApplicationReadyEvent: function a_fireAppReadyEvent() { + var evt = document.createEvent('CustomEvent'); + evt.initCustomEvent('applicationready', + /* canBubble */ true, /* cancelable */ false, + { applications: this.installedApps }); + window.dispatchEvent(evt); + }, + + // We need to dispatch the following events because + // mozApps is not doing so right now. + // ref: https://bugzilla.mozilla.org/show_bug.cgi?id=731746 + + fireApplicationInstallEvent: function a_fireApplicationInstallEvent(app) { + var evt = document.createEvent('CustomEvent'); + evt.initCustomEvent('applicationinstall', + /* canBubble */ true, /* cancelable */ false, + { application: app }); + window.dispatchEvent(evt); + }, + + fireApplicationUninstallEvent: function a_fireApplicationUninstallEvent(app) { + var evt = document.createEvent('CustomEvent'); + evt.initCustomEvent('applicationuninstall', + /* canBubble */ true, /* cancelable */ false, + { application: app }); + window.dispatchEvent(evt); + } +}; + +Applications.init(); diff --git a/apps/system/js/attention_screen.js b/apps/system/js/attention_screen.js new file mode 100644 index 0000000..c14dbe8 --- /dev/null +++ b/apps/system/js/attention_screen.js @@ -0,0 +1,289 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ +'use strict'; + +var AttentionScreen = { + get mainScreen() { + delete this.mainScreen; + return this.mainScreen = document.getElementById('screen'); + }, + + get attentionScreen() { + delete this.attentionScreen; + return this.attentionScreen = document.getElementById('attention-screen'); + }, + + get bar() { + delete this.bar; + return this.bar = document.getElementById('attention-bar'); + }, + + isVisible: function as_isVisible() { + return this.attentionScreen.classList.contains('displayed'); + }, + + isFullyVisible: function as_isFullyVisible() { + return (this.isVisible() && + !this.mainScreen.classList.contains('active-statusbar')); + }, + + init: function as_init() { + window.addEventListener('mozbrowseropenwindow', this.open.bind(this), true); + window.addEventListener('mozbrowserclose', this.close.bind(this), true); + window.addEventListener('mozbrowsererror', this.close.bind(this), true); + window.addEventListener('keyboardchange', this.resize.bind(this), true); + window.addEventListener('keyboardhide', this.resize.bind(this), true); + + this.bar.addEventListener('click', this.show.bind(this)); + window.addEventListener('home', this.hide.bind(this)); + window.addEventListener('holdhome', this.hide.bind(this)); + window.addEventListener('appwillopen', this.hide.bind(this)); + }, + + resize: function as_resize(evt) { + if (evt.type == 'keyboardchange') { + if (!this.isFullyVisible()) + return; + + this.attentionScreen.style.height = + window.innerHeight - evt.detail.height + 'px'; + } else if (evt.type == 'keyboardhide') { + // We still need to reset the height property even when the attention + // screen is not fully visible, or it will overrides the height + // we defined with #attention-screen.status-mode + this.attentionScreen.style.height = ''; + } + }, + + // show the attention screen overlay with newly created frame + open: function as_open(evt) { + if (evt.detail.features != 'attention') + return; + + // stopPropagation means we are not allowing + // Popup Manager to handle this event + evt.stopPropagation(); + + // Canceling any full screen web content + if (document.mozFullScreen) + document.mozCancelFullScreen(); + + // Check if the app has the permission to open attention screens + var manifestURL = evt.target.getAttribute('mozapp'); + var app = Applications.getByManifestURL(manifestURL); + + if (!app || !this._hasAttentionPermission(app)) + return; + + // Hide sleep menu/list menu if it is shown now + ListMenu.hide(); + SleepMenu.hide(); + + // We want the user attention, so we need to turn the screen on + // if it's off. The lockscreen will grab the focus when we do that + // so we need to do it before adding the new iframe to the dom + if (!ScreenManager.screenEnabled) + ScreenManager.turnScreenOn(); + + var attentionFrame = evt.detail.frameElement; + attentionFrame.dataset.frameType = 'attention'; + attentionFrame.dataset.frameName = evt.detail.name; + attentionFrame.dataset.frameOrigin = evt.target.dataset.frameOrigin; + + // We would like to put the dialer call screen on top of all other + // attention screens by ensure it is the last iframe in the DOM tree + if (this._hasTelephonyPermission(app)) { + this.attentionScreen.appendChild(attentionFrame); + } else { + this.attentionScreen.insertBefore(attentionFrame, + this.bar.nextElementSibling); + } + + this._updateAttentionFrameVisibility(); + + // Make the overlay visible if we are not displayed yet. + // alternatively, if the newly appended frame is the visible frame + // and we are in the status bar mode, expend to full screen mode. + if (!this.isVisible()) { + this.attentionScreen.classList.add('displayed'); + this.mainScreen.classList.add('attention'); + this.dispatchEvent('attentionscreenshow', { + origin: attentionFrame.dataset.frameOrigin + }); + } else if (!this.isFullyVisible() && + this.attentionScreen.lastElementChild === attentionFrame) { + this.show(); + } + }, + + // Make sure visibililty state of all attention screens are set correctly + _updateAttentionFrameVisibility: function as_updateAtteFrameVisibility() { + var frames = this.attentionScreen.querySelectorAll('iframe'); + var i = frames.length - 1; + + // In case someone call this function w/o checking for frame first + if (i < 0) + return; + + // set the last one in the DOM to visible + // The setTimeout() and the closure is used to workaround + // https://bugzilla.mozilla.org/show_bug.cgi?id=810431 + setTimeout(function(frame) { + frame.setVisible(true); + frame.focus(); + }, 0, frames[i]); + + while (i--) { + // The setTimeout() and the closure is used to workaround + // https://bugzilla.mozilla.org/show_bug.cgi?id=810431 + setTimeout(function(frame) { + frame.setVisible(false); + frame.blur(); + }, 0, frames[i]); + } + }, + + // close the attention screen overlay + close: function as_close(evt) { + if (!'frameType' in evt.target.dataset || + evt.target.dataset.frameType !== 'attention' || + (evt.type === 'mozbrowsererror' && evt.detail.type !== 'fatal')) + return; + + // Remove the frame + var origin = evt.target.dataset.frameOrigin; + this.attentionScreen.removeChild(evt.target); + + // We've just removed the focused window leaving the system + // without any focused window, let's fix this. + window.focus(); + + // if there are other attention frames, + // we need to update the visibility and show() the overlay. + if (this.attentionScreen.querySelectorAll('iframe').length) { + this._updateAttentionFrameVisibility(); + + this.dispatchEvent('attentionscreenclose', { origin: origin }); + + if (!this.isFullyVisible()) + this.show(); + + return; + } + + // There is no iframes left; + // we should close the attention screen overlay. + + // If the the attention screen is closed during active-statusbar + // mode, we would need to leave that mode. + if (!this.isFullyVisible()) { + this.mainScreen.classList.remove('active-statusbar'); + this.attentionScreen.classList.remove('status-mode'); + this.dispatchEvent('status-inactive', + { origin: this.attentionScreen.lastElementChild.dataset.frameOrigin }); + } + + this.attentionScreen.classList.remove('displayed'); + this.mainScreen.classList.remove('attention'); + this.dispatchEvent('attentionscreenhide', { origin: origin }); + }, + + // expend the attention screen overlay to full screen + show: function as_show() { + // leaving "status-mode". + this.attentionScreen.classList.remove('status-mode'); + // there shouldn't be a transition from "status-mode" to "active-statusbar" + this.attentionScreen.style.transition = 'none'; + + var self = this; + setTimeout(function nextTick() { + self.attentionScreen.style.transition = ''; + + // leaving "active-statusbar" mode, + // with a transform: translateY() slide down transition. + self.mainScreen.classList.remove('active-statusbar'); + self.dispatchEvent('status-inactive', { + origin: self.attentionScreen.lastElementChild.dataset.frameOrigin + }); + }); + }, + + // shrink the attention screen overlay to status bar + // invoked when we get a "home" event + hide: function as_hide() { + if (!this.isFullyVisible()) + return; + + // entering "active-statusbar" mode, + // with a transform: translateY() slide up transition. + this.mainScreen.classList.add('active-statusbar'); + + // The only way to hide attention screen is the home/holdhome event. + // So we don't fire any origin information here. + // The expected behavior is restore homescreen visibility to 'true' + // in the Window Manager. + this.dispatchEvent('status-active'); + + var attentionScreen = this.attentionScreen; + attentionScreen.addEventListener('transitionend', function trWait() { + attentionScreen.removeEventListener('transitionend', trWait); + + // transition completed, entering "status-mode" (40px height iframe) + attentionScreen.classList.add('status-mode'); + }); + }, + + dispatchEvent: function as_dispatchEvent(name, detail) { + var evt = document.createEvent('CustomEvent'); + evt.initCustomEvent(name, true, true, detail); + window.dispatchEvent(evt); + }, + + // If an app with an active attention screen is switched to, + // we would need to cover it with it's attention screen. + // Invoked when displayedApp in Window Manager changes + // XXX should be replaced with a call that listens to appwillopen + // TBD: display the attention screen underneath other attention screens. + showForOrigin: function as_showForOrigin(origin) { + if (!this.isVisible() || this.isFullyVisible()) + return; + + var attentionFrame = this.attentionScreen.lastElementChild; + var frameOrigin = attentionFrame.dataset.frameOrigin; + if (origin === frameOrigin) { + this.show(); + } + }, + + getAttentionScreenOrigins: function as_getAttentionScreenOrigins() { + var attentionScreen = this.attentionScreen; + var frames = this.attentionScreen.querySelectorAll('iframe'); + var attentiveApps = []; + Array.prototype.forEach.call(frames, function pushFrame(frame) { + attentiveApps.push(frame.dataset.frameOrigin); + }); + return attentiveApps; + }, + + _hasAttentionPermission: function as_hasAttentionPermission(app) { + var mozPerms = navigator.mozPermissionSettings; + if (!mozPerms) + return false; + + var value = mozPerms.get('attention', app.manifestURL, app.origin, false); + + return (value === 'allow'); + }, + + _hasTelephonyPermission: function as_hasAttentionPermission(app) { + var mozPerms = navigator.mozPermissionSettings; + if (!mozPerms) + return false; + + var value = mozPerms.get('telephony', app.manifestURL, app.origin, false); + + return (value === 'allow'); + } +}; + +AttentionScreen.init(); diff --git a/apps/system/js/authentication_dialog.js b/apps/system/js/authentication_dialog.js new file mode 100644 index 0000000..3a893ee --- /dev/null +++ b/apps/system/js/authentication_dialog.js @@ -0,0 +1,178 @@ +/* -*- Mode: js; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +// This module listens to mozbrowserusernameandpasswordrequired event. +// It's for http authentication only. +// XXX: ftp authentication will be implemented here but not supported yet. + +var AuthenticationDialog = { + // Used for element id access. + // e.g., 'authentication-dialog-alert-ok' + prefix: 'authentication-dialog-', + + // DOM + elements: {}, + + // Get all elements when inited. + getAllElements: function ad_getAllElements() { + var elementsID = [ + 'http-authentication', 'http-username-input', 'http-password-input', + 'http-authentication-message', 'http-authentication-ok', + 'http-authentication-cancel', 'title' + ]; + + var toCamelCase = function toCamelCase(str) { + return str.replace(/\-(.)/g, function replacer(str, p1) { + return p1.toUpperCase(); + }); + }; + + elementsID.forEach(function createElementRef(name) { + this.elements[toCamelCase(name)] = + document.getElementById(this.prefix + name); + }, this); + + this.screen = document.getElementById('screen'); + this.overlay = document.getElementById('dialog-overlay'); + }, + + // Save the events returned by + // mozbrowserusernameandpasswordrequired for later use. + // The events are stored according to webapp origin + // e.g., 'http://uitest.gaiamobile.org': evt + currentEvents: {}, + + init: function ad_init() { + // Get all elements initially. + this.getAllElements(); + var elements = this.elements; + + // Bind events + window.addEventListener('mozbrowserusernameandpasswordrequired', this); + window.addEventListener('appopen', this); + window.addEventListener('appwillclose', this); + window.addEventListener('appterminated', this); + window.addEventListener('resize', this); + window.addEventListener('keyboardchange', this); + window.addEventListener('keyboardhide', this); + + for (var id in elements) { + if (elements[id].tagName.toLowerCase() == 'button') { + elements[id].addEventListener('click', this); + } + } + }, + + // Default event handler + handleEvent: function ad_handleEvent(evt) { + var elements = this.elements; + switch (evt.type) { + case 'mozbrowserusernameandpasswordrequired': + if (evt.target.dataset.frameType != 'window') + return; + + evt.preventDefault(); + var origin = evt.target.dataset.frameOrigin; + this.currentEvents[origin] = evt; + + if (origin == WindowManager.getDisplayedApp()) + this.show(origin); + break; + + case 'click': + if (evt.currentTarget === elements.httpAuthenticationCancel) { + this.cancelHandler(); + } else { + this.confirmHandler(); + } + break; + + case 'appopen': + if (this.currentEvents[evt.detail.origin]) + this.show(evt.detail.origin); + break; + + case 'appwillclose': + // Do nothing if the app is closed at background. + if (evt.detail.origin !== this.currentOrigin) + return; + + // Reset currentOrigin + this.hide(); + break; + + case 'appterminated': + if (this.currentEvents[evt.detail.origin]) + delete this.currentEvents[evt.detail.origin]; + + break; + + case 'resize': + case 'keyboardhide': + if (!this.currentOrigin) + return; + + this.setHeight(window.innerHeight - StatusBar.height); + break; + + case 'keyboardchange': + this.setHeight(window.innerHeight - + evt.detail.height - StatusBar.height); + break; + } + }, + + setHeight: function ad_setHeight(height) { + if (this.isVisible()) + this.overlay.style.height = height + 'px'; + }, + + show: function ad_show(origin) { + this.currentOrigin = origin; + var evt = this.currentEvents[origin]; + var elements = this.elements; + this.screen.classList.add('authentication-dialog'); + elements.httpAuthentication.classList.add('visible'); + elements.title.textContent = evt.detail.host; + elements.httpAuthenticationMessage.textContent = evt.detail.realm; + elements.httpUsernameInput.value = ''; + elements.httpPasswordInput.value = ''; + + this.setHeight(window.innerHeight - StatusBar.height); + }, + + hide: function ad_hide() { + this.elements.httpUsernameInput.blur(); + this.elements.httpPasswordInput.blur(); + this.currentOrigin = null; + this.elements.httpAuthentication.classList.remove('visible'); + this.screen.classList.remove('authentication-dialog'); + }, + + confirmHandler: function ad_confirmHandler() { + var elements = this.elements; + var evt = this.currentEvents[this.currentOrigin]; + evt.detail.authenticate(elements.httpUsernameInput.value, + elements.httpPasswordInput.value); + elements.httpAuthentication.classList.remove('visible'); + delete this.currentEvents[this.currentOrigin]; + this.screen.classList.remove('authentication-dialog'); + }, + + cancelHandler: function ad_cancelHandler() { + var evt = this.currentEvents[this.currentOrigin]; + var elements = this.elements; + evt.detail.cancel(); + elements.httpAuthentication.classList.remove('visible'); + delete this.currentEvents[this.currentOrigin]; + this.screen.classList.remove('authentication-dialog'); + }, + + isVisible: function ad_isVisible() { + return this.screen.classList.contains('authentication-dialog'); + } +}; + +AuthenticationDialog.init(); diff --git a/apps/system/js/background_service.js b/apps/system/js/background_service.js new file mode 100644 index 0000000..117b556 --- /dev/null +++ b/apps/system/js/background_service.js @@ -0,0 +1,196 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +/* + Allow web apps to inject a tiny persistent background iframe + as the phone starts. +*/ +var BackgroundServiceManager = (function bsm() { + /* We keep the references to background page iframes here. + The iframes will be append to body */ + var frames = {}; + + /* The name of the background window open by background_page in + manifest. */ + var AUTO_OPEN_BG_PAGE_NAME = 'background'; + + /* Init */ + var init = function bsm_init() { + var applications = Applications.installedApps; + Object.keys(applications).forEach(function bsm_each(manifestURL) { + var app = applications[manifestURL]; + if (!app.manifest.background_page) + return; + + // XXX: this work as if background_page is always a path not a full URL. + var url = app.origin + app.manifest.background_page; + open(manifestURL, AUTO_OPEN_BG_PAGE_NAME, url); + }); + }; + + /* mozbrowseropenwindow */ + window.addEventListener('mozbrowseropenwindow', function bsm_winopen(evt) { + if (evt.detail.features !== 'background') + return; + + // stopPropagation means we are not allowing + // Popup Manager to handle this event + evt.stopPropagation(); + + var manifestURL = evt.target.getAttribute('mozapp'); + var detail = evt.detail; + + open(manifestURL, detail.name, detail.url, detail.frameElement); + }, true); + + /* mozbrowserclose */ + window.addEventListener('mozbrowserclose', function bsm_winclose(evt) { + if (!'frameType' in evt.target.dataset || + evt.target.dataset.frameType !== 'background') + return; + + var manifestURL = evt.target.getAttribute('mozapp'); + + close(manifestURL, evt.target.dataset.frameName); + }, true); + + /* mozbrowsererror */ + window.addEventListener('mozbrowsererror', function bsm_winclose(evt) { + if (!'frameType' in evt.target.dataset || + evt.target.dataset.frameType !== 'background' || + evt.detail.type !== 'fatal') + return; + + var target = evt.target; + var manifestURL = target.getAttribute('mozapp'); + + // This bg service has just crashed, clean up the frame + var name = target.dataset.frameName; + close(manifestURL, name); + }, true); + + /* OnInstall */ + window.addEventListener('applicationinstall', function bsm_oninstall(evt) { + var app = evt.detail.application; + var origin = app.origin; + if (!app.manifest.background_page) + return; + + // XXX: this work as if background_page is always a path not a full URL. + var url = origin + app.manifest.background_page; + open(manifestURL, AUTO_OPEN_BG_PAGE_NAME, url); + }); + + /* OnUninstall */ + window.addEventListener('applicationuninstall', function bsm_oninstall(evt) { + var app = evt.detail.application; + close(app.manifestURL); + }); + + /* Check if the app has the permission to open a background page */ + var hasBackgroundPermission = function bsm_checkPermssion(app) { + var mozPerms = navigator.mozPermissionSettings; + if (!mozPerms) + return false; + + var value = mozPerms.get('backgroundservice', app.manifestURL, + app.origin, false); + + return (value === 'allow'); + }; + + /* The open function is responsible of containing the iframe */ + var open = function bsm_open(manifestURL, name, url, frame) { + var app = Applications.getByManifestURL(manifestURL); + if (!app || !hasBackgroundPermission(app)) + return false; + + if (frames[manifestURL] && frames[manifestURL][name]) { + console.error('Window with the same name is there but Gecko ' + + ' failed to use it. See bug 766873. origin: "' + origin + + '", name: "' + name + '".'); + return false; + } + + if (!frame) { + frame = document.createElement('iframe'); + + // If we have a frame element, it's provided by mozbrowseropenwindow, and + // it has the mozbrowser, mozapp, and src attributes set already. + frame.setAttribute('mozbrowser', 'mozbrowser'); + frame.setAttribute('mozapp', manifestURL); + frame.setAttribute('name', name); + + var appName = app.manifest.name; + frame.setAttribute('remote', 'true'); + console.info('%%%%% Launching', appName, 'bg service as remote (OOP)'); + frame.src = url; + } + frame.className = 'backgroundWindow'; + frame.dataset.frameType = 'background'; + frame.dataset.frameName = name; + + if (!frames[manifestURL]) + frames[manifestURL] = {}; + frames[manifestURL][name] = frame; + + document.body.appendChild(frame); + + // Background services should load in the background. + // + // (The funky setTimeout(0) is to work around + // https://bugzilla.mozilla.org/show_bug.cgi?id=810431 .) + setTimeout(function() { frame.setVisible(false) }, 0); + + return true; + }; + + /* The close function will remove the iframe from DOM and + delete the reference */ + var close = function bsm_close(manifestURL, name) { + if (!frames[manifestURL]) + return false; + + if (typeof name == 'undefined') { + // Close all windows + Object.keys(frames[manifestURL]).forEach(function closeEach(name) { + document.body.removeChild(frames[manifestURL][name]); + frames[manifestURL][name] = null; + }); + delete frames[manifestURL]; + return true; + } + + // Close one window + var frame = frames[manifestURL][name]; + if (!frame) + return false; + + document.body.removeChild(frame); + delete frames[manifestURL][name]; + + if (!Object.keys(frames[manifestURL]).length) + delete frames[manifestURL]; + return true; + }; + + /* start initialization */ + if (Applications.ready) { + init(); + } else { + window.addEventListener('applicationready', + function bsm_appListReady(event) { + window.removeEventListener('applicationready', bsm_appListReady); + init(); + }); + } + + /* Return the public APIs */ + return { + 'open': open, + 'close': close + }; +}()); + diff --git a/apps/system/js/battery_manager.js b/apps/system/js/battery_manager.js new file mode 100644 index 0000000..d2eacf8 --- /dev/null +++ b/apps/system/js/battery_manager.js @@ -0,0 +1,277 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +var BatteryManager = { + TOASTER_TIMEOUT: 5000, + TRANSITION_SPEED: 1.8, + TRANSITION_FRACTION: 0.30, + + AUTO_SHUTDOWN_LEVEL: 0.02, + EMPTY_BATTERY_LEVEL: 0.1, + + _battery: window.navigator.battery, + _notification: null, + + getAllElements: function bm_getAllElements() { + this.screen = document.getElementById('screen'); + this.overlay = document.getElementById('system-overlay'); + this.notification = document.getElementById('battery'); + }, + + checkBatteryDrainage: function bm_checkBatteryDrainage() { + var battery = this._battery; + if (!battery) + return; + + if (battery.level <= this.AUTO_SHUTDOWN_LEVEL) + SleepMenu.startPowerOff(false); + }, + + init: function bm_init() { + this.getAllElements(); + var battery = this._battery; + if (battery) { + // When the device is booted, check if the battery is drained. + // If so, SleepMenu.startPowerOff() would be called. + this.checkBatteryDrainage(); + + battery.addEventListener('levelchange', this); + battery.addEventListener('chargingchange', this); + } + window.addEventListener('screenchange', this); + this._toasterGD = new GestureDetector(this.notification); + ['mousedown', 'swipe'].forEach(function(evt) { + this.notification.addEventListener(evt, this); + }, this); + + this._screenOn = true; + this._wasEmptyBatteryNotificationDisplayed = false; + + this.displayIfNecessary(); + }, + + handleEvent: function bm_handleEvent(evt) { + switch (evt.type) { + case 'screenchange': + this._screenOn = evt.detail.screenEnabled; + this.displayIfNecessary(); + break; + + case 'levelchange': + var battery = this._battery; + if (!battery) + return; + + this.checkBatteryDrainage(); + this.displayIfNecessary(); + + PowerSaveHandler.onBatteryChange(); + break; + case 'chargingchange': + PowerSaveHandler.onBatteryChange(); + + var battery = this._battery; + // We turn the screen on if needed in order to let + // the user knows the device is charging + + if (battery && battery.charging) { + this.hide(); + this._wasEmptyBatteryNotificationDisplayed = false; + + if (!this._screenOn) { + ScreenManager.turnScreenOn(); + } + } else { + this.displayIfNecessary(); + } + break; + + case 'mousedown': + this.mousedown(evt); + break; + case 'swipe': + this.swipe(evt); + break; + } + }, + + _shouldWeDisplay: function bm_shouldWeDisplay() { + var battery = this._battery; + if (!battery) { + return false; + } + + return (!this._wasEmptyBatteryNotificationDisplayed && + !battery.charging && + battery.level <= this.EMPTY_BATTERY_LEVEL && + this._screenOn); + }, + + displayIfNecessary: function bm_display() { + if (! this._shouldWeDisplay()) { + return; + } + + // we know it's here, it's checked in shouldWeDisplay() + var level = this._battery.level; + + this.overlay.classList.add('battery'); + + this._toasterGD.startDetecting(); + this._wasEmptyBatteryNotificationDisplayed = true; + + if (this._toasterTimeout) { + clearTimeout(this._toasterTimeout); + } + + this._toasterTimeout = setTimeout(this.hide.bind(this), + this.TOASTER_TIMEOUT); + }, + + hide: function bm_hide() { + var overlayCss = this.overlay.classList; + if (overlayCss.contains('battery')) { + this.overlay.classList.remove('battery'); + this._toasterTimeout = null; + this._toasterGD.stopDetecting(); + } + }, + + // Swipe handling + mousedown: function bm_mousedown(evt) { + evt.preventDefault(); + this._containerWidth = this.overlay.clientWidth; + }, + + swipe: function bm_swipe(evt) { + var detail = evt.detail; + var distance = detail.start.screenX - detail.end.screenX; + var fastEnough = Math.abs(detail.vx) > this.TRANSITION_SPEED; + var farEnough = Math.abs(distance) > + this._containerWidth * this.TRANSITION_FRACTION; + + // If the swipe distance is too short or swipe speed is too slow, + // do nothing. + if (!(farEnough || fastEnough)) + return; + + var self = this; + this.notification.addEventListener('animationend', function animationend() { + self.notification.removeEventListener('animationend', animationend); + self.notification.classList.remove('disappearing'); + self.hide(); + }); + this.notification.classList.add('disappearing'); + } +}; + +var PowerSaveHandler = (function PowerSaveHandler() { + + var _powerSaveResume = {}; + var _powerSaveEnabled = false; + var _states = { + 'wifi.enabled' : false, + 'ril.data.enabled' : false, + 'bluetooth.enabled' : false, + 'geolocation.enabled' : false + }; + + function init() { + SettingsListener.observe('powersave.enabled', false, + function sl_getPowerSave(value) { + var enabled = value; + if (enabled) { + enablePowerSave(); + } else { + disablePowerSave(); + } + _powerSaveEnabled = enabled; + }); + + // Monitor the states of various modules + for (var j in _states) { + SettingsListener.observe(j, true, function getState(state, value) { + _states[state] = value; + }.bind(null, j)); + } + } + + // XXX Break down obj keys in a for each loop because mozSettings + // does not currently supports multiple keys in one set() + // https://bugzilla.mozilla.org/show_bug.cgi?id=779381 + function setMozSettings(keypairs) { + var setlock = SettingsListener.getSettingsLock(); + for (var key in keypairs) { + var obj = {}; + obj[key] = keypairs[key]; + setlock.set(obj); + } + } + + function enablePowerSave() { + // Keep the original states of various modules + for (var j in _states) { + _powerSaveResume[j] = _states[j]; + } + + var settingsToSet = { + // Turn off Wifi + 'wifi.enabled' : false, + // Turn off Data + 'ril.data.enabled' : false, + // Turn off Bluetooth + 'bluetooth.enabled' : false, + // Turn off Geolocation + 'geolocation.enabled' : false + }; + + setMozSettings(settingsToSet); + } + + function disablePowerSave() { + + var settingsToSet = {}; + + for (var state in _powerSaveResume) { + if (_powerSaveResume[state] == true) + settingsToSet[state] = true; + } + + setMozSettings(settingsToSet); + } + + function onBatteryChange() { + var battery = BatteryManager._battery; + + if (battery.charging) { + if (_powerSaveEnabled) + setMozSettings({'powersave.enabled' : false}); + + return; + } + + SettingsListener.observe('powersave.threshold', 0, + function getThreshold(value) { + if (battery.level <= value && !_powerSaveEnabled) { + setMozSettings({'powersave.enabled' : true}); + return; + } + + if (value != 0 && battery.level > value && _powerSaveEnabled) { + setMozSettings({'powersave.enabled' : false}); + return; + } + }); + } + + return { + init: init, + onBatteryChange: onBatteryChange + }; +})(); + +// init PowerSaveHandler first, since it will be used by BatteryManager +PowerSaveHandler.init(); +BatteryManager.init(); diff --git a/apps/system/js/bluetooth.js b/apps/system/js/bluetooth.js new file mode 100644 index 0000000..3a77cc7 --- /dev/null +++ b/apps/system/js/bluetooth.js @@ -0,0 +1,136 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +var Bluetooth = { + + /* this property store a reference of the default adapter */ + defaultAdapter: null, + + /* keep a global connected property here */ + connected: false, + + init: function bt_init() { + if (!window.navigator.mozSettings) + return; + + var bluetooth = window.navigator.mozBluetooth; + + SettingsListener.observe('bluetooth.enabled', true, function(value) { + if (!bluetooth) { + // roll back the setting value to notify the UIs + // that Bluetooth interface is not available + if (value) { + SettingsListener.getSettingsLock().set({ + 'bluetooth.enabled': false + }); + } + return; + } + }); + + var self = this; + // when bluetooth adapter is ready, emit event to notify QuickSettings + // and try to get defaultAdapter at this moment + bluetooth.onadapteradded = function bt_onAdapterAdded() { + var evt = document.createEvent('CustomEvent'); + evt.initCustomEvent('bluetooth-adapter-added', + /* canBubble */ true, /* cancelable */ false, null); + window.dispatchEvent(evt); + self.initDefaultAdapter(); + }; + // if bluetooth is enabled in booting time, try to get adapter now + this.initDefaultAdapter(); + + // when bluetooth is really disabled, emit event to notify QuickSettings + bluetooth.ondisabled = function bt_onDisabled() { + var evt = document.createEvent('CustomEvent'); + evt.initCustomEvent('bluetooth-disabled', + /* canBubble */ true, /* cancelable */ false, null); + window.dispatchEvent(evt); + }; + + /* for v1, we only support two use cases for bluetooth connection: + * 1. connecting with a headset + * 2. transfering a file to/from another device + * So we need to monitor their event messages to know we are (aren't) + * connected, then summarize to an event and dispatch to StatusBar + */ + + // In headset connected case: + navigator.mozSetMessageHandler('bluetooth-hfp-status-changed', + this.updateConnected.bind(this) + ); + + /* In file transfering case: + * since System Message can't be listened in two js files within a app, + * so we listen here but dispatch events to bluetooth_transfer.js + * when getting bluetooth file transfer start/complete system messages + */ + var self = this; + navigator.mozSetMessageHandler('bluetooth-opp-transfer-start', + function bt_fileTransferUpdate(transferInfo) { + self.updateConnected(); + var evt = document.createEvent('CustomEvent'); + evt.initCustomEvent('bluetooth-opp-transfer-start', + /* canBubble */ true, /* cancelable */ false, + {transferInfo: transferInfo}); + window.dispatchEvent(evt); + } + ); + + navigator.mozSetMessageHandler('bluetooth-opp-transfer-complete', + function bt_fileTransferUpdate(transferInfo) { + self.updateConnected(); + var evt = document.createEvent('CustomEvent'); + evt.initCustomEvent('bluetooth-opp-transfer-complete', + /* canBubble */ true, /* cancelable */ false, + {transferInfo: transferInfo}); + window.dispatchEvent(evt); + } + ); + + }, + + // Get adapter for BluetoothTransfer when everytime bluetooth is enabled + initDefaultAdapter: function bt_initDefaultAdapter() { + var bluetooth = window.navigator.mozBluetooth; + var self = this; + + if (!bluetooth || !bluetooth.enabled || + !('getDefaultAdapter' in bluetooth)) + return; + + var req = bluetooth.getDefaultAdapter(); + req.onsuccess = function bt_gotDefaultAdapter(evt) { + self.defaultAdapter = req.result; + }; + }, + + updateConnected: function bt_updateConnected() { + var bluetooth = window.navigator.mozBluetooth; + + if (!bluetooth || !('isConnected' in bluetooth)) + return; + + var wasConnected = this.connected; + this.connected = + bluetooth.isConnected(0x111E) || bluetooth.isConnected(0x1105); + + if (wasConnected !== this.connected) { + var evt = document.createEvent('CustomEvent'); + evt.initCustomEvent('bluetoothconnectionchange', + /* canBubble */ true, /* cancelable */ false, + {deviceConnected: this.connected}); + window.dispatchEvent(evt); + } + }, + + // This function is called by external (BluetoothTransfer) for re-use adapter + getAdapter: function bt_getAdapter() { + return this.defaultAdapter; + } +}; + +Bluetooth.init(); diff --git a/apps/system/js/bluetooth_transfer.js b/apps/system/js/bluetooth_transfer.js new file mode 100644 index 0000000..47483f7 --- /dev/null +++ b/apps/system/js/bluetooth_transfer.js @@ -0,0 +1,511 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ +'use strict'; + +var BluetoothTransfer = { + bannerContainer: null, + pairList: { + index: [] + }, + _deviceStorage: navigator.getDeviceStorage('sdcard'), + _debug: false, + + get transferStatusList() { + delete this.transferStatusList; + return this.transferStatusList = + document.getElementById('bluetooth-transfer-status-list'); + }, + + get banner() { + delete this.banner; + return this.banner = document.getElementById('system-banner'); + }, + + init: function bt_init() { + // 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) + ); + this.bannerContainer = this.banner.firstElementChild; + }, + + getDeviceName: function bt_getDeviceName(address) { + var _ = navigator.mozL10n.get; + var length = this.pairList.index.length; + for (var i = 0; i < length; i++) { + if (this.pairList.index[i].address == address) + return this.pairList.index[i].name; + } + return _('unknown-device'); + }, + + getPairedDevice: function bt_getPairedDevice(callback) { + var adapter = Bluetooth.getAdapter(); + if (adapter == null) { + var msg = 'Cannot get Bluetooth adapter.'; + this.debug(msg); + return; + } + var self = this; + var req = adapter.getPairedDevices(); + req.onsuccess = function bt_getPairedSuccess() { + self.pairList.index = req.result; + var length = self.pairList.index.length; + if (length == 0) { + var msg = + 'There is no paired device! Please pair your bluetooth device first.'; + self.debug(msg); + return; + } + if (callback) { + callback(); + } + }; + req.onerror = function() { + var msg = 'Can not get paired devices from adapter.'; + self.debug(msg); + }; + }, + + debug: function bt_debug(msg) { + if (!this._debug) + 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]) + }); + }, + + onReceivingFileConfirmation: function bt_onReceivingFileConfirmation(evt) { + // Prompt appears when a transfer request from a paired device is received. + var _ = navigator.mozL10n.get; + + var fileSize = evt.fileLength; + var self = this; + var icon = 'style/bluetooth_transfer/images/icon_bluetooth.png'; + + // Check storage is available or not before the prompt. + this.checkStorageSpace(fileSize, + function checkStorageSpaceComplete(isStorageAvailable, errorMessage) { + if (isStorageAvailable) { + NotificationHelper.send(_('notification-fileTransfer-title'), + _('notification-fileTransfer-description'), + icon, + function() { + UtilityTray.hide(); + self.showReceivePrompt(evt); + }); + } else { + self.showStorageUnavaliablePrompt(errorMessage); + } + }); + }, + + showReceivePrompt: function bt_showReceivePrompt(evt) { + var _ = navigator.mozL10n.get; + + 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, address) + }; + + var deviceName = ''; + var self = this; + this.getPairedDevice(function getPairedDeviceComplete() { + deviceName = self.getDeviceName(address); + CustomDialog.show(_('acceptFileTransfer'), + _('wantToReceiveFile', + { deviceName: deviceName, + fileName: fileName, + fileSize: fileSize }), + cancel, confirm); + }); + }, + + declineReceive: function bt_declineReceive(address) { + CustomDialog.hide(); + var adapter = 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(address, fileSize) { + CustomDialog.hide(); + var adapter = Bluetooth.getAdapter(); + if (adapter != null) { + adapter.confirmReceivingFile(address, true); + } else { + var msg = 'Cannot get adapter from system Bluetooth monitor.'; + this.debug(msg); + } + }, + + showStorageUnavaliablePrompt: function bt_showStorageUnavaliablePrompt(msg) { + var _ = navigator.mozL10n.get; + var confirm = { + title: _('confirm'), + callback: function() { + CustomDialog.hide(); + } + }; + + var body = msg; + CustomDialog.show(_('cannotReceiveFile'), body, confirm); + }, + + checkStorageSpace: function bt_checkStorageSpace(fileSize, callback) { + if (!callback) + return; + + var _ = navigator.mozL10n.get; + 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-exist')); + 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')); + }; + }, + + onUpdateProgress: function bt_onUpdateProgress(mode, evt) { + switch (mode) { + case 'start': + var transferInfo = evt.detail.transferInfo; + this.initProgress(transferInfo); + break; + + case 'progress': + var address = evt.address; + 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) { + var _ = navigator.mozL10n.get; + // Create progress dynamically in notification center + var address = evt.address; + var transferMode = + (evt.received == true) ? + _('bluetooth-receiving-progress') : _('bluetooth-sending-progress'); + var content = + '<img src="style/bluetooth_transfer/images/transfer.png" />' + + '<div class="bluetooth-transfer-progress">' + transferMode + '</div>' + + // XXX: Bug 804533 - [Bluetooth] + // Need sending/receiving icon for Bluetooth file transfer + '<progress value="0" max="1"></progress>'; + + var transferTask = document.createElement('div'); + transferTask.id = 'bluetooth-transfer-status'; + transferTask.className = 'notification'; + transferTask.setAttribute('data-id', address); + transferTask.innerHTML = content; + transferTask.addEventListener('click', + this.onCancelTransferTask.bind(this)); + 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); + finishedTask.removeEventListener('click', + this.onCancelTransferTask.bind(this)); + this.transferStatusList.removeChild(finishedTask); + }, + + showBanner: function bt_showBanner(isComplete) { + var _ = navigator.mozL10n.get; + var status = (isComplete) ? 'complete' : 'failed'; + this.banner.addEventListener('animationend', function animationend() { + this.banner.removeEventListener('animationend', animationend); + this.banner.classList.remove('visible'); + }.bind(this)); + this.bannerContainer.textContent = _('bluetooth-file-transfer-result', + { status: status }); + this.banner.classList.add('visible'); + }, + + onCancelTransferTask: function bt_onCancelTransferTask(evt) { + var id = evt.target.dataset.id; + // Show confirm dialog for user to cancel transferring task + UtilityTray.hide(); + this.showCancelTransferPrompt(id); + }, + + showCancelTransferPrompt: function bt_showCancelTransferPrompt(address) { + var _ = navigator.mozL10n.get; + + var cancel = { + title: _('continue'), + callback: this.continueTransfer.bind(this) + }; + + var confirm = { + title: _('cancel'), + callback: this.cancelTransfer.bind(this, address) + }; + + CustomDialog.show(_('cancelFileTransfer'), _('cancelFileTransfer'), + cancel, confirm); + }, + + continueTransfer: function bt_continueTransfer() { + CustomDialog.hide(); + }, + + cancelTransfer: function bt_cancelTransfer(address) { + CustomDialog.hide(); + var adapter = Bluetooth.getAdapter(); + if (adapter != null) { + adapter.stopSendingFile(address); + } else { + var msg = 'Cannot get adapter from system Bluetooth monitor.'; + this.debug(msg); + } + }, + + onTransferComplete: function bt_onTransferComplete(evt) { + var transferInfo = evt.detail.transferInfo; + var _ = navigator.mozL10n.get; + // Remove transferring progress + this.removeProgress(transferInfo); + var fileName = + (transferInfo.fileName) ? transferInfo.fileName : _('unknown-file'); + var icon = 'style/bluetooth_transfer/images/icon_bluetooth.png'; + // Show banner and notification + if (transferInfo.success == true) { + // Show completed message of transferred result on the banner + this.showBanner(true); + if (transferInfo.received) { + // Received file can be opened only + // TODO: Need to modify the icon after visual provide + NotificationHelper.send(_('transferFinished-receivedSuccessful-title'), + fileName, + icon, + this.openReceivedFile.bind(this, transferInfo)); + } else { + NotificationHelper.send(_('transferFinished-sentSuccessful-title'), + fileName, + icon); + } + } else { + // Show failed message of transferred result on the banner + this.showBanner(false); + if (transferInfo.received) { + NotificationHelper.send(_('transferFinished-receivedFailed-title'), + fileName, + icon); + } else { + NotificationHelper.send(_('transferFinished-sentFailed-title'), + fileName, + icon); + } + } + }, + + openReceivedFile: function bt_openReceivedFile(evt) { + // Launch the gallery with an open activity to view this specific photo + // XXX: The prefix file path should be refined when API is ready to provide + var filePath = 'downloads/bluetooth/' + evt.fileName; + var contentType = evt.contentType; + var storageType = 'sdcard'; + var self = this; + var storage = navigator.getDeviceStorage(storageType); + var getreq = storage.get(filePath); + + getreq.onerror = function() { + var msg = 'failed to get file:' + + filePath + getreq.error.name + + a.error.name; + self.debug(msg); + }; + + getreq.onsuccess = function() { + 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 fileType = ''; + var fileName = file.name; + if (contentType != '' && contentType != 'image/*') { + fileType = contentType; + } else { + var fileNameExtension = + fileName.substring(fileName.lastIndexOf('.') + 1); + if (file.type != '') { + fileType = file.type; + // Refine the file type to "audio/ogg" when the file format is *.ogg + if (fileType == 'video/ogg' && + (fileNameExtension.indexOf('ogg') != -1)) { + fileType == 'audio/ogg'; + } + } else { + // Parse Filename Extension to find out MIMETYPE + // Following formats are supported by Gallery and Music APPs + var imageFormatList = ['jpg', 'jpeg', 'png']; + var audioFormatList = ['mp3', 'ogg', 'aac', 'mp4', 'm4a']; + var imageFormatIndex = imageFormatList.indexOf(fileNameExtension); + switch (imageFormatIndex) { + case 0: + case 1: + // The file type of format *.jpg, *.jpeg should be "image/jpeg" + fileType = 'image/jpeg'; + break; + case 2: + // The file type of format *.png should be "image/png" + fileType = 'image/png'; + break; + } + + var audioFormatIndex = audioFormatList.indexOf(fileNameExtension); + switch (audioFormatIndex) { + case 0: + // The file type of format *.mp3 should be "audio/mpeg" + fileType = 'audio/mpeg'; + break; + case 1: + // The file type of format *.ogg should be "audio/ogg" + fileType = 'audio/ogg'; + break; + case 2: + case 3: + case 4: + // The file type of format *.acc, *.mp4, *.m4a + // should be "audio/mp4" + fileType = 'audio/mp4'; + break; + } + } + } + + var a = new MozActivity({ + name: 'open', + data: { + type: fileType, + 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: file.name + } + }); + + a.onerror = function(e) { + var msg = 'open activity error:' + a.error.name; + self.debug(msg); + // Cannot identify MIMETYPE + // So, show cannot open file dialog with unknow media type + UtilityTray.hide(); + self.showUnknownMediaPrompt(fileName); + }; + a.onsuccess = function(e) { + var msg = 'open activity onsuccess'; + self.debug(msg); + }; + }; + }, + + showUnknownMediaPrompt: function bt_showUnknownMediaPrompt(fileName) { + var _ = navigator.mozL10n.get; + var confirm = { + title: _('confirm'), + callback: function() { + CustomDialog.hide(); + } + }; + + var body = _('unknownMediaTypeToOpen') + ' ' + fileName; + CustomDialog.show(_('cannotOpenFile'), body, confirm); + } +}; + +BluetoothTransfer.init(); diff --git a/apps/system/js/bootstrap.js b/apps/system/js/bootstrap.js new file mode 100644 index 0000000..21e2238 --- /dev/null +++ b/apps/system/js/bootstrap.js @@ -0,0 +1,82 @@ +/* -*- Mode: js; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +window.addEventListener('load', function startup() { + function safelyLaunchFTU() { + WindowManager.retrieveHomescreen(WindowManager.retrieveFTU); + } + + if (Applications.ready) { + safelyLaunchFTU(); + } else { + window.addEventListener('applicationready', function appListReady(event) { + window.removeEventListener('applicationready', appListReady); + safelyLaunchFTU(); + }); + } + + window.addEventListener('ftudone', function doneWithFTU() { + window.removeEventListener('ftudone', doneWithFTU); + + var lock = window.navigator.mozSettings.createLock(); + lock.set({ + 'gaia.system.checkForUpdates': true + }); + }); + + SourceView.init(); + Shortcuts.init(); + ScreenManager.turnScreenOn(); + + // We need to be sure to get the focus in order to wake up the screen + // if the phone goes to sleep before any user interaction. + // Apparently it works because no other window has the focus at this point. + window.focus(); + + // This is code copied from + // http://dl.dropbox.com/u/8727858/physical-events/index.html + // It appears to workaround the Nexus S bug where we're not + // getting orientation data. See: + // https://bugzilla.mozilla.org/show_bug.cgi?id=753245 + // It seems it needs to be in both window_manager.js and bootstrap.js. + function dumbListener2(event) {} + window.addEventListener('devicemotion', dumbListener2); + + window.setTimeout(function() { + window.removeEventListener('devicemotion', dumbListener2); + }, 2000); +}); + +/* === Shortcuts === */ +/* For hardware key handling that doesn't belong to anywhere */ +var Shortcuts = { + init: function rm_init() { + window.addEventListener('keyup', this); + }, + + handleEvent: function rm_handleEvent(evt) { + if (!ScreenManager.screenEnabled || evt.keyCode !== evt.DOM_VK_F6) + return; + + document.location.reload(); + } +}; + +/* === Localization === */ +/* set the 'lang' and 'dir' attributes to <html> when the page is translated */ +window.addEventListener('localized', function onlocalized() { + document.documentElement.lang = navigator.mozL10n.language.code; + document.documentElement.dir = navigator.mozL10n.language.direction; +}); + +// Define the default background to use for all homescreens +SettingsListener.observe( + 'wallpaper.image', + 'resources/images/backgrounds/default.png', + function setWallpaper(value) { + document.getElementById('screen').style.backgroundImage = + 'url(' + value + ')'; + } +); diff --git a/apps/system/js/call_forwarding.js b/apps/system/js/call_forwarding.js new file mode 100644 index 0000000..ee46def --- /dev/null +++ b/apps/system/js/call_forwarding.js @@ -0,0 +1,45 @@ +/* -*- Mode: js; js-indent-level: 2; indent-tabs-mode: nil -*- */ +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +(function() { + + // Must be in sync with nsIDOMMozMobileCFInfo interface. + var _cfReason = { + CALL_FORWARD_REASON_UNCONDITIONAL: 0, + CALL_FORWARD_REASON_MOBILE_BUSY: 1, + CALL_FORWARD_REASON_NO_REPLY: 2, + CALL_FORWARD_REASON_NOT_REACHABLE: 3 + }; + var _cfAction = { + CALL_FORWARD_ACTION_DISABLE: 0, + CALL_FORWARD_ACTION_ENABLE: 1, + CALL_FORWARD_ACTION_QUERY_STATUS: 2, + CALL_FORWARD_ACTION_REGISTRATION: 3, + CALL_FORWARD_ACTION_ERASURE: 4 + }; + + var settings = window.navigator.mozSettings; + if (!settings) { + return; + } + var mobileconnection = window.navigator.mozMobileConnection; + if (!mobileconnection) { + return; + } + + mobileconnection.addEventListener('cfstatechange', function(event) { + if (event && + event.reason == _cfReason.CALL_FORWARD_REASON_UNCONDITIONAL) { + var enabled = false; + if (event.success && + (event.action == _cfAction.CALL_FORWARD_ACTION_REGISTRATION || + event.action == _cfAction.CALL_FORWARD_ACTION_ENABLE)) { + enabled = true; + } + settings.createLock().set({'ril.cf.enabled': enabled}); + } + }); + +})(); diff --git a/apps/system/js/captive_portal.js b/apps/system/js/captive_portal.js new file mode 100644 index 0000000..b23cb0d --- /dev/null +++ b/apps/system/js/captive_portal.js @@ -0,0 +1,73 @@ +/* -*Mode: js; js-indent-level: 2; indent-tabs-mode: nil -**/ +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ +'use strict'; + +var CaptivePortalLogin = (function() { + var eventId; + var isManualConnect = false; + var settings = window.navigator.mozSettings; + var notification = null; + var wifiManager = window.navigator.mozWifiManager; + var _ = window.navigator.mozL10n.get; + var captiveNotification_onTap = null; + + function handleLogin(id, url) { + //captive portal login needed + eventId = id; + var currentNetwork = wifiManager.connection.network; + var networkName = (currentNetwork && currentNetwork.ssid) ? + currentNetwork.ssid : ''; + var message = _('captive-wifi-available', { networkName: networkName}); + if (!isManualConnect) { + notification = NotificationScreen.addNotification({ + id: id, title: '', text: message, icon: null + }); + captiveNotification_onTap = function() { + notification.removeEventListener('tap', captiveNotification_onTap); + captiveNotification_onTap = null; + NotificationScreen.removeNotification(id); + new MozActivity({ + name: 'view', + data: { type: 'url', url: url } + }); + }; + notification.addEventListener('tap', captiveNotification_onTap); + } else { + settings.createLock().set({'wifi.connect_via_settings': false}); + new MozActivity({ + name: 'view', + data: { type: 'url', url: url } + }); + } + } + + function handleLoginAbort(id) { + if (id === eventId && notification) { + if (notification.parentNode) { + if (captiveNotification_onTap) { + notification.removeEventListener('tap', captiveNotification_onTap); + captiveNotification_onTap = null; + } + NotificationScreen.removeNotification(id); + notification = null; + } + } + } + + window.addEventListener('mozChromeEvent', function handleChromeEvent(e) { + switch (e.detail.type) { + case 'captive-portal-login': + handleLogin(e.detail.id, e.detail.url); + break; + case 'captive-portal-login-abort': + handleLoginAbort(e.detail.id); + break; + } + }); + + // Using settings API to know whether user is manually selecting + // wifi AP from settings app. + SettingsListener.observe('wifi.connect_via_settings', true, function(value) { + isManualConnect = value; + }); +})(); diff --git a/apps/system/js/cards_view.js b/apps/system/js/cards_view.js new file mode 100644 index 0000000..8bce02b --- /dev/null +++ b/apps/system/js/cards_view.js @@ -0,0 +1,676 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +// +// CardsView is responsible for managing opened apps +// + +'use strict'; + +var CardsView = (function() { + + //display icon of an app on top of app's card + var DISPLAY_APP_ICON = false; + var USER_DEFINED_ORDERING = false; + // If 'true', scrolling moves the list one card + // at time, and snaps the list so the current card + // is centered in the view + // If 'false', use free, physics-based scrolling + // (Gaia default) + var SNAPPING_SCROLLING = true; + // if 'true' user can close the app + // by dragging it upwards + var MANUAL_CLOSING = true; + + var cardsView = document.getElementById('cards-view'); + var screenElement = document.getElementById('screen'); + var cardsList = cardsView.firstElementChild; + var displayedApp; + var runningApps; + // Unkillable apps which have attention screen now + var attentionScreenApps = []; + // Card which we are re-ordering now + var reorderedCard = null; + var currentDisplayed = 0; + // Timer between scrolling CardList further, + // when reordering Cards + var scrollWhileSortingTimer; + // We don't allow user to scroll CardList + // before the timer ticks while in reordering + // mode + var allowScrollingWhileSorting = false; + // Initial margin of the reordered card + var dragMargin = 0; + // Are we reordering or removing the card now? + var draggingCardUp = false; + // Are we moving card left or right? + var sortingDirection; + // List of sorted apps + var userSortedApps = []; + var HVGA = document.documentElement.clientWidth < 480; + var cardsViewShown = false; + + // init events + var gd = new GestureDetector(cardsView); + gd.startDetecting(); + + // A list of all the URLs we've created via URL.createObjectURL which we + // haven't yet revoked. + var screenshotObjectURLs = []; + + /* + * Returns an icon URI + * + * @param{String} the app's origin + */ + function getIconURI(origin) { + var icons = runningApps[origin].manifest.icons; + if (!icons) { + return null; + } + + var sizes = Object.keys(icons).map(function parse(str) { + return parseInt(str, 10); + }); + + sizes.sort(function(x, y) { return y - x; }); + + var index = sizes[(HVGA) ? sizes.length - 1 : 0]; + var iconPath = icons[index]; + + if (iconPath.indexOf('data:') !== 0) { + iconPath = origin + iconPath; + } + + return iconPath; + } + + // Build and display the card switcher overlay + // Note that we rebuild the switcher each time we need it rather + // than trying to keep it in sync with app launches. Performance is + // not an issue here given that the user has to hold the HOME button down + // for one second before the switcher will appear. + function showCardSwitcher() { + if (cardSwitcherIsShown()) + return; + + // events to handle + window.addEventListener('lock', CardsView); + + // Close utility tray if it is opened. + UtilityTray.hide(true); + + // Apps info from WindowManager + displayedApp = WindowManager.getDisplayedApp(); + currentDisplayed = 0; + runningApps = WindowManager.getRunningApps(); + + // Switch to homescreen + WindowManager.launch(null); + cardsViewShown = true; + + // If user is not able to sort apps manualy, + // display most recetly active apps on the far left + if (!USER_DEFINED_ORDERING) { + var sortable = []; + for (var origin in runningApps) + sortable.push({origin: origin, app: runningApps[origin]}); + + sortable.sort(function(a, b) { + return b.app.launchTime - a.app.launchTime; + }); + runningApps = {}; + + // I assume that object properties are enumerated in + // the same order they were defined. + // There is nothing about that in spec, but I've never + // seen any unexpected behavior. + sortable.forEach(function(element) { + runningApps[element.origin] = element.app; + }); + + // First add an item to the cardsList for each running app + for (var origin in runningApps) { + addCard(origin, runningApps[origin], function showCards() { + screenElement.classList.add('cards-view'); + cardsView.classList.add('active'); + }); + } + + } else { // user ordering + + // first run + if (userSortedApps.length === 0) { + for (var origin in runningApps) { + userSortedApps.push(origin); + } + } else { + for (var origin in runningApps) { + // if we have some new app opened + if (userSortedApps.indexOf(origin) === -1) { + userSortedApps.push(origin); + } + } + } + + userSortedApps.forEach(function(origin) { + addCard(origin, runningApps[origin], function showCards() { + screenElement.classList.add('cards-view'); + cardsView.classList.add('active'); + }); + }); + + cardsView.addEventListener('contextmenu', CardsView); + + } + + if (SNAPPING_SCROLLING) { + cardsView.style.overflow = 'hidden'; //disabling native scrolling + } + + if (SNAPPING_SCROLLING || MANUAL_CLOSING) { + cardsView.addEventListener('mousedown', CardsView); + } + + // Make sure we're in portrait mode + screen.mozLockOrientation('portrait-primary'); + + // If there is a displayed app, take keyboard focus away + if (displayedApp) + runningApps[displayedApp].frame.blur(); + + function addCard(origin, app, displayedAppCallback) { + // Display card switcher background first to make user focus on the + // frame closing animation without disturbing by homescreen display. + if (displayedApp == origin && displayedAppCallback) { + setTimeout(displayedAppCallback); + } + // Not showing homescreen + if (app.frame.classList.contains('homescreen')) { + return; + } + + // Build a card representation of each window. + // And add it to the card switcher + var card = document.createElement('li'); + card.classList.add('card'); + card.dataset.origin = origin; + + //display app icon on the tab + if (DISPLAY_APP_ICON) { + var iconURI = getIconURI(origin); + if (iconURI) { + var appIcon = document.createElement('img'); + appIcon.classList.add('appIcon'); + appIcon.src = iconURI; + card.appendChild(appIcon); + } + } + + var title = document.createElement('h1'); + title.textContent = app.name; + card.appendChild(title); + + var frameForScreenshot = app.iframe; + + if (PopupManager.getPopupFromOrigin(origin)) { + var popupFrame = PopupManager.getPopupFromOrigin(origin); + frameForScreenshot = popupFrame; + + var subtitle = document.createElement('p'); + subtitle.textContent = + PopupManager.getOpenedOriginFromOpener(origin); + card.appendChild(subtitle); + card.classList.add('popup'); + } else if (getOffOrigin(app.frame.dataset.url ? + app.frame.dataset.url : app.frame.src, origin)) { + var subtitle = document.createElement('p'); + subtitle.textContent = getOffOrigin(app.frame.dataset.url ? + app.frame.dataset.url : app.frame.src, origin); + card.appendChild(subtitle); + } + + if (TrustedUIManager.hasTrustedUI(origin)) { + var popupFrame = TrustedUIManager.getDialogFromOrigin(origin); + frameForScreenshot = popupFrame.frame; + var header = document.createElement('section'); + header.setAttribute('role', 'region'); + header.classList.add('skin-organic'); + header.innerHTML = '<header><button><span class="icon icon-close">'; + header.innerHTML += '</span></button><h1>' + popupFrame.name; + header.innerHTML += '</h1></header>'; + card.appendChild(header); + card.classList.add('trustedui'); + } else if (attentionScreenApps.indexOf(origin) == -1) { + var closeButton = document.createElement('div'); + closeButton.classList.add('close-card'); + card.appendChild(closeButton); + } + + cardsList.appendChild(card); + // rect is the final size (considering CSS transform) of the card. + var rect = card.getBoundingClientRect(); + + // And then switch it with screenshots when one will be ready + // (instead of -moz-element backgrounds) + frameForScreenshot.getScreenshot(rect.width, rect.height).onsuccess = + function gotScreenshot(screenshot) { + if (screenshot.target.result) { + var objectURL = URL.createObjectURL(screenshot.target.result); + screenshotObjectURLs.push(objectURL); + card.style.backgroundImage = 'url(' + objectURL + ')'; + } + }; + + // Set up event handling + // A click elsewhere in the card switches to that task + card.addEventListener('tap', runApp); + } + } + + function runApp(e) { + // Handle close events + if (e.target.classList.contains('close-card')) { + var element = e.target.parentNode; + cardsList.removeChild(element); + closeApp(element, true); + return; + } + + var origin = this.dataset.origin; + alignCard(currentDisplayed, function cardAligned() { + WindowManager.launch(origin); + }); + } + + function closeApp(element, removeImmediately) { + // Stop the app itself + WindowManager.kill(element.dataset.origin); + + // Fix for non selectable cards when we remove the last card + // Described in https://bugzilla.mozilla.org/show_bug.cgi?id=825293 + if (cardsList.children.length === currentDisplayed) { + currentDisplayed--; + } + + // If there are no cards left, then dismiss the task switcher. + if (!cardsList.children.length) + hideCardSwitcher(removeImmediately); + } + + function getOriginObject(url) { + var parser = document.createElement('a'); + parser.href = url; + + return { + protocol: parser.protocol, + hostname: parser.hostname, + port: parser.port + }; + } + + function getOffOrigin(src, origin) { + // Use src and origin as cache key + var cacheKey = JSON.stringify(Array.prototype.slice.call(arguments)); + if (!getOffOrigin.cache[cacheKey]) { + var native = getOriginObject(origin); + var current = getOriginObject(src); + if (current.protocol == 'http:') { + // Display http:// protocol anyway + getOffOrigin.cache[cacheKey] = current.protocol + '//' + + current.hostname; + } else if (native.protocol == current.protocol && + native.hostname == current.hostname && + native.port == current.port) { + // Same origin policy + getOffOrigin.cache[cacheKey] = ''; + } else if (current.protocol == 'app:') { + // Avoid displaying app:// protocol + getOffOrigin.cache[cacheKey] = ''; + } else { + getOffOrigin.cache[cacheKey] = current.protocol + '//' + + current.hostname; + } + } + + return getOffOrigin.cache[cacheKey]; + } + + getOffOrigin.cache = {}; + + function hideCardSwitcher(removeImmediately) { + if (!cardSwitcherIsShown()) + return; + + // events to handle + window.removeEventListener('lock', CardsView); + + // Make the cardsView overlay inactive + cardsView.classList.remove('active'); + cardsViewShown = false; + + // Release our screenshot blobs. + screenshotObjectURLs.forEach(function(url) { + URL.revokeObjectURL(url); + }); + screenshotObjectURLs = []; + + // And remove all the cards from the document after the transition + function removeCards() { + cardsView.removeEventListener('transitionend', removeCards); + screenElement.classList.remove('cards-view'); + + while (cardsList.firstElementChild) { + cardsList.removeChild(cardsList.firstElementChild); + } + } + if (removeImmediately) { + removeCards(); + } else { + cardsView.addEventListener('transitionend', removeCards); + } + } + + function cardSwitcherIsShown() { + return cardsViewShown; + } + + //scrolling cards + var initialCardViewPosition; + var initialTouchPosition = {}; + var threshold = window.innerWidth / 4; + // Distance after which dragged card starts moving + var moveCardThreshold = window.innerHeight / 6; + var removeCardThreshold = window.innerHeight / 4; + + function alignCard(number, callback) { + if (!cardsList.children[number]) + return; + + var scrollLeft = cardsView.scrollLeft; + var targetScrollLeft = cardsList.children[number].offsetLeft; + + if (Math.abs(scrollLeft - targetScrollLeft) < 4) { + cardsView.scrollLeft = cardsList.children[number].offsetLeft; + if (callback) + callback(); + return; + } + + cardsView.scrollLeft = scrollLeft + (targetScrollLeft - scrollLeft) / 2; + + window.mozRequestAnimationFrame(function newFrameCallback() { + alignCard(number, callback); + }); + } + + function onStartEvent(evt) { + evt.stopPropagation(); + evt.target.setCapture(true); + cardsView.addEventListener('mousemove', CardsView); + cardsView.addEventListener('swipe', CardsView); + + initialCardViewPosition = cardsView.scrollLeft; + initialTouchPosition = { + x: evt.touches ? evt.touches[0].pageX : evt.pageX, + y: evt.touches ? evt.touches[0].pageY : evt.pageY + }; + } + + function onMoveEvent(evt) { + evt.stopPropagation(); + var touchPosition = { + x: evt.touches ? evt.touches[0].pageX : evt.pageX, + y: evt.touches ? evt.touches[0].pageY : evt.pageY + }; + + if (evt.target.classList.contains('card') && MANUAL_CLOSING) { + var differenceY = initialTouchPosition.y - touchPosition.y; + if (differenceY > moveCardThreshold) { + // We don't want user to scroll the CardsView when one of the card is + // already dragger upwards + draggingCardUp = true; + evt.target.style.MozTransform = 'scale(0.6) translate(0, -' + + differenceY + 'px)'; + } + } + + // If we are not reordering or removing Cards now + // and Snapping Scrolling is enabled, we want to scroll + // the CardList + if (SNAPPING_SCROLLING && reorderedCard === null && !draggingCardUp) { + var differenceX = initialTouchPosition.x - touchPosition.x; + cardsView.scrollLeft = initialCardViewPosition + differenceX; + } + + // If re are in reordering mode (there is a DOM element in) + // reorderedCard variable) we are able to put this element somewere + // among the others + if (USER_DEFINED_ORDERING && reorderedCard !== null) { + var differenceX = touchPosition.x - initialTouchPosition.x; + // Probably there is more clever solution for calculating + // position of transformed DOM element, but this was my + // first thought and it seems to work + var moveOffset = (cardsList.children[currentDisplayed].offsetLeft / 0.6) + + differenceX - (dragMargin / 0.6); + + reorderedCard.style.MozTransform = + 'scale(0.6) translate(' + moveOffset + 'px, 0)'; + + if (Math.abs(differenceX) > threshold) { + // We don't want to jump to the next page immediately, + // We are waiting half a second for user to decide if + // he wants to leave the Card here or scroll further + if (allowScrollingWhileSorting) { + allowScrollingWhileSorting = false; + + scrollWhileSortingTimer = setTimeout(function() { + allowScrollingWhileSorting = true; + }, 500); + + if (differenceX > 0 && + currentDisplayed <= cardsList.children.length) { + currentDisplayed++; + sortingDirection = 'right'; + alignCard(currentDisplayed); + } else if (differenceX < 0 && currentDisplayed > 0) { + currentDisplayed--; + sortingDirection = 'left'; + alignCard(currentDisplayed); + } + } + } + } + } + + function onEndEvent(evt) { + evt.stopPropagation(); + var element = evt.target; + var eventDetail = evt.detail; + var direction = eventDetail.direction; + + document.releaseCapture(); + cardsView.removeEventListener('mousemove', CardsView); + cardsView.removeEventListener('swipe', CardsView); + + var touchPosition = { + x: eventDetail.end.pageX, + y: eventDetail.end.pageY + }; + + if (SNAPPING_SCROLLING && !draggingCardUp && reorderedCard === null) { + if (Math.abs(eventDetail.dx) > threshold) { + if ( + direction === 'left' && + currentDisplayed < cardsList.children.length - 1 + ) { + currentDisplayed++; + alignCard(currentDisplayed); + } else if (direction === 'right' && currentDisplayed > 0) { + currentDisplayed--; + alignCard(currentDisplayed); + } + } else { + alignCard(currentDisplayed); + } + } + + // if the element we start dragging on + // is a card and we are not in reordering mode + if ( + element.classList.contains('card') && + MANUAL_CLOSING && + reorderedCard === null + ) { + + draggingCardUp = false; + // Prevent user from closing the app with a attention screen + if (-eventDetail.dy > removeCardThreshold && + attentionScreenApps.indexOf(element.dataset.origin) == -1 + ) { + + // remove the app also from the ordering list + if ( + userSortedApps.indexOf(element.dataset.origin) !== -1 && + USER_DEFINED_ORDERING + ) { + userSortedApps.splice( + userSortedApps.indexOf(element.dataset.origin), + 1 + ); + } + + // Without removing the listener before closing card + // sometimes the 'click' event fires, even if 'mouseup' + // uses stopPropagation() + element.removeEventListener('tap', runApp); + + // Remove the icon from the task list + cardsList.removeChild(element); + + closeApp(element); + + return; + } else { + element.style.MozTransform = ''; + } + } + + if (USER_DEFINED_ORDERING && reorderedCard !== null) { + // Position of the card depends on direction of scrolling + if (sortingDirection === 'right') { + if (currentDisplayed <= cardsList.children.length) { + cardsList.insertBefore( + reorderedCard, + cardsList.children[currentDisplayed + 1] + ); + } else { + cardsList.appendChild(reorderedCard); + } + } else if (sortingDirection === 'left') { + cardsList.insertBefore( + reorderedCard, + cardsList.children[currentDisplayed] + ); + } + reorderedCard.style.MozTransform = ''; + reorderedCard.dataset['edit'] = 'false'; + reorderedCard = null; + + alignCard(currentDisplayed); + + // remove the app origin from ordering array + userSortedApps.splice( + userSortedApps.indexOf(element.dataset.origin), + 1 + ); + // and put in on the new position + userSortedApps.splice(currentDisplayed, 0, element.dataset.origin); + } + } + + function manualOrderStart(evt) { + evt.preventDefault(); + reorderedCard = evt.target; + allowScrollingWhileSorting = true; + if (reorderedCard.classList.contains('card')) { + dragMargin = reorderedCard.offsetLeft; + reorderedCard.dataset['edit'] = true; + sortingDirection = 'left'; + } + } + + window.addEventListener('applicationuninstall', + function removeUninstaledApp(evt) { + var origin = evt.detail.application.origin; + if (userSortedApps.indexOf(origin) !== -1) { + userSortedApps.splice(userSortedApps.indexOf(origin), 1); + } + }, + false); + + function cv_handleEvent(evt) { + switch (evt.type) { + case 'mousedown': + onStartEvent(evt); + break; + + case 'mousemove': + onMoveEvent(evt); + break; + + case 'swipe': + onEndEvent(evt); + break; + + case 'contextmenu': + manualOrderStart(evt); + break; + + case 'home': + if (!cardSwitcherIsShown()) + return; + + evt.stopImmediatePropagation(); + hideCardSwitcher(); + break; + + case 'lock': + case 'attentionscreenshow': + attentionScreenApps = AttentionScreen.getAttentionScreenOrigins(); + hideCardSwitcher(); + break; + + case 'attentionscreenhide': + attentionScreenApps = AttentionScreen.getAttentionScreenOrigins(); + break; + + case 'holdhome': + if (LockScreen.locked) + return; + + SleepMenu.hide(); + showCardSwitcher(); + break; + + case 'appwillopen': + hideCardSwitcher(); + break; + } + } + + // Public API of CardsView + return { + showCardSwitcher: showCardSwitcher, + hideCardSwitcher: hideCardSwitcher, + cardSwitcherIsShown: cardSwitcherIsShown, + handleEvent: cv_handleEvent + }; +})(); + +window.addEventListener('attentionscreenshow', CardsView); +window.addEventListener('attentionscreenhide', CardsView); +window.addEventListener('holdhome', CardsView); +window.addEventListener('home', CardsView); +window.addEventListener('appwillopen', CardsView); + diff --git a/apps/system/js/context_menu.js b/apps/system/js/context_menu.js new file mode 100644 index 0000000..816ef71 --- /dev/null +++ b/apps/system/js/context_menu.js @@ -0,0 +1,24 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +var ContextMenu = { + init: function cm_init() { + window.addEventListener('mozbrowsercontextmenu', this, true); + }, + + handleEvent: function cm_handleEvent(evt) { + var detail = evt.detail; + if (detail.contextmenu.items.length == 0) + return; + + var onsuccess = function(action) { + detail.contextMenuItemSelected(action); + }; + + ListMenu.request(detail.contextmenu.items, '', onsuccess); + } +}; + +ContextMenu.init(); diff --git a/apps/system/js/cost_control.js b/apps/system/js/cost_control.js new file mode 100644 index 0000000..0f07962 --- /dev/null +++ b/apps/system/js/cost_control.js @@ -0,0 +1,89 @@ +/* -*- Mode: js; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +(function() { + + 'use strict'; + + var host = document.location.host; + var domain = host.replace(/(^[\w\d]+\.)?([\w\d]+\.[a-z]+)/, '$2'); + var protocol = document.location.protocol + '//'; + var origin = protocol + 'costcontrol.' + domain; + + var widgetContainer = document.getElementById('cost-control-widget'); + + var widgetFrame; + function _ensureWidget() { + if (!Applications.ready) + return; + + // Check widget is there + widgetFrame = widgetContainer.querySelector('iframe'); + if (widgetFrame && !widgetFrame.dataset.killed) + return; + + // Create the widget + if (!widgetFrame) { + widgetFrame = document.createElement('iframe'); + widgetFrame.addEventListener('mozbrowsererror', + function ccdriver_onError(e) { + e.target.dataset.killed = true; + } + ); + } + + widgetFrame.dataset.frameType = 'widget'; + widgetFrame.dataset.frameOrigin = origin; + delete widgetFrame.dataset.killed; + + widgetFrame.setAttribute('mozbrowser', true); + widgetFrame.setAttribute('remote', 'true'); + widgetFrame.setAttribute('mozapp', origin + '/manifest.webapp'); + + widgetFrame.src = origin + '/widget.html'; + widgetContainer.appendChild(widgetFrame); + + _adjustWidgetPosition(); + } + + function _showWidget() { + _ensureWidget(); + widgetFrame.setVisible(true); + } + + function _hideWidget() { + if (widgetFrame) { + widgetFrame.setVisible(false); + } + } + + function _adjustWidgetPosition() { + // TODO: Remove this when weird bug #809031 (Bugzilla) is solved + // See cost_control.css as well to remove the last rule + var offsetY = document.getElementById('notification-bar').clientHeight; + offsetY += + document.getElementById('notifications-container').clientHeight; + widgetFrame.style.transform = 'translate(0, ' + offsetY + 'px)'; + } + + // Listen to utilitytray show + window.addEventListener('utilitytrayshow', _showWidget); + window.addEventListener('utilitytrayhide', _hideWidget); + + window.addEventListener('applicationready', function _onReady() { + asyncStorage.getItem('ftu.enabled', function _onValue(enabled) { + if (enabled !== false) { + window.addEventListener('ftudone', function ftudone(e) { + window.removeEventListener('ftudone', ftudone); + _ensureWidget(); + widgetFrame.setVisible(false); + }); + } else { + _ensureWidget(); + widgetFrame.setVisible(false); + } + }); + }); + + window.addEventListener('resize', _adjustWidgetPosition); +}()); diff --git a/apps/system/js/crash_reporter.js b/apps/system/js/crash_reporter.js new file mode 100644 index 0000000..4068291 --- /dev/null +++ b/apps/system/js/crash_reporter.js @@ -0,0 +1,140 @@ +/* -*- Mode: js; js-indent-level: 2; indent-tabs-mode: nil -*- */ +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +// This file calls getElementById without waiting for an onload event, so it +// must have a defer attribute or be included at the end of the <body>. + +var CrashReporter = (function() { + var _ = navigator.mozL10n.get; + var settings = navigator.mozSettings; + var screen = document.getElementById('screen'); + + // The name of the app that just crashed. + var crashedAppName = ''; + + // Whether or not to show a "Report" button in the banner. + var showReportButton = false; + + // Only show the "Report" button if the user hasn't set a preference to + // always/never report crashes. + SettingsListener.observe('app.reportCrashes', 'ask', + function handleCrashSetting(value) { + showReportButton = (value != 'always' && value != 'never'); + }); + + // This function should only ever be called once. + function showDialog(crashID, isChrome) { + var title = isChrome ? _('crash-dialog-os2') : + _('crash-dialog-app', { name: crashedAppName }); + document.getElementById('crash-dialog-title').textContent = title; + + // "Don't Send Report" button in dialog + var noButton = document.getElementById('dont-send-report'); + noButton.addEventListener('click', function onNoButtonClick() { + settings.createLock().set({'app.reportCrashes': 'never'}); + removeDialog(); + }); + + // "Send Report" button in dialog + var yesButton = document.getElementById('send-report'); + yesButton.addEventListener('click', function onYesButtonClick() { + submitCrash(crashID); + if (checkbox.checked) { + settings.createLock().set({'app.reportCrashes': 'always'}); + } + removeDialog(); + }); + + var checkbox = document.getElementById('always-send'); + checkbox.addEventListener('click', function onCheckboxClick() { + // Disable the "Don't Send Report" button if the "Always send..." + // checkbox is checked + noButton.disabled = this.checked; + }); + + // "What's in a crash report?" link + var crashInfoLink = document.getElementById('crash-info-link'); + crashInfoLink.addEventListener('click', function onLearnMoreClick() { + var dialog = document.getElementById('crash-dialog'); + document.getElementById('crash-reports-done'). + addEventListener('click', function onDoneClick() { + this.removeEventListener('click', onDoneClick); + dialog.classList.remove('learn-more'); + }); + dialog.classList.add('learn-more'); + }); + + screen.classList.add('crash-dialog'); + } + + // We can get rid of the dialog after it is shown once. + function removeDialog() { + screen.classList.remove('crash-dialog'); + var dialog = document.getElementById('crash-dialog'); + dialog.parentNode.removeChild(dialog); + } + + function showBanner(crashID, isChrome) { + var message = isChrome ? _('crash-banner-os2') : + _('crash-banner-app', { name: crashedAppName }); + + var button = null; + if (showReportButton) { + button = { + label: _('crash-banner-report'), + callback: function reportCrash() { + submitCrash(crashID); + } + }; + } + + SystemBanner.show(message, button); + } + + function submitCrash(crashID) { + var event = document.createEvent('CustomEvent'); + event.initCustomEvent('mozContentEvent', true, true, { + type: 'submit-crash', + crashID: crashID + }); + window.dispatchEvent(event); + } + + // - Show a dialog only the first time there's a crash to report. + // - On subsequent crashes, show a banner letting the user know there was a + // crash. + // - If the user hasn't set a pref, add a "Report" button to the banner. + function handleCrash(crashID, isChrome) { + // Check to see if we should show a dialog. + var dialogReq = settings.createLock().get('crashReporter.dialogShown'); + dialogReq.onsuccess = function dialogShownSuccess() { + var dialogShown = dialogReq.result['crashReporter.dialogShown']; + if (!dialogShown) { + settings.createLock().set({'crashReporter.dialogShown': true}); + showDialog(crashID, isChrome); + } else { + showBanner(crashID, isChrome); + } + }; + } + + // We depend on window_manager.js calling this function before + // we get a 'handle-crash' event from shell.js + function setAppName(name) { + crashedAppName = name; + } + + // We will be notified of system crashes from shell.js + window.addEventListener('mozChromeEvent', function handleChromeEvent(e) { + if (e.detail.type == 'handle-crash') { + handleCrash(e.detail.crashID, e.detail.chrome); + } + }); + + return { + setAppName: setAppName + }; +})(); + diff --git a/apps/system/js/gridview.js b/apps/system/js/gridview.js new file mode 100644 index 0000000..399bf3e --- /dev/null +++ b/apps/system/js/gridview.js @@ -0,0 +1,40 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +var GridView = { + grid: null, + + get visible() { + return this.grid && this.grid.style.display === 'block'; + }, + + hide: function gv_hide() { + if (this.grid) + this.grid.style.visibility = 'hidden'; + }, + + show: function gv_show() { + var grid = this.grid; + if (!grid) { + grid = document.createElement('div'); + grid.id = 'debug-grid'; + grid.dataset.zIndexLevel = 'debug-grid'; + + this.grid = grid; + document.getElementById('screen').appendChild(grid); + } + + grid.style.visibility = 'visible'; + }, + + toggle: function gv_toggle() { + this.visible ? this.hide() : this.show(); + } +}; + +SettingsListener.observe('debug.grid.enabled', false, function(value) { + !!value ? GridView.show() : GridView.hide(); +}); + diff --git a/apps/system/js/hardware_buttons.js b/apps/system/js/hardware_buttons.js new file mode 100644 index 0000000..3363463 --- /dev/null +++ b/apps/system/js/hardware_buttons.js @@ -0,0 +1,318 @@ +// hardware_buttons.js: +// +// Gecko code in b2g/chrome/content/shell.js sends mozChromeEvents +// when the user presses or releases a hardware button such as Home, Sleep, +// and Volume Up and Down. +// +// This module listens for those low-level mozChromeEvents, processes them +// and generates higher-level events to handle autorepeat on the volume keys +// long presses on Home and Sleep, and the Home+Sleep key combination. +// +// Other system app modules should listen for the high-level button events +// generated by this module. +// +// The low-level input events processed by this module have type set +// to "mozChromeEvent" and detail.type set to one of: +// +// home-button-press +// home-button-release +// sleep-button-press +// sleep-button-release +// volume-up-button-press +// volume-up-button-release +// volume-down-button-press +// volume-down-button-release +// +// The high-level events generated by this module are simple Event objects +// that are not cancelable and do not bubble. The are dispatched at the +// window object. The type property is set to one of these: +// +// Event Type Meaning +// -------------------------------------------------------------- +// home short press and release of home button +// holdhome long press and hold of home button +// sleep short press and release of sleep button +// wake sleep or home pressed while sleeping +// holdsleep long press and hold of sleep button +// volumeup volume up pressed and released or autorepeated +// volumedown volume down pressed and released or autorepeated +// home+sleep home and sleep pressed at same time (used for screenshots) +// home+volume home and either volume key at the same time (view source) +// +// Because these events are fired at the window object, they cannot be +// captured. Many modules listen for the home event. Those that want +// to respond to it and prevent others from responding should call +// stopImmediatePropagation(). Overlays that want to prevent the window +// manager from showing the homescreen on the home event should call that +// method. Note, however, that this only works for scripts that run and +// register their event handlers before window_manager.js does. +// +'use strict'; + +(function() { + var HOLD_INTERVAL = 750; // How long for press and hold Home or Sleep + var REPEAT_DELAY = 700; // How long before volume autorepeat begins + var REPEAT_INTERVAL = 100; // How fast the autorepeat is. + + // Dispatch a high-level event of the specified type + function fire(type) { + window.dispatchEvent(new Event(type)); + } + + // We process events with a finite state machine. + // Each state object has a process() method for handling events. + // And optionally has enter() and exit() methods called when the FSM + // enters and exits that state + var state; + + // This function transitions to a new state + function setState(s, type) { + // Exit the current state() + if (state && state.exit) + state.exit(type); + state = s; + // Enter the new state + if (state && state.enter) + state.enter(type); + } + + // This event handler listens for hardware button events and passes the + // event type to the process() method of the current state for processing + window.addEventListener('mozChromeEvent', function(e) { + var type = e.detail.type; + switch (type) { + case 'home-button-press': + case 'home-button-release': + case 'sleep-button-press': + case 'sleep-button-release': + case 'volume-up-button-press': + case 'volume-up-button-release': + case 'volume-down-button-press': + case 'volume-down-button-release': + state.process(type); + break; + } + }); + + // The base state is the default, when no hardware buttons are pressed + var baseState = { + process: function(type) { + switch (type) { + case 'home-button-press': + // If the phone is sleeping, then pressing Home wakes it + // (on press, not release) + if (!ScreenManager.screenEnabled) { + fire('wake'); + setState(wakeState, type); + } else { + setState(homeState, type); + } + return; + case 'sleep-button-press': + // If the phone is sleeping, then pressing Sleep wakes it + // (on press, not release) + if (!ScreenManager.screenEnabled) { + fire('wake'); + setState(wakeState, type); + } else { + setState(sleepState, type); + } + return; + case 'volume-up-button-press': + case 'volume-down-button-press': + setState(volumeState, type); + return; + case 'home-button-release': + case 'sleep-button-release': + case 'volume-up-button-release': + case 'volume-down-button-release': + // Ignore button releases that occur in this state. + // These can happen after home+sleep and home+volume. + return; + } + console.error('Unexpected hardware key: ', type); + } + }; + + // We enter the home state when the user presses the Home button + // We can fire home, holdhome, or homesleep events from this state + var homeState = { + timer: null, + enter: function() { + this.timer = setTimeout(function() { + fire('holdhome'); + navigator.vibrate(50); + setState(baseState); + }, HOLD_INTERVAL); + }, + exit: function() { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + }, + process: function(type) { + switch (type) { + case 'home-button-release': + fire('home'); + navigator.vibrate(50); + setState(baseState, type); + return; + case 'sleep-button-press': + fire('home+sleep'); + setState(baseState, type); + return; + case 'volume-up-button-press': + case 'volume-down-button-press': + fire('home+volume'); + setState(baseState, type); + return; + } + console.error('Unexpected hardware key: ', type); + setState(baseState, type); + } + }; + + // We enter the sleep state when the user presses the Sleep button + // We can fire sleep, holdsleep, or homesleep events from this state + var sleepState = { + timer: null, + enter: function() { + this.timer = setTimeout(function() { + fire('holdsleep'); + setState(baseState); + }, HOLD_INTERVAL); + }, + exit: function() { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + }, + process: function(type) { + switch (type) { + case 'sleep-button-release': + fire('sleep'); + setState(baseState, type); + return; + case 'home-button-press': + fire('home+sleep'); + setState(baseState, type); + return; + case 'volume-up-button-press': + case 'volume-down-button-press': + setState(volumeState, type); + return; + } + console.error('Unexpected hardware key: ', type); + setState(baseState, type); + } + }; + + // We enter the volume state when the user presses the volume up or + // volume down buttons. + // We can fire volumeup and volumedown events from this state + var volumeState = { + direction: null, + timer: null, + repeating: false, + repeat: function() { + this.repeating = true; + if (this.direction === 'volume-up-button-press') + fire('volumeup'); + else + fire('volumedown'); + this.timer = setTimeout(this.repeat.bind(this), REPEAT_INTERVAL); + }, + enter: function(type) { + var self = this; + this.direction = type; // Is volume going up or down? + this.repeating = false; + this.timer = setTimeout(this.repeat.bind(this), REPEAT_DELAY); + }, + exit: function() { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + }, + process: function(type) { + switch (type) { + case 'home-button-press': + fire('home+volume'); + setState(baseState, type); + return; + case 'sleep-button-press': + setState(sleepState, type); + return; + case 'volume-up-button-release': + if (this.direction === 'volume-up-button-press') { + if (!this.repeating) + fire('volumeup'); + setState(baseState, type); + return; + } + break; + case 'volume-down-button-release': + if (this.direction === 'volume-down-button-press') { + if (!this.repeating) + fire('volumedown'); + setState(baseState, type); + return; + } + break; + default: + // Ignore anything else (such as sleep button release) + return; + } + console.error('Unexpected hardware key: ', type); + setState(baseState, type); + } + }; + + // We enter this state when the user presses Home or Sleep on a sleeping + // phone. We give immediate feedback by waking the phone up on the press + // rather than waiting for the release, but this means we need a special + // state so that we don't actually send a home or sleep event on the + // key release. Note, however, that this state does set a timer so that + // it can send holdhome or holdsleep events. (This means that pressing and + // holding sleep will bring up the power menu, even on a sleeping phone.) + var wakeState = { + timer: null, + delegateState: null, + enter: function(type) { + if (type === 'home-button-press') + this.delegateState = homeState; + else + this.delegateState = sleepState; + this.timer = setTimeout(function() { + if (type === 'home-button-press') { + fire('holdhome'); + } else if (type === 'sleep-button-press') { + fire('holdsleep'); + } + setState(baseState, type); + }, HOLD_INTERVAL); + }, + exit: function() { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + }, + process: function(type) { + switch (type) { + case 'home-button-release': + case 'sleep-button-release': + setState(baseState, type); + return; + default: + this.delegateState.process(type); + return; + } + } + }; + + // Kick off the FSM in the base state + setState(baseState); +}()); diff --git a/apps/system/js/icc_cache.js b/apps/system/js/icc_cache.js new file mode 100644 index 0000000..1f1d0df --- /dev/null +++ b/apps/system/js/icc_cache.js @@ -0,0 +1,86 @@ +/* -*- Mode: js; js-indent-level: 2; indent-tabs-mode: nil -*- */ +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +(function() { + /** + * Constants + */ + var DEBUG = false; + + /** + * Debug method + */ + function debug(msg, optObject) { + if (DEBUG) { + var output = '[DEBUG] STKCACHE: ' + msg; + if (optObject) { + output += JSON.stringify(optObject); + } + console.log(output); + } + } + + if (!window.navigator.mozMobileConnection) { + return; + } + + var icc = window.navigator.mozMobileConnection.icc; + // Remove previous menu + var resetApplications = window.navigator.mozSettings.createLock().set({ + 'icc.applications': '{}' + }); + resetApplications.onsuccess = function icc_resetApplications() { + debug('STK Cache Reseted'); + // Register to receive STK commands + window.navigator.mozSetMessageHandler('icc-stkcommand', + function handleSTKCommand(command) { + debug('STK Proactive Command:', command); + if (command.typeOfCommand == icc.STK_CMD_SET_UP_MENU) { + debug('STK_CMD_SET_UP_MENU:', command.options); + var reqApplications = window.navigator.mozSettings.createLock().set({ + 'icc.applications': JSON.stringify(command.options) + }); + reqApplications.onsuccess = function icc_getApplications() { + debug('Cached'); + icc.sendStkResponse(command, { + resultCode: icc.STK_RESULT_OK + }); + } + } else { + // Unsolicited command? -> Open settings + debug('CMD: ', command); + var application = document.location.protocol + '//' + + document.location.host.replace('system', 'settings'); + debug('application: ', application); + var reqIccData = window.navigator.mozSettings.createLock().set({ + 'icc.data': JSON.stringify(command) + }); + reqIccData.onsuccess = function icc_getIccData() { + if (WindowManager.getRunningApps()[application]) { + debug('Settings is running. Ignoring'); + return; // If settings is opened, we don't manage it + } + + debug('Locating settings . . .'); + navigator.mozApps.mgmt.getAll().onsuccess = function gotApps(evt) { + var apps = evt.target.result; + apps.forEach(function appIterator(app) { + if (app.origin != application) + return; + + var reqIccData = window.navigator.mozSettings.createLock().set({ + 'icc.data': JSON.stringify(command) + }); + reqIccData.onsuccess = function icc_getIccData() { + debug('Launching ', app.origin); + app.launch(); + } + }, this); + } + } + } + }); + } +})(); diff --git a/apps/system/js/identity.js b/apps/system/js/identity.js new file mode 100644 index 0000000..8030377 --- /dev/null +++ b/apps/system/js/identity.js @@ -0,0 +1,98 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +// When bug 794999 is resolved, switch to use the abstract Trusted UI Component + +'use strict'; + +const kIdentityScreen = 'https://login.native-persona.org/sign_in#NATIVE'; +const kIdentityFrame = + 'https://login.native-persona.org/communication_iframe'; + +var Identity = (function() { + var iframe; + + return { + trustedUILayers: {}, + + init: function() { + window.addEventListener('mozChromeEvent', this); + }, + + handleEvent: function onMozChromeEvent(e) { + var chromeEventId = e.detail.id; + var requestId = e.detail.requestId; + switch (e.detail.type) { + // Chrome asks Gaia to show the identity dialog. + case 'open-id-dialog': + if (!chromeEventId) + return; + + // When opening the dialog, we record the chrome event id, which + // we will need to send back to the TrustedUIManager when asking + // to close. + this.trustedUILayers[requestId] = chromeEventId; + + if (!e.detail.showUI && iframe) { + this._dispatchEvent({ + id: chromeEventId, + frame: iframe + }); + return; + } + var frame = document.createElement('iframe'); + frame.setAttribute('mozbrowser', 'true'); + frame.setAttribute('remote', true); + frame.classList.add('screen'); + frame.src = e.detail.showUI ? kIdentityScreen : kIdentityFrame; + frame.addEventListener('mozbrowserloadstart', + function loadStart(evt) { + // After creating the new frame containing the identity flow, we + // send it back to chrome so the identity callbacks can be injected. + this._dispatchEvent({ + id: chromeEventId, + frame: evt.target + }); + }.bind(this)); + + + if (e.detail.showUI) { + // The identity flow is shown within the trusted UI. + TrustedUIManager.open(navigator.mozL10n.get('persona-signin'), + frame, + this.trustedUILayers[requestId]); + } else { + var container = document.getElementById('screen'); + container.appendChild(frame); + frame.classList.add('communication-frame'); + iframe = frame; + } + break; + + case 'received-id-assertion': + if (e.detail.showUI) { + TrustedUIManager.close(this.trustedUILayers[requestId], + (function dialogClosed() { + delete this.trustedUILayers[requestId]; + }).bind(this)); + } + this._dispatchEvent({ id: chromeEventId }); + break; + } + }, + _dispatchEvent: function su_dispatchEvent(obj) { + var event = document.createEvent('CustomEvent'); + event.initCustomEvent('mozContentEvent', true, true, obj); + window.dispatchEvent(event); + } + }; +})(); + +// Make sure L10n is ready before init +if (navigator.mozL10n.readyState == 'complete' || + navigator.mozL10n.readyState == 'interactive') { + Identity.init(); +} else { + window.addEventListener('localized', Identity.init.bind(Identity)); +} + diff --git a/apps/system/js/keyboard_manager.js b/apps/system/js/keyboard_manager.js new file mode 100644 index 0000000..62aeca7 --- /dev/null +++ b/apps/system/js/keyboard_manager.js @@ -0,0 +1,86 @@ +'use strict'; + +var KeyboardManager = (function() { + function getKeyboardURL() { + // TODO: Retrieve it from Settings, allowing 3rd party keyboards + var host = document.location.host; + var domain = host.replace(/(^[\w\d]+\.)?([\w\d]+\.[a-z]+)/, '$2'); + var protocol = document.location.protocol; + + return protocol + '//keyboard.' + domain + '/'; + } + + function generateKeyboard(container, keyboardURL, manifestURL) { + var keyboard = document.createElement('iframe'); + keyboard.src = keyboardURL; + keyboard.setAttribute('mozbrowser', 'true'); + keyboard.setAttribute('mozpasspointerevents', 'true'); + keyboard.setAttribute('mozapp', manifestURL); + //keyboard.setAttribute('remote', 'true'); + + container.appendChild(keyboard); + return keyboard; + } + + // Generate a <iframe mozbrowser> containing the keyboard. + var container = document.getElementById('keyboard-frame'); + var keyboardURL = getKeyboardURL() + 'index.html'; + var manifestURL = getKeyboardURL() + 'manifest.webapp'; + var keyboard = generateKeyboard(container, keyboardURL, manifestURL); + + // Listen for mozbrowserlocationchange of keyboard iframe. + var previousHash = ''; + + var urlparser = document.createElement('a'); + keyboard.addEventListener('mozbrowserlocationchange', function(e) { + urlparser.href = e.detail; + if (previousHash == urlparser.hash) + return; + previousHash = urlparser.hash; + + var type = urlparser.hash.split('='); + switch (type[0]) { + case '#show': + var updateHeight = function updateHeight() { + container.removeEventListener('transitionend', updateHeight); + if (container.classList.contains('hide')) { + // The keyboard has been closed already, let's not resize the + // application and ends up with half apps. + return; + } + + var detail = { + 'detail': { + 'height': parseInt(type[1]) + } + }; + + dispatchEvent(new CustomEvent('keyboardchange', detail)); + } + + if (container.classList.contains('hide')) { + container.classList.remove('hide'); + container.addEventListener('transitionend', updateHeight); + return; + } + + updateHeight(); + break; + + case '#hide': + // inform window manager to resize app first or + // it may show the underlying homescreen + dispatchEvent(new CustomEvent('keyboardhide')); + container.classList.add('hide'); + break; + } + }); + + // For Bug 812115: hide the keyboard when the app is closed here, + // since it would take a longer round-trip to receive focuschange + window.addEventListener('appwillclose', function closeKeyboard() { + dispatchEvent(new CustomEvent('keyboardhide')); + container.classList.add('hide'); + }); +})(); + diff --git a/apps/system/js/list_menu.js b/apps/system/js/list_menu.js new file mode 100644 index 0000000..303fafe --- /dev/null +++ b/apps/system/js/list_menu.js @@ -0,0 +1,180 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +var ListMenu = { + get element() { + delete this.element; + return this.element = document.getElementById('listmenu'); + }, + + get container() { + delete this.container; + return this.container = document.querySelector('#listmenu menu'); + }, + + get visible() { + return this.element.classList.contains('visible'); + }, + + // Listen to click event only + init: function lm_init() { + window.addEventListener('click', this, true); + window.addEventListener('screenchange', this, true); + window.addEventListener('home', this); + window.addEventListener('holdhome', this); + }, + + // Pass an array of list items and handler for clicking on the items + // Modified to fit contextmenu use case, loop into the menu items + request: function lm_request(listItems, title, successCb, errorCb) { + this.container.innerHTML = ''; + this.currentLevel = 0; + this.internalList = []; + this.setTitle(title); + this.buildMenu(listItems); + this.internalList.forEach(function render_item(item) { + this.container.appendChild(item); + }, this); + + this.onreturn = successCb || function() {}; + this.oncancel = errorCb || function() {}; + + this.show(); + }, + + buildMenu: function lm_buildMenu(items) { + var containerDiv = document.createElement('ul'); + var _ = navigator.mozL10n.get; + + if (this.currentLevel === 0) { + containerDiv.classList.add('list-menu-root'); + containerDiv.id = 'list-menu-root'; + } else { + containerDiv.id = 'list-menu-' + this.internalList.length; + } + this.internalList.push(containerDiv); + + items.forEach(function traveseItems(item) { + var item_div = document.createElement('li'); + var button = document.createElement('a'); + button.setAttribute('role', 'button'); + if (item.type && item.type == 'menu') { + // XXX: We disallow multi-level menu at this moment + // See https://bugzilla.mozilla.org/show_bug.cgi?id=824928 + // for UX design and dev implementation tracking + return; + } else if (item.type && item.type == 'menuitem') { + button.dataset.value = item.id; + button.textContent = item.label; + } else { + button.dataset.value = item.value; + button.textContent = item.label; + } + + item_div.appendChild(button); + if (item.icon) { + button.style.backgroundImage = 'url(' + item.icon + ')'; + button.classList.add('icon'); + } + containerDiv.appendChild(item_div); + }, this); + + if (this.currentLevel > 0) { + var back = document.createElement('li'); + var button = document.createElement('a'); + button.setAttribute('role', 'button'); + button.textContent = _('back'); + button.href = '#' + this.currentParent; + back.classList.add('back'); + back.appendChild(button); + containerDiv.appendChild(back); + } else { + var cancel = document.createElement('li'); + var button = document.createElement('button'); + button.textContent = _('cancel'); + button.dataset.action = 'cancel'; + cancel.appendChild(button); + containerDiv.appendChild(cancel); + } + + containerDiv.dataset.level = this.currentLevel; + this.currentChild = containerDiv.id; + }, + + setTitle: function lm_setTitle(title) { + if (!title) + return; + + var titleElement = document.createElement('h3'); + titleElement.textContent = title; + this.container.appendChild(titleElement); + }, + + show: function lm_show() { + if (this.visible) + return; + + this.container.classList.remove('slidedown'); + this.element.classList.add('visible'); + }, + + hide: function lm_hide() { + if (!this.visible) + return; + + var self = this; + var container = this.container; + container.addEventListener('transitionend', function list_hide() { + container.removeEventListener('transitionend', list_hide); + self.element.classList.remove('visible'); + }); + + setTimeout(function() { + container.classList.add('slidedown'); + }); + }, + + handleEvent: function lm_handleEvent(evt) { + switch (evt.type) { + case 'screenchange': + if (!evt.detail.screenEnabled) { + this.hide(); + this.oncancel(); + } + break; + + case 'click': + if (!this.visible) + return; + + var cancel = evt.target.dataset.action; + if (cancel && cancel == 'cancel') { + this.hide(); + this.oncancel(); + return; + } + + var value = evt.target.dataset.value; + if (!value) { + return; + } + + this.hide(); + this.onreturn(value); + break; + + case 'home': + case 'holdhome': + if (!this.visible) + return; + + this.hide(); + this.oncancel(); + break; + } + } +}; + +ListMenu.init(); diff --git a/apps/system/js/lockscreen.js b/apps/system/js/lockscreen.js new file mode 100644 index 0000000..f2275c9 --- /dev/null +++ b/apps/system/js/lockscreen.js @@ -0,0 +1,1019 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +var LockScreen = { + /* + * Boolean return true when initialized. + */ + ready: false, + + /* + * Boolean return the status of the lock screen. + * Must not multate directly - use unlock()/lockIfEnabled() + * Listen to 'lock' and 'unlock' event to properly handle status changes + */ + locked: true, + + /* + * Boolean return whether if the lock screen is enabled or not. + * Must not multate directly - use setEnabled(val) + * Only Settings Listener should change this value to sync with data + * in Settings API. + */ + enabled: true, + + /* + * Boolean returns wether we want a sound effect when unlocking. + */ + unlockSoundEnabled: true, + + /* + * Boolean return whether if the lock screen is enabled or not. + * Must not multate directly - use setPassCodeEnabled(val) + * Only Settings Listener should change this value to sync with data + * in Settings API. + * Will be ignored if 'enabled' is set to false. + */ + passCodeEnabled: false, + + /* + * Four digit Passcode + * XXX: should come for Settings + */ + passCode: '0000', + + /* + * The time to request for passcode input since device is off. + */ + passCodeRequestTimeout: 0, + + /* + * Store the first time the screen went off since unlocking. + */ + _screenOffTime: 0, + + /* + * Check the timeout of passcode lock + */ + _passCodeTimeoutCheck: false, + + /* + * Current passcode entered by the user + */ + passCodeEntered: '', + + /** + * Are we currently switching panels ? + */ + _switchingPanel: false, + + /* + * Timeout after incorrect attempt + */ + kPassCodeErrorTimeout: 500, + + /* + * Airplane mode + */ + airplaneMode: false, + + /* + * Timeout ID for backing from triggered state to normal state + */ + triggeredTimeoutId: 0, + + /* + * Interval ID for elastic of curve and arrow + */ + elasticIntervalId: 0, + + /* + * elastic animation interval + */ + ELASTIC_INTERVAL: 5000, + + /* + * timeout for triggered state after swipe up + */ + TRIGGERED_TIMEOUT: 7000, + + /* + * Max value for handle swiper up + */ + HANDLE_MAX: 70, + + /* init */ + init: function ls_init() { + if (this.ready) { // already initialized: just trigger a translation + this.updateTime(); + this.updateConnState(); + return; + } + this.ready = true; + + this.getAllElements(); + + this.lockIfEnabled(true); + this.writeSetting(this.enabled); + + /* Status changes */ + window.addEventListener('volumechange', this); + window.addEventListener('screenchange', this); + + /* Gesture */ + this.area.addEventListener('mousedown', this); + this.areaCamera.addEventListener('click', this); + this.areaUnlock.addEventListener('click', this); + this.iconContainer.addEventListener('mousedown', this); + + /* Unlock & camera panel clean up */ + this.overlay.addEventListener('transitionend', this); + + /* Passcode input pad*/ + this.passcodePad.addEventListener('click', this); + + /* switching panels */ + window.addEventListener('home', this); + + /* blocking holdhome and prevent Cards View from show up */ + window.addEventListener('holdhome', this, true); + + /* mobile connection state on lock screen */ + var conn = window.navigator.mozMobileConnection; + if (conn && conn.voice) { + conn.addEventListener('voicechange', this); + conn.addEventListener('cardstatechange', this); + conn.addEventListener('iccinfochange', this); + this.updateConnState(); + this.connstate.hidden = false; + } + + var self = this; + if (navigator && navigator.mozCellBroadcast) { + navigator.mozCellBroadcast.onreceived = function onReceived(event) { + var msg = event.message; + if (conn && + conn.voice.network.mcc === MobileOperator.BRAZIL_MCC && + msg.messageId === MobileOperator.BRAZIL_CELLBROADCAST_CHANNEL) { + self.cellbroadcastLabel = msg.body; + self.updateConnState(); + } + }; + } + + SettingsListener.observe('lockscreen.enabled', true, function(value) { + self.setEnabled(value); + }); + + SettingsListener.observe('ring.enabled', true, function(value) { + self.mute.hidden = value; + }); + + SettingsListener.observe('vibration.enabled', true, function(value) { + if (value) { + self.mute.classList.add('vibration'); + } else { + self.mute.classList.remove('vibration'); + } + }); + + SettingsListener.observe('ril.radio.disabled', false, function(value) { + self.airplaneMode = value; + self.updateConnState(); + }); + + SettingsListener.observe('wallpaper.image', + 'resources/images/backgrounds/default.png', + function(value) { + self.updateBackground(value); + self.overlay.classList.remove('uninit'); + }); + + SettingsListener.observe( + 'lockscreen.passcode-lock.code', '0000', function(value) { + self.passCode = value; + }); + + SettingsListener.observe( + 'lockscreen.passcode-lock.enabled', false, function(value) { + self.setPassCodeEnabled(value); + }); + + SettingsListener.observe('lockscreen.unlock-sound.enabled', + true, function(value) { + self.setUnlockSoundEnabled(value); + }); + + SettingsListener.observe('lockscreen.passcode-lock.timeout', + 0, function(value) { + self.passCodeRequestTimeout = value; + }); + }, + + /* + * Set enabled state. + * If enabled state is somehow updated when the lock screen is enabled + * This function will unlock it. + */ + setEnabled: function ls_setEnabled(val) { + if (typeof val === 'string') { + this.enabled = val == 'false' ? false : true; + } else { + this.enabled = val; + } + + if (!this.enabled && this.locked) { + this.unlock(); + } + }, + + setPassCodeEnabled: function ls_setPassCodeEnabled(val) { + if (typeof val === 'string') { + this.passCodeEnabled = val == 'false' ? false : true; + } else { + this.passCodeEnabled = val; + } + }, + + setUnlockSoundEnabled: function ls_setUnlockSoundEnabled(val) { + if (typeof val === 'string') { + this.unlockSoundEnabled = val == 'false' ? false : true; + } else { + this.unlockSoundEnabled = val; + } + }, + + handleEvent: function ls_handleEvent(evt) { + switch (evt.type) { + case 'screenchange': + // XXX: If the screen is not turned off by ScreenManager + // we would need to lock the screen again + // when it's being turned back on + if (!evt.detail.screenEnabled) { + // Don't update the time after we're already locked otherwise turning + // the screen off again will bypass the passcode before the timeout. + if (!this.locked) { + this._screenOffTime = new Date().getTime(); + } + + // Remove camera once screen turns off + if (this.camera.firstElementChild) + this.camera.removeChild(this.camera.firstElementChild); + + } else { + var _screenOffInterval = new Date().getTime() - this._screenOffTime; + if (_screenOffInterval > this.passCodeRequestTimeout * 1000) { + this._passCodeTimeoutCheck = true; + } else { + this._passCodeTimeoutCheck = false; + } + } + + this.lockIfEnabled(true); + break; + case 'voicechange': + case 'cardstatechange': + case 'iccinfochange': + this.updateConnState(); + + case 'click': + if (evt.target === this.areaUnlock || evt.target === this.areaCamera) { + this.handleIconClick(evt.target); + break; + } + + if (!evt.target.dataset.key) + break; + + // Cancel the default action of <a> + evt.preventDefault(); + this.handlePassCodeInput(evt.target.dataset.key); + break; + + case 'mousedown': + var leftTarget = this.areaCamera; + var rightTarget = this.areaUnlock; + var handle = this.areaHandle; + var overlay = this.overlay; + var target = evt.target; + + // Reset timer when touch while overlay triggered + if (overlay.classList.contains('triggered')) { + clearTimeout(this.triggeredTimeoutId); + this.triggeredTimeoutId = setTimeout(this.unloadPanel.bind(this), + this.TRIGGERED_TIMEOUT); + break; + } + + overlay.classList.remove('elastic'); + this.setElasticEnabled(false); + + this._touch = { + touched: false, + leftTarget: leftTarget, + rightTarget: rightTarget, + overlayWidth: this.overlay.offsetWidth, + handleWidth: this.areaHandle.offsetWidth, + maxHandleOffset: rightTarget.offsetLeft - handle.offsetLeft - + (handle.offsetWidth - rightTarget.offsetWidth) / 2 + }; + window.addEventListener('mouseup', this); + window.addEventListener('mousemove', this); + + this._touch.touched = true; + this._touch.initX = evt.pageX; + this._touch.initY = evt.pageY; + overlay.classList.add('touched'); + break; + + case 'mousemove': + this.handleMove(evt.pageX, evt.pageY); + break; + + case 'mouseup': + window.removeEventListener('mousemove', this); + window.removeEventListener('mouseup', this); + + this.handleMove(evt.pageX, evt.pageY); + this.handleGesture(); + delete this._touch; + this.overlay.classList.remove('touched'); + + break; + + case 'transitionend': + if (evt.target !== this.overlay) + return; + + if (this.overlay.dataset.panel !== 'camera' && + this.camera.firstElementChild) { + this.camera.removeChild(this.camera.firstElementChild); + } + + if (!this.locked) + this.switchPanel(); + break; + + case 'home': + if (this.locked) { + this.switchPanel(); + evt.stopImmediatePropagation(); + } + break; + + case 'holdhome': + if (!this.locked) + return; + + evt.stopImmediatePropagation(); + evt.stopPropagation(); + break; + } + }, + + handleMove: function ls_handleMove(pageX, pageY) { + var touch = this._touch; + + if (!touch.touched) { + // Do nothing if the user have not move the finger to the handle yet + if (document.elementFromPoint(pageX, pageY) !== this.areaHandle) + return; + + touch.touched = true; + touch.initX = pageX; + touch.initY = pageY; + + var overlay = this.overlay; + overlay.classList.add('touched'); + } + + var dy = pageY - touch.initY; + var ty = Math.max(- this.HANDLE_MAX, dy); + var base = - ty / this.HANDLE_MAX; + // mapping position 20-100 to opacity 0.1-0.5 + var opacity = base <= 0.2 ? 0.1 : base * 0.5; + touch.ty = ty; + + this.iconContainer.style.transform = 'translateY(' + ty + 'px)'; + this.areaCamera.style.opacity = + this.areaUnlock.style.opacity = opacity; + }, + + handleGesture: function ls_handleGesture() { + var touch = this._touch; + if (touch.ty < -50) { + this.areaHandle.style.transform = + this.areaHandle.style.opacity = + this.iconContainer.style.transform = + this.iconContainer.style.opacity = + this.areaCamera.style.transform = + this.areaCamera.style.opacity = + this.areaUnlock.style.transform = + this.areaUnlock.style.opacity = ''; + this.overlay.classList.add('triggered'); + + this.triggeredTimeoutId = + setTimeout(this.unloadPanel.bind(this), this.TRIGGERED_TIMEOUT); + } else if (touch.ty > -10) { + touch.touched = false; + this.unloadPanel(); + this.playElastic(); + + var self = this; + var container = this.iconContainer; + container.addEventListener('animationend', function prompt() { + container.removeEventListener('animationend', prompt); + self.overlay.classList.remove('elastic'); + self.setElasticEnabled(true); + }); + } else { + this.unloadPanel(); + this.setElasticEnabled(true); + } + }, + + handleIconClick: function ls_handleIconClick(target) { + var self = this; + switch (target) { + case this.areaCamera: + var panelOrFullApp = function panelOrFullApp() { + if (self.passCodeEnabled) { + // Go to secure camera panel + self.switchPanel('camera'); + return; + } + + self.unlock(); + + var a = new MozActivity({ + name: 'record', + data: { + type: 'photos' + } + }); + a.onerror = function ls_activityError() { + console.log('MozActivity: camera launch error.'); + }; + }; + + panelOrFullApp(); + break; + + case this.areaUnlock: + var passcodeOrUnlock = function passcodeOrUnlock() { + if (!self.passCodeEnabled || !self._passCodeTimeoutCheck) { + self.unlock(); + } else { + self.switchPanel('passcode'); + } + }; + passcodeOrUnlock(); + break; + } + }, + + handlePassCodeInput: function ls_handlePassCodeInput(key) { + switch (key) { + case 'e': // Emergency Call + this.switchPanel('emergency-call'); + break; + + case 'c': + this.switchPanel(); + break; + + case 'b': + if (this.overlay.dataset.passcodeStatus) + return; + + this.passCodeEntered = + this.passCodeEntered.substr(0, this.passCodeEntered.length - 1); + this.updatePassCodeUI(); + + break; + default: + if (this.overlay.dataset.passcodeStatus) + return; + + this.passCodeEntered += key; + this.updatePassCodeUI(); + + if (this.passCodeEntered.length === 4) + this.checkPassCode(); + break; + } + }, + + lockIfEnabled: function ls_lockIfEnabled(instant) { + if (this.enabled) { + this.lock(instant); + } else { + this.unlock(instant); + } + }, + + unlock: function ls_unlock(instant) { + var currentApp = WindowManager.getDisplayedApp(); + WindowManager.setOrientationForApp(currentApp); + + var currentFrame = WindowManager.getAppFrame(currentApp).firstChild; + var wasAlreadyUnlocked = !this.locked; + this.locked = false; + this.setElasticEnabled(false); + this.mainScreen.focus(); + + var repaintTimeout = 0; + var nextPaint = (function() { + clearTimeout(repaintTimeout); + currentFrame.removeNextPaintListener(nextPaint); + + if (instant) { + this.overlay.classList.add('no-transition'); + this.switchPanel(); + } else { + this.overlay.classList.remove('no-transition'); + } + + this.mainScreen.classList.remove('locked'); + + if (!wasAlreadyUnlocked) { + // Any changes made to this, + // also need to be reflected in apps/system/js/storage.js + this.dispatchEvent('unlock'); + this.writeSetting(false); + + if (instant) + return; + + if (this.unlockSoundEnabled) { + var unlockAudio = new Audio('./resources/sounds/unlock.ogg'); + unlockAudio.play(); + } + } + }).bind(this); + + this.dispatchEvent('will-unlock'); + currentFrame.addNextPaintListener(nextPaint); + repaintTimeout = setTimeout(function ensureUnlock() { + nextPaint(); + }, 400); + }, + + lock: function ls_lock(instant) { + var wasAlreadyLocked = this.locked; + this.locked = true; + + this.updateTime(); + + this.switchPanel(); + + this.setElasticEnabled(ScreenManager.screenEnabled); + + this.overlay.focus(); + if (instant) + this.overlay.classList.add('no-transition'); + else + this.overlay.classList.remove('no-transition'); + + this.mainScreen.classList.add('locked'); + + screen.mozLockOrientation('portrait-primary'); + + if (!wasAlreadyLocked) { + if (document.mozFullScreen) + document.mozCancelFullScreen(); + + // Any changes made to this, + // also need to be reflected in apps/system/js/storage.js + this.dispatchEvent('lock'); + this.writeSetting(true); + } + }, + + loadPanel: function ls_loadPanel(panel, callback) { + this._loadingPanel = true; + switch (panel) { + case 'passcode': + case 'main': + if (callback) + setTimeout(callback); + break; + + case 'emergency-call': + // create the <iframe> and load the emergency call + var frame = document.createElement('iframe'); + + frame.src = './emergency-call/index.html'; + frame.onload = function emergencyCallLoaded() { + if (callback) + callback(); + }; + this.panelEmergencyCall.appendChild(frame); + + break; + + case 'camera': + // create the <iframe> and load the camera + var frame = document.createElement('iframe'); + + frame.src = './camera/index.html'; + var mainScreen = this.mainScreen; + frame.onload = function cameraLoaded() { + mainScreen.classList.add('lockscreen-camera'); + if (callback) + callback(); + }; + this.overlay.classList.remove('no-transition'); + this.camera.appendChild(frame); + + break; + } + }, + + unloadPanel: function ls_unloadPanel(panel, toPanel, callback) { + switch (panel) { + case 'passcode': + // Reset passcode panel only if the status is not error + if (this.overlay.dataset.passcodeStatus == 'error') + break; + + delete this.overlay.dataset.passcodeStatus; + this.passCodeEntered = ''; + this.updatePassCodeUI(); + break; + + case 'camera': + this.mainScreen.classList.remove('lockscreen-camera'); + break; + + case 'emergency-call': + var ecPanel = this.panelEmergencyCall; + ecPanel.addEventListener('transitionend', function unloadPanel() { + ecPanel.removeEventListener('transitionend', unloadPanel); + ecPanel.removeChild(ecPanel.firstElementChild); + }); + break; + + case 'main': + default: + var self = this; + var unload = function unload() { + self.areaHandle.style.transform = + self.areaUnlock.style.transform = + self.areaCamera.style.transform = + self.iconContainer.style.transform = + self.iconContainer.style.opacity = + self.areaHandle.style.opacity = + self.areaUnlock.style.opacity = + self.areaCamera.style.opacity = ''; + self.overlay.classList.remove('triggered'); + self.areaHandle.classList.remove('triggered'); + self.areaCamera.classList.remove('triggered'); + self.areaUnlock.classList.remove('triggered'); + + clearTimeout(self.triggeredTimeoutId); + self.setElasticEnabled(false); + }; + + if (toPanel !== 'camera') { + unload(); + break; + } + + this.overlay.addEventListener('transitionend', + function ls_unloadDefaultPanel(evt) { + if (evt.target !== this) + return; + + self.overlay.removeEventListener('transitionend', + ls_unloadDefaultPanel); + unload(); + } + ); + + break; + } + + if (callback) + setTimeout(callback); + }, + + switchPanel: function ls_switchPanel(panel) { + if (this._switchingPanel) { + return; + } + + var overlay = this.overlay; + var self = this; + panel = panel || 'main'; + + this._switchingPanel = true; + this.loadPanel(panel, function panelLoaded() { + self.unloadPanel(overlay.dataset.panel, panel, + function panelUnloaded() { + if (overlay.dataset.panel !== panel) + self.dispatchEvent('lockpanelchange'); + + overlay.dataset.panel = panel; + self._switchingPanel = false; + }); + }); + }, + + updateTime: function ls_updateTime() { + if (!this.locked) + return; + + var d = new Date(); + var f = new navigator.mozL10n.DateTimeFormat(); + var _ = navigator.mozL10n.get; + + var timeFormat = _('shortTimeFormat') || '%H:%M'; + var dateFormat = _('longDateFormat') || '%A %e %B'; + var time = f.localeFormat(d, timeFormat); + this.clockNumbers.textContent = time.match(/([012]?\d).[0-5]\d/g); + this.clockMeridiem.textContent = (time.match(/AM|PM/i) || []).join(''); + this.date.textContent = f.localeFormat(d, dateFormat); + + var self = this; + window.setTimeout(function ls_clockTimeout() { + self.updateTime(); + }, (59 - d.getSeconds()) * 1000); + }, + + updateConnState: function ls_updateConnState() { + var conn = window.navigator.mozMobileConnection; + if (!conn) + return; + + var voice = conn.voice; + var iccInfo = conn.iccInfo; + var connstateLine1 = this.connstate.firstElementChild; + var connstateLine2 = this.connstate.lastElementChild; + var _ = navigator.mozL10n.get; + + var updateConnstateLine1 = function updateConnstateLine1(l10nId) { + connstateLine1.dataset.l10nId = l10nId; + connstateLine1.textContent = _(l10nId) || ''; + }; + + var self = this; + var updateConnstateLine2 = function updateConnstateLine2(l10nId) { + if (l10nId) { + self.connstate.classList.add('twolines'); + connstateLine2.dataset.l10nId = l10nId; + connstateLine2.textContent = _(l10nId) || ''; + } else { + self.connstate.classList.remove('twolines'); + delete(connstateLine2.dataset.l10nId); + connstateLine2.textContent = ''; + } + }; + + // Reset line 2 + updateConnstateLine2(); + + if (this.airplaneMode) { + updateConnstateLine1('airplaneMode'); + return; + } + + // Possible value of voice.state are: + // 'notSearching', 'searching', 'denied', 'registered', + // where the latter three mean the phone is trying to grab the network. + // See https://bugzilla.mozilla.org/show_bug.cgi?id=777057 + if (voice.state == 'notSearching') { + updateConnstateLine1('noNetwork'); + return; + } + + if (!voice.connected && !voice.emergencyCallsOnly) { + // "Searching" + // voice.state can be any of the latter three values. + // (it's possible that the phone is briefly 'registered' + // but not yet connected.) + updateConnstateLine1('searching'); + return; + } + + if (voice.emergencyCallsOnly) { + updateConnstateLine1('emergencyCallsOnly'); + + switch (conn.cardState) { + case 'absent': + updateConnstateLine2('emergencyCallsOnly-noSIM'); + break; + + case 'pinRequired': + updateConnstateLine2('emergencyCallsOnly-pinRequired'); + break; + + case 'pukRequired': + updateConnstateLine2('emergencyCallsOnly-pukRequired'); + break; + + case 'networkLocked': + updateConnstateLine2('emergencyCallsOnly-networkLocked'); + break; + + default: + updateConnstateLine2(); + break; + } + return; + } + + var operatorInfos = MobileOperator.userFacingInfo(conn); + if (this.cellbroadcastLabel) { + connstateLine2.textContent = this.cellbroadcastLabel; + } else if (operatorInfos.carrier) { + connstateLine2.textContent = operatorInfos.carrier + ' ' + + operatorInfos.region; + } + + var operator = operatorInfos.operator; + + if (voice.roaming) { + var l10nArgs = { operator: operator }; + connstateLine1.dataset.l10nId = 'roaming'; + connstateLine1.dataset.l10nArgs = JSON.stringify(l10nArgs); + connstateLine1.textContent = _('roaming', l10nArgs); + + return; + } + + delete connstateLine1.dataset.l10nId; + connstateLine1.textContent = operator; + }, + + updatePassCodeUI: function lockscreen_updatePassCodeUI() { + var overlay = this.overlay; + if (overlay.dataset.passcodeStatus) + return; + if (this.passCodeEntered) { + overlay.classList.add('passcode-entered'); + } else { + overlay.classList.remove('passcode-entered'); + } + var i = 4; + while (i--) { + var span = this.passcodeCode.childNodes[i]; + if (this.passCodeEntered.length > i) { + span.dataset.dot = true; + } else { + delete span.dataset.dot; + } + } + }, + + checkPassCode: function lockscreen_checkPassCode() { + if (this.passCodeEntered === this.passCode) { + var self = this; + this.overlay.dataset.passcodeStatus = 'success'; + this.passCodeError = 0; + + var transitionend = function() { + self.passcodeCode.removeEventListener('transitionend', transitionend); + self.unlock(); + }; + this.passcodeCode.addEventListener('transitionend', transitionend); + } else { + this.overlay.dataset.passcodeStatus = 'error'; + if ('vibrate' in navigator) + navigator.vibrate([50, 50, 50]); + + var self = this; + setTimeout(function error() { + delete self.overlay.dataset.passcodeStatus; + self.passCodeEntered = ''; + self.updatePassCodeUI(); + }, this.kPassCodeErrorTimeout); + } + }, + + updateBackground: function ls_updateBackground(background_datauri) { + this._imgPreload([background_datauri, 'style/lockscreen/images/mask.png'], + function(images) { + + // Bug 829075 : We need a <canvas> in the DOM to prevent banding on + // Otoro-like devices + var canvas = document.createElement('canvas'); + canvas.classList.add('lockscreen-wallpaper'); + canvas.width = images[0].width; + canvas.height = images[0].height; + + var ctx = canvas.getContext('2d'); + ctx.drawImage(images[0], 0, 0); + ctx.drawImage(images[1], 0, 0); + + var panels_selector = '.lockscreen-panel[data-wallpaper]'; + var panels = document.querySelectorAll(panels_selector); + for (var i = 0, il = panels.length; i < il; i++) { + var copied_canvas; + var panel = panels[i]; + + // Remove previous <canvas> if they exist + var old_canvas = panel.querySelector('canvas'); + if (old_canvas) { + old_canvas.parentNode.removeChild(old_canvas); + } + + // For the first panel, we can use the existing <canvas> + if (!copied_canvas) { + copied_canvas = canvas; + } else { + // Otherwise, copy the node and content + copied_canvas = canvas.cloneNode(); + copied_canvas.getContext('2d').drawImage(canvas, 0, 0); + } + + panel.insertBefore(copied_canvas, panel.firstChild); + } + }); + }, + + _imgPreload: function ls_imgPreload(img_paths, callback) { + var loaded = 0; + var images = []; + var il = img_paths.length; + var inc = function() { + loaded += 1; + if (loaded === il && callback) { + callback(images); + } + }; + for (var i = 0; i < il; i++) { + images[i] = new Image(); + images[i].onload = inc; + images[i].src = img_paths[i]; + } + }, + + getAllElements: function ls_getAllElements() { + // ID of elements to create references + var elements = ['connstate', 'mute', 'clock-numbers', 'clock-meridiem', + 'date', 'area', 'area-unlock', 'area-camera', 'icon-container', + 'area-handle', 'passcode-code', + 'passcode-pad', 'camera', 'accessibility-camera', + 'accessibility-unlock', 'panel-emergency-call']; + + var toCamelCase = function toCamelCase(str) { + return str.replace(/\-(.)/g, function replacer(str, p1) { + return p1.toUpperCase(); + }); + }; + + elements.forEach((function createElementRef(name) { + this[toCamelCase(name)] = document.getElementById('lockscreen-' + name); + }).bind(this)); + + this.overlay = document.getElementById('lockscreen'); + this.mainScreen = document.getElementById('screen'); + }, + + dispatchEvent: function ls_dispatchEvent(name) { + var evt = document.createEvent('CustomEvent'); + evt.initCustomEvent(name, true, true, null); + window.dispatchEvent(evt); + }, + + writeSetting: function ls_writeSetting(value) { + if (!window.navigator.mozSettings) + return; + + SettingsListener.getSettingsLock().set({ + 'lockscreen.locked': value + }); + }, + + setElasticEnabled: function ls_setElasticEnabled(value) { + clearInterval(this.elasticIntervalId); + if (value) { + this.elasticIntervalId = + setInterval(this.playElastic.bind(this), this.ELASTIC_INTERVAL); + } + }, + + playElastic: function ls_playElastic() { + if (this._touch && this._touch.touched) + return; + + var overlay = this.overlay; + var container = this.iconContainer; + + overlay.classList.add('elastic'); + container.addEventListener('animationend', function animationend(e) { + container.removeEventListener(e.type, animationend); + overlay.classList.remove('elastic'); + }); + } +}; + +// Bug 836195 - [Homescreen] Dock icons drop down in the UI +// consistently when using a lockcode and visiting camera +LockScreen.init(); + +navigator.mozL10n.ready(LockScreen.init.bind(LockScreen)); + diff --git a/apps/system/js/modal_dialog.js b/apps/system/js/modal_dialog.js new file mode 100644 index 0000000..33f3201 --- /dev/null +++ b/apps/system/js/modal_dialog.js @@ -0,0 +1,443 @@ +/* -*- Mode: js; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +// The modal dialog listen to mozbrowsershowmodalprompt event. +// Blocking the current app and then show cutom modal dialog +// (alert/confirm/prompt) + +var ModalDialog = { + // Used for element id access. + // e.g., 'modal-dialog-alert-ok' + prefix: 'modal-dialog-', + + // DOM + elements: {}, + + // Get all elements when inited. + getAllElements: function md_getAllElements() { + var elementsID = ['alert', 'alert-ok', 'alert-message', + 'prompt', 'prompt-ok', 'prompt-cancel', 'prompt-input', 'prompt-message', + 'confirm', 'confirm-ok', 'confirm-cancel', 'confirm-message', + 'select-one', 'select-one-cancel', 'select-one-menu', 'select-one-title', + 'alert-title', 'confirm-title', 'prompt-title']; + + var toCamelCase = function toCamelCase(str) { + return str.replace(/\-(.)/g, function replacer(str, p1) { + return p1.toUpperCase(); + }); + }; + + // Loop and add element with camel style name to Modal Dialog attribute. + elementsID.forEach(function createElementRef(name) { + this.elements[toCamelCase(name)] = + document.getElementById(this.prefix + name); + }, this); + + this.screen = document.getElementById('screen'); + this.overlay = document.getElementById('dialog-overlay'); + }, + + // Save the events returned by mozbrowsershowmodalprompt for later use. + // The events are stored according to webapp origin + // e.g., 'http://uitest.gaiamobile.org': evt + currentEvents: {}, + + init: function md_init() { + // Get all elements initially. + this.getAllElements(); + var elements = this.elements; + + // Bind events + window.addEventListener('mozbrowsershowmodalprompt', this); + window.addEventListener('appopen', this); + window.addEventListener('appwillclose', this); + window.addEventListener('appterminated', this); + window.addEventListener('resize', this); + window.addEventListener('keyboardchange', this); + window.addEventListener('keyboardhide', this); + window.addEventListener('home', this); + window.addEventListener('holdhome', this); + + for (var id in elements) { + var tagName = elements[id].tagName.toLowerCase(); + if (tagName == 'button' || tagName == 'ul') { + elements[id].addEventListener('click', this); + } + } + }, + + // Default event handler + handleEvent: function md_handleEvent(evt) { + var elements = this.elements; + switch (evt.type) { + case 'mozbrowsershowmodalprompt': + var frameType = evt.target.dataset.frameType; + if (frameType != 'window' && frameType != 'inline-activity') + return; + + evt.preventDefault(); + var origin = evt.target.dataset.frameOrigin; + this.currentEvents[origin] = evt; + + // Show modal dialog only if + // the frame is currently displayed. + if (origin == WindowManager.getDisplayedApp() || + frameType == 'inline-activity') + this.show(evt.target, origin); + break; + + case 'click': + if (evt.currentTarget === elements.confirmCancel || + evt.currentTarget === elements.promptCancel || + evt.currentTarget === elements.selectOneCancel) { + this.cancelHandler(); + } else if (evt.currentTarget === elements.selectOneMenu) { + this.selectOneHandler(evt.target); + } else { + this.confirmHandler(); + } + break; + + case 'appopen': + this.show(evt.target, evt.detail.origin); + break; + + case 'home': + case 'holdhome': + // Inline activity, which origin is different from foreground app + if (this.isVisible() && + this.currentOrigin != WindowManager.getDisplayedApp()) + this.cancelHandler(); + break; + + case 'appwillclose': + // Do nothing if the app is closed at background. + if (evt.detail.origin !== this.currentOrigin) + return; + + // Reset currentOrigin + this.hide(); + break; + + case 'appterminated': + if (this.currentEvents[evt.detail.origin]) + delete this.currentEvents[evt.detail.origin]; + + break; + + case 'resize': + case 'keyboardhide': + if (!this.currentOrigin) + return; + + this.setHeight(window.innerHeight - StatusBar.height); + break; + + case 'keyboardchange': + this.setHeight(window.innerHeight - + evt.detail.height - StatusBar.height); + break; + } + }, + + setHeight: function md_setHeight(height) { + if (this.isVisible()) + this.overlay.style.height = height + 'px'; + }, + + // Show relative dialog and set message/input value well + show: function md_show(target, origin) { + if (!(origin in this.currentEvents)) + return; + + var _ = navigator.mozL10n.get; + var evt = this.currentEvents[origin]; + this.currentOrigin = origin; + + var message = evt.detail.message || ''; + var elements = this.elements; + this.screen.classList.add('modal-dialog'); + + function escapeHTML(str) { + var span = document.createElement('span'); + span.textContent = str; + // Escape space for displaying multiple space in message. + span.innerHTML = span.innerHTML.replace(/\n/g, '<br/>'); + return span.innerHTML; + } + + var type = evt.detail.promptType || evt.detail.type; + if (type !== 'selectone') { + message = escapeHTML(message); + } + + switch (type) { + case 'alert': + elements.alertMessage.innerHTML = message; + elements.alert.classList.add('visible'); + this.setTitle('alert', ''); + elements.alertOk.textContent = evt.yesText ? evt.yesText : _('ok'); + break; + + case 'prompt': + elements.prompt.classList.add('visible'); + elements.promptInput.value = evt.detail.initialValue; + elements.promptMessage.innerHTML = message; + this.setTitle('prompt', ''); + elements.promptOk.textContent = evt.yesText ? evt.yesText : _('ok'); + elements.promptCancel.textContent = evt.noText ? + evt.noText : _('cancel'); + break; + + case 'confirm': + elements.confirm.classList.add('visible'); + elements.confirmMessage.innerHTML = message; + this.setTitle('confirm', ''); + elements.confirmOk.textContent = evt.yesText ? evt.yesText : _('ok'); + elements.confirmCancel.textContent = evt.noText ? + evt.noText : _('cancel'); + break; + + case 'selectone': + this.buildSelectOneDialog(message); + elements.selectOne.classList.add('visible'); + break; + } + + this.setHeight(window.innerHeight - StatusBar.height); + }, + + hide: function md_hide() { + var evt = this.currentEvents[this.currentOrigin]; + var type = evt.detail.promptType; + if (type == 'prompt') { + this.elements.promptInput.blur(); + } + this.currentOrigin = null; + this.screen.classList.remove('modal-dialog'); + this.elements[type].classList.remove('visible'); + }, + + setTitle: function md_setTitle(type, title) { + this.elements[type + 'Title'].textContent = title; + }, + + // When user clicks OK button on alert/confirm/prompt + confirmHandler: function md_confirmHandler() { + this.screen.classList.remove('modal-dialog'); + var elements = this.elements; + + var evt = this.currentEvents[this.currentOrigin]; + + var type = evt.detail.promptType || evt.detail.type; + switch (type) { + case 'alert': + elements.alert.classList.remove('visible'); + break; + + case 'prompt': + evt.detail.returnValue = elements.promptInput.value; + elements.prompt.classList.remove('visible'); + break; + + case 'confirm': + evt.detail.returnValue = true; + elements.confirm.classList.remove('visible'); + break; + } + + if (evt.isPseudo && evt.callback) { + evt.callback(evt.detail.returnValue); + } + + if (evt.detail.unblock) + evt.detail.unblock(); + + delete this.currentEvents[this.currentOrigin]; + }, + + // When user clicks cancel button on confirm/prompt or + // when the user try to escape the dialog with the escape key + cancelHandler: function md_cancelHandler() { + var evt = this.currentEvents[this.currentOrigin]; + this.screen.classList.remove('modal-dialog'); + var elements = this.elements; + + var type = evt.detail.promptType || evt.detail.type; + switch (type) { + case 'alert': + elements.alert.classList.remove('visible'); + break; + + case 'prompt': + /* return null when click cancel */ + evt.detail.returnValue = null; + elements.prompt.classList.remove('visible'); + break; + + case 'confirm': + /* return false when click cancel */ + evt.detail.returnValue = false; + elements.confirm.classList.remove('visible'); + break; + + case 'selectone': + /* return null when click cancel */ + evt.detail.returnValue = null; + elements.selectOne.classList.remove('visible'); + break; + } + + if (evt.isPseudo && evt.cancelCallback) { + evt.cancelCallback(evt.detail.returnValue); + } + + if (evt.detail.unblock) + evt.detail.unblock(); + + delete this.currentEvents[this.currentOrigin]; + }, + + // When user selects an option on selectone dialog + selectOneHandler: function md_confirmHandler(target) { + this.screen.classList.remove('modal-dialog'); + var elements = this.elements; + + var evt = this.currentEvents[this.currentOrigin]; + + evt.detail.returnValue = target.id; + elements.selectOne.classList.remove('visible'); + + if (evt.isPseudo && evt.callback) { + evt.callback(evt.detail.returnValue); + } + + if (evt.detail.unblock) + evt.detail.unblock(); + + delete this.currentEvents[this.currentOrigin]; + }, + + buildSelectOneDialog: function md_buildSelectOneDialog(data) { + var elements = this.elements; + elements.selectOneTitle.textContent = data.title; + elements.selectOneMenu.innerHTML = ''; + + if (!data.options) { + return; + } + + var itemsHTML = []; + for (var i = 0; i < data.options.length; i++) { + itemsHTML.push('<li><button id="'); + itemsHTML.push(data.options[i].id); + itemsHTML.push('">'); + itemsHTML.push(data.options[i].text); + itemsHTML.push('</button></li>'); + } + + elements.selectOneMenu.innerHTML = itemsHTML.join(''); + }, + + /** + * Method about customized alert + * @param {String} title the title of the dialog. null or empty for + * no title. + * @param {String} text message for the dialog. + * @param {Object} confirm {title, callback} object when confirm. + */ + alert: function md_alert(title, text, confirm) { + this.showWithPseudoEvent({ + type: 'alert', + text: text, + callback: confirm.callback, + title: title, + yesText: confirm.title + }); + }, + + /** + * Method about customized confirm + * @param {String} title the title of the dialog. null or empty for + * no title. + * @param {String} text message for the dialog. + * @param {Object} confirm {title, callback} object when confirm. + * @param {Object} cancel {title, callback} object when cancel. + */ + confirm: function md_confirm(title, text, confirm, cancel) { + this.showWithPseudoEvent({ + type: 'confirm', + text: text, + callback: confirm.callback, + cancel: cancel.callback, + title: title, + yesText: confirm.title, + noText: cancel.title + }); + }, + + /** + * Method about customized prompt + * @param {String} title the title of the dialog. null or empty for + * no title. + * @param {String} text message for the dialog. + * @param {String} default_value message in the text field. + * @param {Object} confirm {title, callback} object when confirm. + * @param {Object} cancel {title, callback} object when cancel. + */ + prompt: function md_prompt(title, text, default_value, confirm, cancel) { + this.showWithPseudoEvent({ + type: 'prompt', + text: text, + initialValue: default_value, + callback: confirm.callback, + cancel: cancel.callback, + title: title, + yesText: confirm.title, + noText: cancel.title + }); + }, + + selectOne: function md_selectOne(data, callback) { + this.showWithPseudoEvent({ + type: 'selectone', + text: data, + callback: callback + }); + }, + + showWithPseudoEvent: function md_showWithPseudoEvent(config) { + var pseudoEvt = { + isPseudo: true, + detail: { + unblock: null + } + }; + + pseudoEvt.detail.message = config.text; + pseudoEvt.callback = config.callback; + pseudoEvt.detail.promptType = config.type; + pseudoEvt.cancelCallback = config.cancel; + pseudoEvt.yesText = config.yesText; + pseudoEvt.noText = config.noText; + if (config.type == 'prompt') { + pseudoEvt.detail.initialValue = config.initialValue; + } + + // Create a virtual mapping in this.currentEvents, + // since system-app uses the different way to call ModalDialog. + this.currentEvents['system'] = pseudoEvt; + this.show(null, 'system'); + if (config.title) + this.setTitle(config.type, config.title); + }, + + isVisible: function md_isVisible() { + return this.screen.classList.contains('modal-dialog'); + } +}; + +ModalDialog.init(); + diff --git a/apps/system/js/mouse2touch.js b/apps/system/js/mouse2touch.js new file mode 100644 index 0000000..b9d6a89 --- /dev/null +++ b/apps/system/js/mouse2touch.js @@ -0,0 +1,70 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +/* +* Mouse2Touch is a shim that listens to MouseEvent but passes +* fake TouchEvent object to touch event handler +* +* XXX: remove this if we are not going to support testing on +* non-touch devices, e.g. B2G Desktop, Nightly, or, +* make the creation of this object optional if we can reliably detect +* touch support by evaluate (document instanceof DocumentTouch) +* +*/ +var Mouse2Touch = (function m2t() { + var Mouse2TouchEvent = { + 'mousedown': 'touchstart', + 'mousemove': 'touchmove', + 'mouseup': 'touchend' + }; + + var Touch2MouseEvent = { + 'touchstart': 'mousedown', + 'touchmove': 'mousemove', + 'touchend': 'mouseup' + }; + + var ForceOnWindow = { + 'touchmove': true, + 'touchend': true + }; + + var addEventHandler = function m2t_addEventHandlers(target, + name, + listener) { + target = ForceOnWindow[name] ? window : target; + name = Touch2MouseEvent[name] || name; + target.addEventListener(name, { + handleEvent: function m2t_handleEvent(evt) { + if (Mouse2TouchEvent[evt.type]) { + var original = evt; + evt = { + type: Mouse2TouchEvent[original.type], + target: original.target, + touches: [original], + preventDefault: function() { + original.preventDefault(); + } + }; + evt.changedTouches = evt.touches; + } + return listener.handleEvent(evt); + } + }, true); + }; + + var removeEventHandler = function m2t_removeEventHandler(target, + name, + listener) { + target = ForceOnWindow[name] ? window : target; + name = Touch2MouseEvent[name] || name; + target.removeEventListener(name, listener); + }; + + return { + addEventHandler: addEventHandler, + removeEventHandler: removeEventHandler + }; +})(); diff --git a/apps/system/js/notifications.js b/apps/system/js/notifications.js new file mode 100644 index 0000000..dae4ab4 --- /dev/null +++ b/apps/system/js/notifications.js @@ -0,0 +1,410 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +(function appCacheIcons() { + // Caching the icon for notification if appCache is in effect + var appCache = window.applicationCache; + if (!appCache) + return; + + var addIcons = function addIcons(app) { + var icons = app.manifest.icons; + if (icons) { + Object.keys(icons).forEach(function iconIterator(key) { + var url = app.origin + icons[key]; + appCache.mozAdd(url); + }); + } + }; + + var removeIcons = function removeIcons(app) { + var icons = app.manifest.icons; + if (icons) { + Object.keys(icons).forEach(function iconIterator(key) { + var url = app.origin + icons[key]; + appCache.mozRemove(url); + }); + } + }; + + window.addEventListener('applicationinstall', function bsm_oninstall(evt) { + addIcons(evt.detail.application); + }); + + window.addEventListener('applicationuninstall', function bsm_oninstall(evt) { + removeIcons(evt.detail.application); + }); +}()); + +var NotificationScreen = { + TOASTER_TIMEOUT: 5000, + TRANSITION_SPEED: 1.8, + TRANSITION_FRACTION: 0.30, + + _notification: null, + _containerWidth: null, + _toasterTimeout: null, + _toasterGD: null, + + lockscreenPreview: true, + silent: false, + alerts: true, + vibrates: true, + + init: function ns_init() { + window.addEventListener('mozChromeEvent', this); + this.container = + document.getElementById('desktop-notifications-container'); + this.lockScreenContainer = + document.getElementById('notifications-lockscreen-container'); + this.toaster = document.getElementById('notification-toaster'); + this.toasterIcon = document.getElementById('toaster-icon'); + this.toasterTitle = document.getElementById('toaster-title'); + this.toasterDetail = document.getElementById('toaster-detail'); + this.clearAllButton = document.getElementById('notification-clear'); + + this._toasterGD = new GestureDetector(this.toaster); + ['tap', 'mousedown', 'swipe'].forEach(function(evt) { + this.container.addEventListener(evt, this); + this.toaster.addEventListener(evt, this); + }, this); + + this.clearAllButton.addEventListener('click', this.clearAll.bind(this)); + + // will hold the count of external contributors to the notification + // screen + this.externalNotificationsCount = 0; + + window.addEventListener('utilitytrayshow', this); + window.addEventListener('unlock', this.clearLockScreen.bind(this)); + window.addEventListener('mozvisibilitychange', this); + window.addEventListener('appopen', this.handleAppopen.bind(this)); + + this._sound = 'style/notifications/ringtones/notifier_exclamation.ogg'; + + var self = this; + SettingsListener.observe('notification.ringtone', '', function(value) { + self._sound = value; + }); + }, + + handleEvent: function ns_handleEvent(evt) { + switch (evt.type) { + case 'mozChromeEvent': + var detail = evt.detail; + if (detail.type !== 'desktop-notification') + return; + + this.addNotification(detail); + break; + case 'tap': + var target = evt.target; + this.tap(target); + break; + case 'mousedown': + this.mousedown(evt); + break; + case 'swipe': + this.swipe(evt); + break; + case 'utilitytrayshow': + this.updateTimestamps(); + StatusBar.updateNotificationUnread(false); + break; + case 'mozvisibilitychange': + //update timestamps in lockscreen notifications + if (!document.mozHidden) { + this.updateTimestamps(); + } + break; + } + }, + + handleAppopen: function ns_handleAppopen(evt) { + var manifestURL = evt.detail.manifestURL, + selector = '[data-manifest-u-r-l="' + manifestURL + '"]'; + + var nodes = this.container.querySelectorAll(selector); + + for (var i = nodes.length - 1; i >= 0; i--) { + this.closeNotification(nodes[i]); + } + }, + + // Swipe handling + mousedown: function ns_mousedown(evt) { + if (!evt.target.dataset.notificationID) + return; + + evt.preventDefault(); + this._notification = evt.target; + this._containerWidth = this.container.clientWidth; + }, + + swipe: function ns_swipe(evt) { + var detail = evt.detail; + var distance = detail.start.screenX - detail.end.screenX; + var fastEnough = Math.abs(detail.vx) > this.TRANSITION_SPEED; + var farEnough = Math.abs(distance) > + this._containerWidth * this.TRANSITION_FRACTION; + + // We only remove the notification if the swipe was + // - left to right + // - far or fast enough + if ((distance > 0) || + !(farEnough || fastEnough)) { + // Werent far or fast enough to delete, restore + delete this._notification; + return; + } + + this._notification.classList.add('disappearing'); + + var notification = this._notification; + this._notification = null; + + var toaster = this.toaster; + var self = this; + notification.addEventListener('transitionend', function trListener() { + notification.removeEventListener('transitionend', trListener); + + self.closeNotification(notification); + + if (notification != toaster) + return; + + // Putting back the toaster in a clean state for the next notification + toaster.style.display = 'none'; + setTimeout(function nextLoop() { + toaster.style.MozTransition = ''; + toaster.style.MozTransform = ''; + toaster.classList.remove('displayed'); + toaster.classList.remove('disappearing'); + + setTimeout(function nextLoop() { + toaster.style.display = 'block'; + }); + }); + }); + }, + + tap: function ns_tap(notificationNode) { + var notificationID = notificationNode.dataset.notificationID; + + var event = document.createEvent('CustomEvent'); + event.initCustomEvent('mozContentEvent', true, true, { + type: 'desktop-notification-click', + id: notificationID + }); + window.dispatchEvent(event); + + this.removeNotification(notificationNode.dataset.notificationID, false); + + if (notificationNode == this.toaster) { + this.toaster.classList.remove('displayed'); + } else { + UtilityTray.hide(); + } + }, + + updateTimestamps: function ns_updateTimestamps() { + var timestamps = document.getElementsByClassName('timestamp'); + for (var i = 0, l = timestamps.length; i < l; i++) { + timestamps[i].textContent = + this.prettyDate(new Date(timestamps[i].dataset.timestamp)); + } + }, + + /** + * Display a human-readable relative timestamp. + */ + prettyDate: function prettyDate(time) { + var date; + if (navigator.mozL10n) { + date = navigator.mozL10n.DateTimeFormat().fromNow(time, true); + } else { + date = time.toLocaleFormat(); + } + return date; + }, + + addNotification: function ns_addNotification(detail) { + var notificationNode = document.createElement('div'); + notificationNode.className = 'notification'; + + notificationNode.dataset.notificationID = detail.id; + notificationNode.dataset.manifestURL = detail.manifestURL; + + if (detail.icon) { + var icon = document.createElement('img'); + icon.src = detail.icon; + notificationNode.appendChild(icon); + this.toasterIcon.src = detail.icon; + this.toasterIcon.hidden = false; + } else { + this.toasterIcon.hidden = true + } + + var time = document.createElement('span'); + var timestamp = new Date(); + time.classList.add('timestamp'); + time.dataset.timestamp = timestamp; + time.textContent = this.prettyDate(timestamp); + notificationNode.appendChild(time); + + var title = document.createElement('div'); + title.textContent = detail.title; + notificationNode.appendChild(title); + + this.toasterTitle.textContent = detail.title; + + var message = document.createElement('div'); + message.classList.add('detail'); + message.textContent = detail.text; + notificationNode.appendChild(message); + + this.toasterDetail.textContent = detail.text; + + this.container.insertBefore(notificationNode, + this.container.firstElementChild); + new GestureDetector(notificationNode).startDetecting(); + + // We turn the screen on if needed in order to let + // the user see the notification toaster + if (typeof(ScreenManager) !== 'undefined' && + !ScreenManager.screenEnabled) { + ScreenManager.turnScreenOn(); + } + + this.updateStatusBarIcon(true); + + // Notification toaster + if (this.lockscreenPreview || !LockScreen.locked) { + this.toaster.dataset.notificationID = detail.id; + + this.toaster.classList.add('displayed'); + this._toasterGD.startDetecting(); + + if (this._toasterTimeout) + clearTimeout(this._toasterTimeout); + + this._toasterTimeout = setTimeout((function() { + this.toaster.classList.remove('displayed'); + this._toasterTimeout = null; + this._toasterGD.stopDetecting(); + }).bind(this), this.TOASTER_TIMEOUT); + } + + // Adding it to the lockscreen if locked and the privacy setting + // does not prevent it. + if (typeof(LockScreen) !== 'undefined' && + LockScreen.locked && this.lockscreenPreview) { + var lockScreenNode = notificationNode.cloneNode(true); + this.lockScreenContainer.insertBefore(lockScreenNode, + this.lockScreenContainer.firstElementChild); + } + + if (this.alerts && !this.silent) { + var ringtonePlayer = new Audio(); + ringtonePlayer.src = this._sound; + ringtonePlayer.mozAudioChannelType = 'notification'; + ringtonePlayer.play(); + window.setTimeout(function smsRingtoneEnder() { + ringtonePlayer.pause(); + ringtonePlayer.src = ''; + }, 2000); + } + + if (this.vibrates) { + if (document.mozHidden) { + window.addEventListener('mozvisibilitychange', function waitOn() { + window.removeEventListener('mozvisibilitychange', waitOn); + navigator.vibrate([200, 200, 200, 200]); + }); + } else { + navigator.vibrate([200, 200, 200, 200]); + } + } + + return notificationNode; + }, + + closeNotification: function ns_closeNotification(notificationNode) { + var notificationID = notificationNode.dataset.notificationID; + + var event = document.createEvent('CustomEvent'); + event.initCustomEvent('mozContentEvent', true, true, { + type: 'desktop-notification-close', + id: notificationID + }); + window.dispatchEvent(event); + + this.removeNotification(notificationNode.dataset.notificationID); + }, + + removeNotification: function ns_removeNotification(notificationID) { + var notifSelector = '[data-notification-i-d="' + notificationID + '"]'; + var notificationNode = this.container.querySelector(notifSelector); + + notificationNode.parentNode.removeChild(notificationNode); + this.updateStatusBarIcon(); + }, + + clearAll: function ns_clearAll() { + while (this.container.firstElementChild) { + this.closeNotification(this.container.firstElementChild); + } + }, + + clearLockScreen: function ns_clearLockScreen() { + while (this.lockScreenContainer.firstElementChild) { + var element = this.lockScreenContainer.firstElementChild; + this.lockScreenContainer.removeChild(element); + } + }, + + updateStatusBarIcon: function ns_updateStatusBarIcon(unread) { + var nbTotalNotif = this.container.children.length + + this.externalNotificationsCount; + StatusBar.updateNotification(nbTotalNotif); + + if (unread) + StatusBar.updateNotificationUnread(true); + }, + + incExternalNotifications: function ns_incExternalNotifications() { + this.externalNotificationsCount++; + this.updateStatusBarIcon(true); + }, + + decExternalNotifications: function ns_decExternalNotifications() { + this.externalNotificationsCount--; + if (this.externalNotificationsCount < 0) { + this.externalNotificationsCount = 0; + } + this.updateStatusBarIcon(); + } + +}; + +NotificationScreen.init(); + +SettingsListener.observe( + 'lockscreen.notifications-preview.enabled', true, function(value) { + + NotificationScreen.lockscreenPreview = value; +}); + +SettingsListener.observe('alert-sound.enabled', true, function(value) { + NotificationScreen.alerts = value; +}); + +SettingsListener.observe('ring.enabled', true, function(value) { + NotificationScreen.silent = !value; +}); + +SettingsListener.observe('alert-vibration.enabled', true, function(value) { + NotificationScreen.vibrates = value; +}); diff --git a/apps/system/js/operator_variant/operator_variant.js b/apps/system/js/operator_variant/operator_variant.js new file mode 100644 index 0000000..13ad257 --- /dev/null +++ b/apps/system/js/operator_variant/operator_variant.js @@ -0,0 +1,168 @@ +/* -*- Mode: js; js-indent-level: 2; indent-tabs-mode: nil -*- */ +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +(function OperatorVariant() { + /** + * Get the mcc/mnc info that has been stored in the settings. + */ + + var settings = window.navigator.mozSettings; + if (!settings) + return; + + var iccSettings = { mcc: -1, mnc: -1 }; + + // Read the mcc/mnc settings, then trigger callback. + function getICCSettings(callback) { + var transaction = settings.createLock(); + var mccKey = 'operatorvariant.mcc'; + var mncKey = 'operatorvariant.mnc'; + + var mccRequest = transaction.get(mccKey); + mccRequest.onsuccess = function() { + iccSettings.mcc = parseInt(mccRequest.result[mccKey], 10) || 0; + var mncRequest = transaction.get(mncKey); + mncRequest.onsuccess = function() { + iccSettings.mnc = parseInt(mncRequest.result[mncKey], 10) || 0; + callback(); + }; + }; + } + + + /** + * Compare the cached mcc/mnc info with the one in the SIM card, + * and retrieve/apply APN settings if they differ. + */ + + var mobileConnection = window.navigator.mozMobileConnection; + if (!mobileConnection) + return; + + // Check the mcc/mnc information on the SIM card. + function checkICCInfo() { + if (!mobileConnection.iccInfo || mobileConnection.cardState !== 'ready') + return; + + // ensure that the iccSettings have been retrieved + if ((iccSettings.mcc < 0) || (iccSettings.mnc < 0)) + return; + + // XXX sometimes we get 0/0 for mcc/mnc, even when cardState === 'ready'... + var mcc = parseInt(mobileConnection.iccInfo.mcc, 10) || 0; + var mnc = parseInt(mobileConnection.iccInfo.mnc, 10) || 0; + if (!mcc || !mnc) + return; + + // same SIM card => do nothing + if ((mcc == iccSettings.mcc) && (mnc == iccSettings.mnc)) + return; + + // new SIM card => cache iccInfo, load and apply new APN settings + iccSettings.mcc = mcc; + iccSettings.mnc = mnc; + retrieveOperatorVariantSettings(applyOperatorVariantSettings); + }; + + // Load and query APN database, then trigger callback on results. + function retrieveOperatorVariantSettings(callback) { + var OPERATOR_VARIANT_FILE = 'shared/resources/apn.json'; + + var xhr = new XMLHttpRequest(); + xhr.open('GET', OPERATOR_VARIANT_FILE, true); + xhr.responseType = 'json'; + xhr.onreadystatechange = function() { + if (xhr.readyState == 4 && (xhr.status == 200 || xhr.status === 0)) { + var apn = xhr.response; + var mcc = iccSettings.mcc; + var mnc = iccSettings.mnc; + // get a list of matching APNs + var compatibleAPN = apn[mcc] ? (apn[mcc][mnc] || []) : []; + callback(compatibleAPN); + } + }; + xhr.send(); + } + + // Store APN settings for the first carrier matching the mcc/mnc info. + function applyOperatorVariantSettings(result) { + var apnPrefNames = { + 'default': { + 'ril.data.carrier': 'carrier', + 'ril.data.apn': 'apn', + 'ril.data.user': 'user', + 'ril.data.passwd': 'password', + 'ril.data.httpProxyHost': 'proxy', + 'ril.data.httpProxyPort': 'port' + }, + 'supl': { + 'ril.supl.carrier': 'carrier', + 'ril.supl.apn': 'apn', + 'ril.supl.user': 'user', + 'ril.supl.passwd': 'password', + 'ril.supl.httpProxyHost': 'proxy', + 'ril.supl.httpProxyPort': 'port' + }, + 'mms': { + 'ril.mms.carrier': 'carrier', + 'ril.mms.apn': 'apn', + 'ril.mms.user': 'user', + 'ril.mms.passwd': 'password', + 'ril.mms.httpProxyHost': 'proxy', + 'ril.mms.httpProxyPort': 'port', + 'ril.mms.mmsc': 'mmsc', + 'ril.mms.mmsproxy': 'mmsproxy', + 'ril.mms.mmsport': 'mmsport' + }, + 'operatorvariant': { + 'ril.iccInfo.mbdn': 'voicemail', + 'ril.sms.strict7BitEncoding.enabled': 'enableStrict7BitEncodingForSms', + 'ril.cellbroadcast.searchlist': 'cellBroadcastSearchList' + } + }; + + var booleanPrefNames = [ + 'ril.sms.strict7BitEncoding.enabled' + ]; + + // store relevant APN settings + var transaction = settings.createLock(); + for (var type in apnPrefNames) { + var apn = {}; + for (var i = 0; i < result.length; i++) { + if (result[i] && result[i].type.indexOf(type) != -1) { + apn = result[i]; + break; + } + } + var prefNames = apnPrefNames[type]; + for (var key in prefNames) { + var name = apnPrefNames[type][key]; + var item = {}; + if (booleanPrefNames.indexOf(key) != -1) { + item[key] = apn[name] || false; + } else { + item[key] = apn[name] || ''; + } + transaction.set(item); + } + } + + // store the current mcc/mnc info in the settings + transaction.set({ + 'operatorvariant.mcc': iccSettings.mcc, + 'operatorvariant.mnc': iccSettings.mnc + }); + } + + + /** + * Check the APN settings on startup and when the SIM card is changed. + */ + + getICCSettings(checkICCInfo); + mobileConnection.addEventListener('iccinfochange', checkICCInfo); +})(); + diff --git a/apps/system/js/payment.js b/apps/system/js/payment.js new file mode 100644 index 0000000..a953f5c --- /dev/null +++ b/apps/system/js/payment.js @@ -0,0 +1,151 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +// TODO: Blocked by [Payment] UX and visuals for the payment request +// confirmation screen https://github.com/mozilla-b2g/gaia/issues/2692 + +'use strict'; + +const kPaymentConfirmationScreen = '../payment.html'; + +var Payment = { + chromeEventId: null, + trustedUILayers: {}, + + init: function init() { + window.addEventListener('mozChromeEvent', this); + }, + + handleEvent: function onMozChromeEvent(e) { + // We save the mozChromeEvent identifiers to send replies back from content + // with this exact value. + this.chromeEventId = e.detail.id; + if (!this.chromeEventId) + return; + + var requestId = e.detail.requestId; + + switch (e.detail.type) { + // Chrome asks Gaia to show the payment request confirmation dialog. + case 'open-payment-confirmation-dialog': + var requests = e.detail.paymentRequests; + if (!requests) + return; + + var returnSelection = (function returnSelection(selection) { + if (!selection) + return; + + this._dispatchEvent({ + id: this.chromeEventId, + userSelection: selection + }); + }).bind(this); + + // If there is only one request, we skip the confirmation dialog and + // send the request type back to the chrome as a user selection, so + // the payment flow can continue. + if (requests.length == 1) { + returnSelection(requests[0].type); + return; + } + + var frame = document.createElement('iframe'); + frame.setAttribute('mozbrowser', 'true'); + frame.setAttribute('remote', true); + frame.classList.add('screen'); + frame.src = kPaymentConfirmationScreen; + frame.addEventListener('mozbrowserloadend', function addReqs(evt) { + var frame = evt.target; + if (!frame || !requests) + return; + + // TODO: Temp layout until issue #2692 is solved. + var frameDocument = frame.contentWindow.document; + var requestsList = frameDocument.getElementById('requests') + .getElementsByTagName('ul')[0]; + for (var i in requests) { + var requestElement = frameDocument.createElement('li'); + var button = frameDocument.createElement('button'); + button.setAttribute('value', requests[i].type); + var requestText = 'Pay with ' + requests[i].providerName + '\n' + + requests[i].productName + '\n' + + requests[i].productDescription + '\n' + + requests[i].productPrice[0].amount + ' ' + + requests[i].productPrice[0].currency; + button.appendChild(frameDocument.createTextNode(requestText)); + button.onclick = function selectRequest() { + // We send the selected request back to Chrome so it can start + // the appropriate payment flow. + returnSelection(this.getAttribute('value')); + }; + requestElement.appendChild(button); + requestsList.appendChild(requestElement); + } + }); + + this._openTrustedUI(frame); + break; + + // Chrome asks Gaia to show the payment flow according to the + // payment request selected by the user. + case 'open-payment-flow-dialog': + if (!e.detail.uri) + return; + + // TODO: For now, known payment providers (BlueVia and Mozilla Market) + // only accepts the JWT by GET, so we just add it to the URI. + e.detail.uri += e.detail.jwt; + + this.trustedUILayers[requestId] = this.chromeEventId; + + var frame = document.createElement('iframe'); + frame.setAttribute('mozbrowser', 'true'); + frame.classList.add('screen'); + frame.src = e.detail.uri; + frame.addEventListener('mozbrowserloadstart', (function loadStart(evt) { + // After creating the new frame containing the payment provider buy + // flow, we send it back to chrome so the payment callbacks can be + // injected. + this._dispatchEvent({ + id: this.chromeEventId, + frame: evt.target + }); + }).bind(this)); + + // The payment flow is shown within the trusted UI + this._openTrustedUI(frame); + break; + + case 'close-payment-flow-dialog': + TrustedUIManager.close(this.trustedUILayers[requestId], + (function dialogClosed() { + this._dispatchEvent({ id: this.chromeEventId }); + delete this.trustedUILayers[requestId]; + }).bind(this)); + break; + } + }, + + _openTrustedUI: function _openTrustedUI(frame) { + // The payment flow is shown within the trusted UI with the name of + // the mozPay caller application as title. + var title = WindowManager.getCurrentDisplayedApp().name; + title = title ? title : navigator.mozL10n.get('payment-flow'); + TrustedUIManager.open(title, frame, this.chromeEventId); + }, + + _dispatchEvent: function _dispatchEvent(obj) { + var event = document.createEvent('CustomEvent'); + event.initCustomEvent('mozContentEvent', true, true, obj); + window.dispatchEvent(event); + } +}; + +// Make sure L10n is ready before init +if (navigator.mozL10n.readyState == 'complete' || + navigator.mozL10n.readyState == 'interactive') { + Payment.init(); +} else { + window.addEventListener('localized', Payment.init.bind(Payment)); +} diff --git a/apps/system/js/permission_manager.js b/apps/system/js/permission_manager.js new file mode 100644 index 0000000..f3c013c --- /dev/null +++ b/apps/system/js/permission_manager.js @@ -0,0 +1,203 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +var PermissionManager = (function() { + var _ = navigator.mozL10n.get; + + window.addEventListener('mozChromeEvent', function pm_chromeEventHandler(e) { + var detail = e.detail; + switch (detail.type) { + case 'permission-prompt': + overlay.dataset.type = detail.permission; + handlePermissionPrompt(detail); + break; + case 'fullscreenoriginchange': + delete overlay.dataset.type; + handleFullscreenOriginChange(detail); + break; + } + }); + + var fullscreenRequest = undefined; + + var handleFullscreenOriginChange = function(detail) { + // If there's already a fullscreen request visible, cancel it, + // we'll show the request for the new domain. + if (fullscreenRequest != undefined) { + cancelRequest(fullscreenRequest); + fullscreenRequest = undefined; + } + if (detail.fullscreenorigin != WindowManager.getDisplayedApp()) { + // The message to be displayed on the approval UI. + var message = _('fullscreen-request', { 'origin': detail.fullscreenorigin }); + fullscreenRequest = requestPermission(message, + /* yesCallback */ null, + /* noCallback */ function() { + document.mozCancelFullScreen(); + }); + } + }; + + var handlePermissionPrompt = function pm_handlePermissionPrompt(detail) { + remember.checked = detail.remember ? true : false; + var str = ''; + + var permissionID = 'perm-' + detail.permission.replace(':', '-'); + if (detail.isApp) { // App + str = _(permissionID + '-appRequest', { 'app': detail.appName }); + } else { // Web content + str = _(permissionID + '-webRequest', { 'site': detail.origin }); + } + + requestPermission(str, function pm_permYesCB() { + dispatchResponse(detail.id, 'permission-allow', remember.checked); + }, function pm_permNoCB() { + dispatchResponse(detail.id, 'permission-deny', remember.checked); + }); + }; + + var dispatchResponse = function pm_dispatchResponse(id, type, remember) { + var event = document.createEvent('CustomEvent'); + remember = remember ? true : false; + + event.initCustomEvent('mozContentEvent', true, true, { + id: id, + type: type, + remember: remember + }); + window.dispatchEvent(event); + }; + + // A queue of pending requests. Callers of requestPermission() must be + // careful not to create an infinite loop! + var pending = []; + + // Div over in which the permission UI resides. + var overlay = document.getElementById('permission-screen'); + var dialog = document.getElementById('permission-dialog'); + var message = document.getElementById('permission-message'); + + // "Yes"/"No" buttons on the permission UI. + var yes = document.getElementById('permission-yes'); + var no = document.getElementById('permission-no'); + + // Remember the choice checkbox + var remember = document.getElementById('permission-remember-checkbox'); + var rememberSection = document.getElementById('permission-remember-section'); + + // The ID of the next permission request. This is incremented by one + // on every request, modulo some large number to prevent overflow problems. + var nextRequestID = 0; + + // The ID of the request currently visible on the screen. This has the value + // "undefined" when there is no request visible on the screen. + var currentRequestId = undefined; + + var hidePermissionPrompt = function() { + overlay.classList.remove('visible'); + currentRequestId = undefined; + // Cleanup the event handlers. + yes.removeEventListener('click', clickHandler); + yes.callback = null; + no.removeEventListener('click', clickHandler); + no.callback = null; + }; + + // Show the next request, if we have one. + var showNextPendingRequest = function() { + if (pending.length == 0) + return; + var request = pending.shift(); + showPermissionPrompt(request.id, + request.message, + request.yescallback, + request.nocallback); + }; + + // This is the event listener function for the yes/no buttons. + var clickHandler = function(evt) { + var callback = null; + if (evt.target === yes && yes.callback) { + callback = yes.callback; + } else if (evt.target === no && no.callback) { + callback = no.callback; + } + hidePermissionPrompt(); + + // Call the appropriate callback, if it is defined. + if (callback) + window.setTimeout(callback, 0); + + showNextPendingRequest(); + }; + + var requestPermission = function(msg, + yescallback, nocallback) { + var id = nextRequestID; + nextRequestID = (nextRequestID + 1) % 1000000; + + if (currentRequestId != undefined) { + // There is already a permission request being shown, queue this one. + pending.push({ + id: id, + message: msg, + yescallback: yescallback, + nocallback: nocallback + }); + return id; + } + + showPermissionPrompt(id, msg, yescallback, nocallback); + + return id; + }; + + var showPermissionPrompt = function(id, msg, + yescallback, nocallback) { + // Put the message in the dialog. + // Note plain text since this may include text from + // untrusted app manifests, for example. + message.textContent = msg; + + currentRequestId = id; + + // Make the screen visible + overlay.classList.add('visible'); + + // Set event listeners for the yes and no buttons + yes.addEventListener('click', clickHandler); + yes.callback = yescallback; + + no.addEventListener('click', clickHandler); + no.callback = nocallback; + }; + + // Cancels a request with a specfied id. Request can either be + // currently showing, or pending. If there are further pending requests, + // the next is shown. + var cancelRequest = function(id) { + if (currentRequestId === id) { + // Request is currently being displayed. Hide the permission prompt, + // and show the next request, if we have any. + hidePermissionPrompt(); + showNextPendingRequest(); + } else { + // The request is currently not being displayed. Search through the + // list of pending requests, and remove it from the list if present. + for (var i = 0; i < pending.length; i++) { + if (pending[i].id === id) { + pending.splice(i, 1); + break; + } + } + } + }; + + rememberSection.addEventListener('click', function onLabelClick() { + remember.checked = !remember.checked; + }); + +}()); + diff --git a/apps/system/js/popup_manager.js b/apps/system/js/popup_manager.js new file mode 100644 index 0000000..583c9c8 --- /dev/null +++ b/apps/system/js/popup_manager.js @@ -0,0 +1,313 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ +'use strict'; + +var PopupManager = { + _currentPopup: {}, + _currentOrigin: '', + _endTimes: 0, + _startTimes: 0, + + throbber: document.getElementById('popup-throbber'), + + overlay: document.getElementById('dialog-overlay'), + + popupContainer: document.getElementById('popup-container'), + + container: document.getElementById('frame-container'), + + screen: document.getElementById('screen'), + + closeButton: document.getElementById('popup-close'), + + errorTitle: document.getElementById('popup-error-title'), + + errorMessage: document.getElementById('popup-error-message'), + + errorReload: document.getElementById('popup-error-reload'), + + errorBack: document.getElementById('popup-error-back'), + + init: function pm_init() { + this.title = document.getElementById('popup-title'); + window.addEventListener('mozbrowseropenwindow', this); + window.addEventListener('mozbrowserclose', this); + window.addEventListener('appwillclose', this); + window.addEventListener('appopen', this); + window.addEventListener('appterminated', this); + window.addEventListener('home', this); + window.addEventListener('keyboardhide', this); + window.addEventListener('keyboardchange', this); + this.closeButton.addEventListener('click', this); + this.errorReload.addEventListener('click', this); + this.errorBack.addEventListener('click', this); + }, + + open: function pm_open(frame, origin) { + // Only one popup per origin at a time. + // If the popup is being shown, we swap frames. + if (this._currentPopup[origin]) { + this.container.removeChild(this._currentPopup[origin]); + delete this._currentPopup[origin]; + } + + this.title.textContent = this.getTitleFromUrl(frame.dataset.url); + + // Reset overlay height + this.setHeight(window.innerHeight - StatusBar.height); + + this._currentPopup[origin] = frame; + + var popup = this._currentPopup[origin]; + var dataset = popup.dataset; + dataset.frameType = 'popup'; + dataset.frameName = name; + dataset.frameOrigin = origin; + + // this seems needed, or an override to origin in close() + this._currentOrigin = origin; + + this.container.appendChild(popup); + + this.screen.classList.add('popup'); + + popup.addEventListener('mozbrowsererror', this); + popup.addEventListener('mozbrowserloadend', this); + popup.addEventListener('mozbrowserloadstart', this); + popup.addEventListener('mozbrowserlocationchange', this); + }, + + close: function pm_close(evt) { + if (evt && (!'frameType' in evt.target.dataset || + evt.target.dataset.frameType !== 'popup')) + return; + + var self = this; + this.popupContainer.addEventListener('transitionend', function wait(event) { + self.popupContainer.removeEventListener('transitionend', wait); + self.screen.classList.remove('popup'); + self.popupContainer.classList.remove('disappearing'); + self.container.removeChild(self._currentPopup[self._currentOrigin]); + delete self._currentPopup[self._currentOrigin]; + }); + + this.popupContainer.classList.add('disappearing'); + + // We just removed the focused window leaving the system + // without any focused window, let's fix this. + window.focus(); + }, + + backHandling: function pm_backHandling() { + if (!this._currentPopup[this._currentOrigin]) + return; + + this.close(); + }, + + isVisible: function pm_isVisible() { + return (this._currentPopup[this._currentOrigin] != null); + }, + + setHeight: function pm_setHeight(height) { + if (this.isVisible()) + this.overlay.style.height = height + 'px'; + }, + + handleEvent: function pm_handleEvent(evt) { + switch (evt.type) { + case 'click': + switch (evt.target) { + case this.closeButton: + this.backHandling(); + break; + + case this.errorBack: + this.backHandling(); + break; + + case this.errorReload: + this.container.classList.remove('error'); + delete this._currentPopup[this._currentOrigin].dataset.error; + this._currentPopup[this._currentOrigin].reload(true); + break; + } + break; + + case 'mozbrowserloadstart': + this.throbber.classList.add('loading'); + break; + + case 'mozbrowserloadend': + this.throbber.classList.remove('loading'); + break; + + case 'mozbrowserlocationchange': + evt.target.dataset.url = evt.detail; + this.show(); + break; + + case 'mozbrowsererror': + this._currentPopup[evt.target.dataset.frameOrigin].dataset.error = true; + this.showError(); + break; + + case 'mozbrowseropenwindow': + var detail = evt.detail; + var openerType = evt.target.dataset.frameType; + var openerOrigin = evt.target.dataset.frameOrigin; + + // Only app frame is allowed to launch popup + if (openerType !== 'window') + return; + + // <a href="" target="_blank"> links should opened outside the app + // itself and fire an activity to be opened into a new browser window. + if (detail.name === '_blank') { + new MozActivity({ name: 'view', + data: { type: 'url', url: detail.url }}); + return; + } + + this.throbber.classList.remove('loading'); + + var frame = detail.frameElement; + frame.dataset.url = detail.url; + + this.container.classList.remove('error'); + this.open(frame, openerOrigin); + + break; + + case 'mozbrowserclose': + this.close(evt); + break; + + case 'home': + // Reset overlay height before hiding + this.setHeight(window.innerHeight - StatusBar.height); + this.hide(this._currentOrigin); + break; + + case 'appwillclose': + if (!this._currentPopup[evt.detail.origin]) + return; + + this.hide(evt.detail.origin); + break; + + case 'appopen': + this._currentOrigin = evt.detail.origin; + this.show(); + break; + + case 'appterminated': + if (!this._currentPopup[evt.detail.origin]) + return; + this.close(evt.detail.origin); + break; + + case 'keyboardchange': + this.setHeight(window.innerHeight - + StatusBar.height - evt.detail.height); + break; + + case 'keyboardhide': + this.setHeight(window.innerHeight - StatusBar.height); + break; + } + }, + + showError: function pm_showError() { + if (!('error' in this._currentPopup[this._currentOrigin].dataset)) { + this.container.classList.remove('error'); + return; + } + + var contentOrigin = + this.getTitleFromUrl(this._currentPopup[this._currentOrigin].dataset.url); + var _ = navigator.mozL10n.get; + + if (AirplaneMode.enabled) { + this.errorTitle.textContent = _('airplane-is-on'); + this.errorMessage.textContent = _('airplane-is-turned-on', + {name: contentOrigin}); + } else if (!navigator.onLine) { + this.errorTitle.textContent = _('network-connection-unavailable'); + this.errorMessage.textContent = _('network-error', {name: contentOrigin}); + } else { + this.errorTitle.textContent = _('error-title', {name: contentOrigin}); + this.errorMessage.textContent = _('error-message', {name: contentOrigin}); + } + this.container.classList.add('error'); + }, + + // This is for card view to request + // Return nothing if the content is the same origin as opener + // Return URL if the content is off-origin + getOpenedOriginFromOpener: function pm_getOpenedOriginOpener(origin) { + var opened = this._getOriginObject(this._currentPopup[origin].dataset.url); + var opener = this._getOriginObject(origin); + // Same origin means: Protocol, Domain, Port + if (opened.protocol == opener.protocol && + opened.hostname == opener.hostname && + opened.port == opener.port) { + return ''; + } else { + return opened.protocol + '//' + opened.hostname; + } + }, + + getTitleFromUrl: function pm_getTitleFromUrl(url) { + var app = WindowManager.getCurrentDisplayedApp(); + var opened = this._getOriginObject(url); + var opener = this._getOriginObject(app.frame.dataset.frameOrigin); + // Same origin means: Protocol, Domain, Port + if (opened.protocol == opener.protocol && + opened.hostname == opener.hostname && + opened.port == opener.port) { + return app.name; + } else { + return opened.protocol + '//' + opened.hostname; + } + }, + + _getOriginObject: function pm__getOriginObject(url) { + var parser = document.createElement('a'); + parser.href = url; + + return { + protocol: parser.protocol, + hostname: parser.hostname, + port: parser.port + }; + }, + + getPopupFromOrigin: function pm_getPopupFromOrigin(origin) { + return this._currentPopup[origin]; + }, + + show: function pm_show() { + if (!this._currentPopup[this._currentOrigin]) + return; + + + this.showError(); + this.screen.classList.add('popup'); + + var popup = this._currentPopup[this._currentOrigin]; + this.title.textContent = this.getTitleFromUrl(popup.dataset.url); + popup.hidden = false; + }, + + hide: function pm_hide(origin) { + if (!this._currentPopup[origin]) + return; + + this.screen.classList.remove('popup'); + this._currentPopup[origin].hidden = true; + } +}; + +PopupManager.init(); + diff --git a/apps/system/js/quick_settings.js b/apps/system/js/quick_settings.js new file mode 100644 index 0000000..328bb35 --- /dev/null +++ b/apps/system/js/quick_settings.js @@ -0,0 +1,279 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +var QuickSettings = { + // Indicate setting status of geolocation.enabled + geolocationEnabled: false, + WIFI_STATUSCHANGE_TIMEOUT: 2000, + + init: function qs_init() { + var settings = window.navigator.mozSettings; + var conn = window.navigator.mozMobileConnection; + if (!settings || !conn) + return; + + this.getAllElements(); + + this.overlay.addEventListener('click', this); + window.addEventListener('utilitytrayshow', this); + + var self = this; + + /* + * Monitor data network icon + */ + conn.addEventListener('datachange', function qs_onDataChange() { + var label = { + 'lte': '4G', // 4G LTE + 'ehrpd': '4G', // 4G CDMA + 'hspa+': 'H+', // 3.5G HSPA+ + 'hsdpa': 'H', 'hsupa': 'H', 'hspa': 'H', // 3.5G HSDPA + 'evdo0': '3G', 'evdoa': '3G', 'evdob': '3G', '1xrtt': '3G', // 3G CDMA + 'umts': '3G', // 3G + 'edge': 'E', // EDGE + 'is95a': '2G', 'is95b': '2G', // 2G CDMA + 'gprs': '2G' + }; + self.data.dataset.network = label[conn.data.type]; + }); + + /* monitor data setting + * TODO prevent quickly tapping on it + */ + SettingsListener.observe('ril.data.enabled', true, function(value) { + if (value) { + self.data.dataset.enabled = 'true'; + } else { + delete self.data.dataset.enabled; + } + }); + + /* monitor bluetooth setting and initialization/disable ready event + * - when settings changed, update UI and lock toogle to prevent quickly + * tapping on it. + * - when got bluetooth initialization/disable ready, active toogle, so + * return the control to user. + */ + var btFirstSet = true; + SettingsListener.observe('bluetooth.enabled', true, function(value) { + // check self.bluetooth.dataset.enabled and value are identical + if ((self.bluetooth.dataset.enabled && value) || + (self.bluetooth.dataset.enabled === undefined && !value)) + return; + + if (value) { + self.bluetooth.dataset.enabled = 'true'; + } else { + delete self.bluetooth.dataset.enabled; + } + + // Set to the initializing state to block user interaction until the + // operation completes. (unless we are being called for the first time, + // where Bluetooth is already initialize + if (!btFirstSet) + self.bluetooth.dataset.initializing = 'true'; + btFirstSet = false; + }); + window.addEventListener('bluetooth-adapter-added', this); + window.addEventListener('bluetooth-disabled', this); + + + /* monitor wifi setting and initialization/disable ready event + * - when settings changed, update UI and lock toogle to prevent quickly + * tapping on it. + * - when got bluetooth initialization/disable ready, active toogle, so + * return the control to user. + */ + var wifiFirstSet = true; + SettingsListener.observe('wifi.enabled', true, function(value) { + // check self.wifi.dataset.enabled and value are identical + if ((self.wifi.dataset.enabled && value) || + (self.wifi.dataset.enabled === undefined && !value)) + return; + + if (value) { + self.wifi.dataset.enabled = 'true'; + } else { + delete self.wifi.dataset.enabled; + } + // Set to the initializing state to block user interaction until the + // operation completes. (unless we are being called for the first time, + // where Wifi is already initialize + if (!wifiFirstSet) + self.wifi.dataset.initializing = 'true'; + wifiFirstSet = false; + }); + window.addEventListener('wifi-enabled', this); + window.addEventListener('wifi-disabled', this); + window.addEventListener('wifi-statuschange', this); + + /* monitor geolocation setting + * TODO prevent quickly tapping on it + */ + SettingsListener.observe('geolocation.enabled', true, function(value) { + self.geolocationEnabled = value; + }); + + // monitor airplane mode + SettingsListener.observe('ril.radio.disabled', false, function(value) { + self.data.dataset.airplaneMode = value; + if (value) { + self.data.classList.add('quick-settings-airplane-mode'); + self.airplaneMode.dataset.enabled = 'true'; + } else { + self.data.classList.remove('quick-settings-airplane-mode'); + delete self.airplaneMode.dataset.enabled; + } + }); + }, + + handleEvent: function qs_handleEvent(evt) { + evt.preventDefault(); + switch (evt.type) { + case 'click': + switch (evt.target) { + case this.wifi: + // do nothing if wifi isn't ready + if (this.wifi.dataset.initializing) + return; + var enabled = !!this.wifi.dataset.enabled; + SettingsListener.getSettingsLock().set({ + 'wifi.enabled': !enabled + }); + if (!enabled) + this.toggleAutoConfigWifi = true; + break; + + case this.data: + if (this.data.dataset.airplaneMode !== 'true') { + // TODO should ignore the action if data initialization isn't done + var enabled = !!this.data.dataset.enabled; + + SettingsListener.getSettingsLock().set({ + 'ril.data.enabled': !enabled + }); + } + + break; + + case this.bluetooth: + // do nothing if bluetooth isn't ready + if (this.bluetooth.dataset.initializing) + return; + + var enabled = !!this.bluetooth.dataset.enabled; + SettingsListener.getSettingsLock().set({ + 'bluetooth.enabled': !enabled + }); + break; + + case this.airplaneMode: + var enabled = !!this.airplaneMode.dataset.enabled; + SettingsListener.getSettingsLock().set({ + 'ril.radio.disabled': !enabled + }); + break; + + case this.fullApp: + // XXX: This should be replaced probably by Web Activities + var host = document.location.host; + var domain = host.replace(/(^[\w\d]+\.)?([\w\d]+\.[a-z]+)/, '$2'); + var protocol = document.location.protocol + '//'; + Applications.getByManifestURL(protocol + 'settings.' + + domain + '/manifest.webapp').launch(); + + UtilityTray.hide(); + break; + } + break; + + case 'utilitytrayshow': + break; + + // unlock bluetooth toggle + case 'bluetooth-adapter-added': + case 'bluetooth-disabled': + delete this.bluetooth.dataset.initializing; + break; + // unlock wifi toggle + case 'wifi-enabled': + delete this.wifi.dataset.initializing; + if (this.toggleAutoConfigWifi) { + // Check whether it found a wifi to connect after a timeout. + this.wifiStatusTimer = setTimeout(this.autoConfigWifi.bind(this), + this.WIFI_STATUSCHANGE_TIMEOUT); + } + break; + case 'wifi-disabled': + delete this.wifi.dataset.initializing; + if (this.toggleAutoConfigWifi) { + clearTimeout(this.wifiStatusTimer); + this.wifiStatusTimer = null; + this.toggleAutoConfigWifi = false; + } + break; + + case 'wifi-statuschange': + if (this.toggleAutoConfigWifi && !this.wifi.dataset.initializing) + this.autoConfigWifi(); + break; + } + }, + + getAllElements: function qs_getAllElements() { + // ID of elements to create references + var elements = ['wifi', 'data', 'bluetooth', 'airplane-mode', 'full-app']; + + var toCamelCase = function toCamelCase(str) { + return str.replace(/\-(.)/g, function replacer(str, p1) { + return p1.toUpperCase(); + }); + }; + + elements.forEach(function createElementRef(name) { + this[toCamelCase(name)] = + document.getElementById('quick-settings-' + name); + }, this); + + this.overlay = document.getElementById('quick-settings'); + }, + + // XXX Break down obj keys in a for each loop because mozSettings + // does not currently supports multiple keys in one set() + // https://bugzilla.mozilla.org/show_bug.cgi?id=779381 + setMozSettings: function qs_setter(keypairs) { + var setlock = SettingsListener.getSettingsLock(); + for (var key in keypairs) { + var obj = {}; + obj[key] = keypairs[key]; + setlock.set(obj); + } + }, + + /* Auto-config wifi if user enabled wifi from quick settings bar. + * If there are no known networks around, wifi settings page + * will be opened. Otherwise nothing will be done. + */ + autoConfigWifi: function qs_autoConfigWifi() { + clearTimeout(this.wifiStatusTimer); + this.wifiStatusTimer = null; + this.toggleAutoConfigWifi = false; + + var wifiManager = window.navigator.mozWifiManager; + var status = wifiManager.connection.status; + + if (status == 'disconnected') { + var activity = new MozActivity({ + name: 'configure', + data: { + target: 'device', + section: 'wifi' + } + }); + } + } +}; + +QuickSettings.init(); diff --git a/apps/system/js/remote_debugger.js b/apps/system/js/remote_debugger.js new file mode 100644 index 0000000..51544bb --- /dev/null +++ b/apps/system/js/remote_debugger.js @@ -0,0 +1,41 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +var RemoteDebugger = (function() { + + return { + init: function() { + window.addEventListener('mozChromeEvent', this); + }, + + handleEvent: function onMozChromeEvent(e) { + if (e.detail.type !== 'remote-debugger-prompt') { + return; + } + + // Reusing the ModalDialog infrastructure. + ModalDialog.showWithPseudoEvent({ + text: navigator.mozL10n.get('remoteDebuggerMessage'), + type: 'confirm', + callback: function() { + RemoteDebugger._dispatchEvent(true); + }, + cancel: function() { + RemoteDebugger._dispatchEvent(false); + } + }); + }, + + _dispatchEvent: function su_dispatchEvent(value) { + var event = document.createEvent('CustomEvent'); + event.initCustomEvent('mozContentEvent', true, true, + { type: 'remote-debugger-prompt', + value: value }); + window.dispatchEvent(event); + } + }; +})(); + +RemoteDebugger.init(); diff --git a/apps/system/js/screen_manager.js b/apps/system/js/screen_manager.js new file mode 100644 index 0000000..9a69e8c --- /dev/null +++ b/apps/system/js/screen_manager.js @@ -0,0 +1,503 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +var ScreenManager = { + /* + * return the current screen status + * Must not mutate directly - use toggleScreen/turnScreenOff/turnScreenOn. + * Listen to 'screenchange' event to properly handle status changes + * This value can be "out of sync" with real mozPower value, + * we do this to give screen some time to flash before actual turn off. + */ + screenEnabled: false, + + /* + * before idle-screen-off, invoke a nice dimming to the brightness + * to notify the user that the screen is about to be turn off. + * The user can cancel the idle-screen-off by touching the screen + * and by pressing a button (trigger onactive callback on Idle API) + * + */ + _inTransition: false, + + /* + * Whether the wake lock is enabled or not + */ + _screenWakeLocked: false, + + /* + * Whether the device light is enabled or not + * sync with setting 'screen.automatic-brightness' + */ + _deviceLightEnabled: true, + + /* + * Preferred brightness without considering device light nor dimming + * sync with setting 'screen.brightness' + */ + _userBrightness: 1, + _savedBrightness: 1, + + /* + * The auto-brightness algorithm will never set the screen brightness + * to a value smaller than this. 0.1 seems like a good screen brightness + * in a completely dark room on a Unagi. + */ + AUTO_BRIGHTNESS_MINIMUM: 0.1, + + /* + * This constant is used in the auto brightness algorithm. We take + * the base 10 logarithm of the incoming lux value from the light + * sensor and multiplied it by this constant. That value is used to + * compute a weighted average with the current brightness and + * finally that average brightess is and then clamped to the range + * [AUTO_BRIGHTNESS_MINIMUM, 1.0]. + * + * Making this value larger will increase the brightness for a given + * ambient light level. At a value of about .25, the screen will be + * at full brightness in sunlight or in a well-lighted work area. + * At a value of about .3, the screen will typically be at maximum + * brightness in outdoor daylight conditions, even when overcast. + */ + AUTO_BRIGHTNESS_CONSTANT: .27, + + /* + * When we change brightness we animate it smoothly. + * This constant is the number of milliseconds between adjustments + */ + BRIGHTNESS_ADJUST_INTERVAL: 20, + + /* + * When brightening or dimming the screen, this is how much we adjust + * the brightness value at a time. + */ + BRIGHTNESS_ADJUST_STEP: 0.04, + + /* + * Wait for _dimNotice milliseconds during idle-screen-off + */ + _dimNotice: 10 * 1000, + + /* + * We track the value of the idle timeout pref in this variable. + */ + _idleTimeout: 0, + _idleTimerId: 0, + + /* + * If the screen off is triggered by promixity during phon call then + * we need wake it up while phone is ended. + */ + _screenOffByProximity: false, + + /* + * Request wakelock during in_call state. + * To ensure turnScreenOff by proximity event is protected by wakelock for + * early suspend only. + */ + _cpuWakeLock: null, + + init: function scm_init() { + window.addEventListener('sleep', this); + window.addEventListener('wake', this); + + this.screen = document.getElementById('screen'); + + var self = this; + var power = navigator.mozPower; + + if (power) { + power.addWakeLockListener(function scm_handleWakeLock(topic, state) { + switch (topic) { + case 'screen': + self._screenWakeLocked = (state == 'locked-foreground'); + + if (self._screenWakeLocked) + // Turn screen on if wake lock is acquire + self.turnScreenOn(); + self._reconfigScreenTimeout(); + break; + + case 'cpu': + power.cpuSleepAllowed = (state != 'locked-foreground' && + state != 'locked-background'); + break; + } + }); + } + + this._firstOn = false; + SettingsListener.observe('screen.timeout', 60, + function screenTimeoutChanged(value) { + if (typeof value !== 'number') + value = parseInt(value); + self._idleTimeout = value; + self._setIdleTimeout(self._idleTimeout); + + if (!self._firstOn) { + self._firstOn = true; + + // During boot up, the brightness was set by bootloader as 0.5, + // Let's set the API value to that so setScreenBrightness() can + // dim nicely to value set by user. + power.screenBrightness = 0.5; + + // Turn screen on with dim. + self.turnScreenOn(false); + } + }); + + SettingsListener.observe('screen.automatic-brightness', true, + function deviceLightSettingChanged(value) { + self.setDeviceLightEnabled(value); + }); + + SettingsListener.observe('screen.brightness', 1, + function brightnessSettingChanged(value) { + self._userBrightness = value; + self.setScreenBrightness(value, false); + }); + + var telephony = window.navigator.mozTelephony; + if (telephony) { + telephony.addEventListener('callschanged', this); + } + }, + + // + // Automatically adjust the screen brightness based on the ambient + // light (in lux) measured by the device light sensor + // + autoAdjustBrightness: function scm_adjustBrightness(lux) { + var currentBrightness = this._targetBrightness; + + if (lux < 1) // Can't take the log of 0 or negative numbers + lux = 1; + + var computedBrightness = + Math.log(lux) / Math.LN10 * this.AUTO_BRIGHTNESS_CONSTANT; + + var clampedBrightness = Math.max(this.AUTO_BRIGHTNESS_MINIMUM, + Math.min(1.0, computedBrightness)); + + // If nothing changed, we're done. + if (clampedBrightness === currentBrightness) + return; + + this.setScreenBrightness(clampedBrightness, false); + }, + + handleEvent: function scm_handleEvent(evt) { + switch (evt.type) { + case 'devicelight': + if (!this._deviceLightEnabled || !this.screenEnabled || + this._inTransition) + return; + this.autoAdjustBrightness(evt.value); + break; + + case 'sleep': + this.turnScreenOff(true); + break; + + case 'wake': + this.turnScreenOn(); + break; + + case 'userproximity': + this._screenOffByProximity = evt.near; + if (evt.near) { + this.turnScreenOff(true); + } else { + this.turnScreenOn(); + } + break; + + case 'callschanged': + var telephony = window.navigator.mozTelephony; + if (!telephony.calls.length) { + if (this._screenOffByProximity) { + this.turnScreenOn(); + } + + window.removeEventListener('userproximity', this); + this._screenOffByProximity = false; + + if (this._cpuWakeLock) { + this._cpuWakeLock.unlock(); + this._cpuWakeLock = null; + } + break; + } + + // If the _cpuWakeLock is already set we are in a multiple + // call setup, turning the screen on to let user see the + // notification. + if (this._cpuWakeLock) { + this.turnScreenOn(); + + break; + } + + // Enable the user proximity sensor once the call is connected. + var call = telephony.calls[0]; + call.addEventListener('statechange', this); + + break; + + case 'statechange': + var call = evt.target; + if (call.state !== 'connected') { + break; + } + + // The call is connected. Remove the statechange listener + // and enable the user proximity sensor. + call.removeEventListener('statechange', this); + + this._cpuWakeLock = navigator.requestWakeLock('cpu'); + window.addEventListener('userproximity', this); + break; + } + }, + + toggleScreen: function scm_toggleScreen() { + if (this.screenEnabled) { + this.turnScreenOff(); + } else { + this.turnScreenOn(); + } + }, + + turnScreenOff: function scm_turnScreenOff(instant) { + if (!this.screenEnabled) + return false; + + var self = this; + + // Remember the current screen brightness. We will restore it when + // we turn the screen back on. + self._savedBrightness = navigator.mozPower.screenBrightness; + + // Remove the cpuWakeLock if screen is not turned off by + // userproximity event. + if (!this._screenOffByProximity && this._cpuWakeLock) { + window.removeEventListener('userproximity', this); + this._cpuWakeLock.unlock(); + this._cpuWakeLock = null; + } + + var screenOff = function scm_screenOff() { + self._setIdleTimeout(0); + + window.removeEventListener('devicelight', self); + + self.screenEnabled = false; + self._inTransition = false; + self.screen.classList.add('screenoff'); + setTimeout(function realScreenOff() { + self.setScreenBrightness(0, true); + // Sometimes the ScreenManager.screenEnabled and mozPower.screenEnabled + // values are out of sync. Since the rest of the world relies only on + // the value of ScreenManager.screenEnabled it can be some situations + // where the screen is off but ScreenManager think it is on... (see + // bug 822463). Ideally a callback should have been used, like + // ScreenManager.getScreenState(function(value) { ...} ); but there + // are too many places to change that for now. + self.screenEnabled = false; + navigator.mozPower.screenEnabled = false; + }, 20); + + self.fireScreenChangeEvent(); + }; + + if (instant) { + if (!WindowManager.isFtuRunning()) { + screenOff(); + } + return true; + } + + this.setScreenBrightness(0.1, false); + this._inTransition = true; + setTimeout(function noticeTimeout() { + if (!self._inTransition) + return; + + screenOff(); + }, self._dimNotice); + + return true; + }, + + turnScreenOn: function scm_turnScreenOn(instant) { + if (this.screenEnabled) { + if (this._inTransition) { + // Cancel the dim out + this._inTransition = false; + this.setScreenBrightness(this._savedBrightness, true); + this._reconfigScreenTimeout(); + } + return false; + } + + // Set the brightness before the screen is on. + this.setScreenBrightness(this._savedBrightness, instant); + + // If we are in a call and there is no cpuWakeLock, + // we would have to get one here. + var telephony = window.navigator.mozTelephony; + if (!this._cpuWakeLock && telephony && telephony.calls.length) { + telephony.calls.some(function checkCallConnection(call) { + if (call.state == 'connected') { + this._cpuWakeLock = navigator.requestWakeLock('cpu'); + window.addEventListener('userproximity', this); + return true; + } + return false; + }, this); + } + + // Actually turn the screen on. + var power = navigator.mozPower; + if (power) + power.screenEnabled = true; + this.screenEnabled = true; + this.screen.classList.remove('screenoff'); + + // Attaching the event listener effectively turn on the hardware + // device light sensor, which _must be_ done after power.screenEnabled. + if (this._deviceLightEnabled) + window.addEventListener('devicelight', this); + + this._reconfigScreenTimeout(); + this.fireScreenChangeEvent(); + + return true; + }, + + _reconfigScreenTimeout: function scm_reconfigScreenTimeout() { + // Remove idle timer if screen wake lock is acquired. + if (this._screenWakeLocked) { + this._setIdleTimeout(0); + // The screen should be turn off with shorter timeout if + // it was never unlocked. + } else if (LockScreen.locked) { + this._setIdleTimeout(10, true); + var self = this; + var stopShortIdleTimeout = function scm_stopShortIdleTimeout() { + window.removeEventListener('unlock', stopShortIdleTimeout); + window.removeEventListener('lockpanelchange', stopShortIdleTimeout); + self._setIdleTimeout(self._idleTimeout, false); + }; + + window.addEventListener('unlock', stopShortIdleTimeout); + window.addEventListener('lockpanelchange', stopShortIdleTimeout); + } else { + this._setIdleTimeout(this._idleTimeout, false); + } + }, + + setScreenBrightness: function scm_setScreenBrightness(brightness, instant) { + this._targetBrightness = brightness; + var power = navigator.mozPower; + if (!power) + return; + + // Make sure we don't have another brightness change scheduled + if (this._transitionBrightnessTimer) { + clearTimeout(this._transitionBrightnessTimer); + this._transitionBrightnessTimer = null; + } + + if (typeof instant !== 'boolean') + instant = true; + + if (instant) { + power.screenBrightness = brightness; + return; + } + + // transitionBrightness() is a looping function that will + // gracefully tune the brightness to _targetBrightness for us. + this.transitionBrightness(); + }, + + transitionBrightness: function scm_transitionBrightness() { + var self = this; + var power = navigator.mozPower; + var screenBrightness = power.screenBrightness; + var delta = this.BRIGHTNESS_ADJUST_STEP; + + // Is this the last time adjustment we need to make? + if (Math.abs(this._targetBrightness - screenBrightness) <= delta) { + power.screenBrightness = this._targetBrightness; + this._transitionBrightnessTimer = null; + return; + } + + if (screenBrightness > this._targetBrightness) + delta *= -1; + + screenBrightness += delta; + power.screenBrightness = screenBrightness; + + this._transitionBrightnessTimer = + setTimeout(function transitionBrightnessTimeout() { + self.transitionBrightness(); + }, this.BRIGHTNESS_ADJUST_INTERVAL); + }, + + setDeviceLightEnabled: function scm_setDeviceLightEnabled(enabled) { + if (!enabled && this._deviceLightEnabled) { + // Disabled -- set the brightness back to preferred brightness + this.setScreenBrightness(this._userBrightness, false); + } + this._deviceLightEnabled = enabled; + + if (!this.screenEnabled) + return; + + // Disable/enable device light sensor accordingly. + // This will also toggle the actual hardware, which + // must be done while the screen is on. + if (enabled) { + window.addEventListener('devicelight', this); + } else { + window.removeEventListener('devicelight', this); + } + }, + + _setIdleTimeout: function scm_setIdleTimeout(time, instant) { + window.clearIdleTimeout(this._idleTimerId); + + // Reset the idled state back to false. + this._idled = false; + + // 0 is the value used to disable idle timer by user and by us. + if (time === 0) + return; + + var self = this; + var idleCallback = function idle_proxy() { + self.turnScreenOff(instant); + }; + var activeCallback = function active_proxy() { + self.turnScreenOn(true); + }; + + this._idleTimerId = window.setIdleTimeout(idleCallback, + activeCallback, time * 1000); + }, + + fireScreenChangeEvent: function scm_fireScreenChangeEvent() { + var evt = new CustomEvent('screenchange', + { bubbles: true, cancelable: false, + detail: { screenEnabled: this.screenEnabled } }); + window.dispatchEvent(evt); + } +}; + +ScreenManager.init(); diff --git a/apps/system/js/screenshot.js b/apps/system/js/screenshot.js new file mode 100644 index 0000000..9e8e7f6 --- /dev/null +++ b/apps/system/js/screenshot.js @@ -0,0 +1,115 @@ +// screenshot.js: system screenshot module +// +// This system module takes a screenshot of the currently running app +// or homescreen and stores it with DeviceStorage when the user +// presses the home and sleep buttons at the same time. It communicates +// with gecko code running in b2g/chrome/content/shell.js using a private +// event-based API. It is the gecko code that creates the screenshot. +// +// This script must be used with the defer attribute. +// +// +(function() { + window.addEventListener('home+sleep', takeScreenshot); + + // Assume that the maximum screenshot size is 4 bytes per pixel + // plus a bit extra. In practice, with compression, our PNG files will be + // much smaller than this. + var MAX_SCREENSHOT_SIZE = window.innerWidth * window.innerHeight * 4 + 4096; + + function takeScreenshot() { + // Give feedback that the screenshot request was received + navigator.vibrate(100); + + // We don't need device storage here, but check to see that + // it is available before sending the screenshot request to chrome. + // If device storage is available, the callback will be called. + // Otherwise, an error message notification will be displayed. + getDeviceStorage(function() { + // Let chrome know we'd like a screenshot. + // This is a completely non-standard undocumented API + // for communicating with our chrome code. + var screenshotProps = { + detail: { + type: 'take-screenshot' + } + }; + window.dispatchEvent(new CustomEvent('mozContentEvent', screenshotProps)); + }); + } + + // Display a screenshot success or failure notification. + // Localize the first argument, and localize the third if the second is null + function notify(titleid, body, bodyid) { + var title = navigator.mozL10n.get(titleid) || titleid; + body = body || navigator.mozL10n.get(bodyid); + navigator.mozNotification.createNotification(title, body).show(); + } + + // Get a DeviceStorage object and pass it to the callback. + // Or, if device storage is not available, display a notification. + function getDeviceStorage(callback) { + var storage = navigator.getDeviceStorage('pictures'); + var availreq = storage.available(); + availreq.onsuccess = function() { + var state = availreq.result; + if (state === 'unavailable') { + notify('screenshotFailed', null, 'screenshotNoSDCard'); + } + else if (state === 'shared') { + notify('screenshotFailed', null, 'screenshotSDCardInUse'); + } + else if (state === 'available') { + var freereq = storage.freeSpace(); + freereq.onsuccess = function() { + if (freereq.result < MAX_SCREENSHOT_SIZE) { + notify('screenshotFailed', null, 'screenshotSDCardLow'); + } + else { + callback(storage); + } + }; + freereq.onerror = function() { + notify('screenshotFailed', freereq.error && freereq.error.name); + }; + } + } + availreq.onerror = function() { + notify('screenshotFailed', availreq.error && availreq.error.name); + } + } + + // Handle the event we get from chrome with the screenshot + window.addEventListener('mozChromeEvent', function ss_onMozChromeEvent(e) { + try { + if (e.detail.type === 'take-screenshot-success') { + getDeviceStorage(function(storage) { + var filename = 'screenshots/' + + new Date().toISOString().slice(0, -5).replace(/[:T]/g, '-') + + '.png'; + + var saveRequest = storage.addNamed(e.detail.file, filename); + + saveRequest.onsuccess = function ss_onsuccess() { + // Vibrate again when the screenshot is saved + navigator.vibrate(100); + + // Display filename in a notification + notify('screenshotSaved', filename); + }; + + saveRequest.onerror = function ss_onerror() { + notify('screenshotFailed', saveRequest.error.name); + }; + }); + } + else if (e.detail.type === 'take-screenshot-error') { + notify('screenshotFailed', e.detail.error); + } + } + catch (e) { + console.log('exception in screenshot handler', e); + notify('screenshotFailed', e.toString()); + } + }); +}()); diff --git a/apps/system/js/sim_lock.js b/apps/system/js/sim_lock.js new file mode 100644 index 0000000..3a3d3e0 --- /dev/null +++ b/apps/system/js/sim_lock.js @@ -0,0 +1,110 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +var SimLock = { + init: function sl_init() { + // Do not do anything if we can't have access to MobileConnection API + var conn = window.navigator.mozMobileConnection; + if (!conn) + return; + + this.onClose = this.onClose.bind(this); + + // Watch for apps that need a mobile connection + window.addEventListener('appwillopen', this); + + // Display the dialog only after lockscreen is unlocked + // To prevent keyboard being displayed behind it. + window.addEventListener('unlock', this); + + // always monitor card state change + conn.addEventListener('cardstatechange', this.showIfLocked.bind(this)); + }, + + handleEvent: function sl_handleEvent(evt) { + switch (evt.type) { + case 'unlock': + this.showIfLocked(); + break; + case 'appwillopen': + // if an app needs telephony or sms permission, + // we will launch the unlock screen if needed. + + var app = Applications.getByManifestURL( + evt.target.getAttribute('mozapp')); + + if (!app || !app.manifest.permissions) + return; + + // Ignore first time usage app which already ask for SIM code + if (evt.target.classList.contains('ftu')) + return; + + if (!('telephony' in app.manifest.permissions || + 'sms' in app.manifest.permissions)) + return; + + // Ignore second `appwillopen` event when showIfLocked ends up + // eventually opening the app on valid pin code + var origin = evt.target.dataset.frameOrigin; + if (origin == this._lastOrigin) { + delete this._lastOrigin; + return; + } + this._lastOrigin = origin; + + // if sim is locked, cancel app opening in order to display + // it after PIN dialog + if (this.showIfLocked()) + evt.preventDefault(); + + break; + } + }, + + showIfLocked: function sl_showIfLocked() { + var conn = window.navigator.mozMobileConnection; + if (!conn) + return false; + + if (LockScreen.locked) + return false; + + switch (conn.cardState) { + // do nothing in absent and null card states + case null: + case 'absent': + break; + case 'pukRequired': + case 'pinRequired': + SimPinDialog.show('unlock', this.onClose); + return true; + case 'networkLocked': + // XXXX: After unlocking the SIM the cardState is + // 'networkLocked' but it changes inmediately to 'ready' + // if the phone is not SIM-locked. If the cardState + // is still 'networkLocked' after two seconds we unlock + // the network control key lock (network personalization). + setTimeout(function checkState() { + if (conn.cardState == 'networkLocked') { + SimPinDialog.show('unlock', SimLock.onClose); + } + }, 5000); + break; + } + return false; + }, + + onClose: function sl_onClose(reason) { + // Display the app only when PIN code is valid and when we click + // on `X` button + if (this._lastOrigin && (reason == 'success' || reason == 'skip')) + WindowManager.setDisplayedApp(this._lastOrigin); + delete this._lastOrigin; + } + +}; + +SimLock.init(); diff --git a/apps/system/js/simcard_dialog.js b/apps/system/js/simcard_dialog.js new file mode 100644 index 0000000..4176cbf --- /dev/null +++ b/apps/system/js/simcard_dialog.js @@ -0,0 +1,355 @@ +/* -*- Mode: js; js-indent-level: 2; indent-tabs-mode: nil -*- */ +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +var SimPinDialog = { + dialogTitle: document.querySelector('#simpin-dialog header h1'), + dialogDone: document.querySelector('#simpin-dialog button[type="submit"]'), + dialogClose: document.querySelector('#simpin-dialog button[type="reset"]'), + + pinArea: document.getElementById('pinArea'), + pukArea: document.getElementById('pukArea'), + nckArea: document.getElementById('nckArea'), + newPinArea: document.getElementById('newPinArea'), + confirmPinArea: document.getElementById('confirmPinArea'), + + pinInput: null, + pukInput: null, + nckInput: null, + newPinInput: null, + confirmPinInput: null, + + errorMsg: document.getElementById('errorMsg'), + errorMsgHeader: document.getElementById('messageHeader'), + errorMsgBody: document.getElementById('messageBody'), + + mobileConnection: null, + + lockType: 'pin', + action: 'unlock', + + // Now we don't have a number-password type for input field + // mimic one by binding one number input and one text input + getNumberPasswordInputField: function spl_wrapNumberInput(name) { + var valueEntered = ''; + var inputField = document.querySelector('input[name="' + name + '"]'); + var displayField = document.querySelector('input[name="' + name + 'Vis"]'); + var codeMaxLength = parseInt(inputField.getAttribute('maxlength'), 10); + var self = this; + + inputField.addEventListener('keypress', function(evt) { + if (evt.target !== inputField) + return; + evt.preventDefault(); + + var code = evt.charCode; + if (code !== 0 && (code < 0x30 || code > 0x39)) + return; + + if (code === 0) { // backspace + valueEntered = valueEntered.substr(0, valueEntered.length - 1); + } else { + if (valueEntered.length >= codeMaxLength) + return; + valueEntered += String.fromCharCode(code); + } + displayField.value = encryption(valueEntered); + if (displayField.value.length >= 4) + self.dialogDone.disabled = false; + else + self.dialogDone.disabled = true; + }); + + function encryption(str) { + return (new Array(str.length + 1)).join('*'); + } + + function setValue(value) { + valueEntered = value; + inputField.value = value; + displayField.value = encryption(valueEntered); + } + + function setFocus() { + inputField.focus(); + } + + function blur() { + inputField.blur(); + } + + return { + get value() { return valueEntered; }, + set value(value) { setValue(value) }, + focus: setFocus, + blur: blur + }; + }, + + handleCardState: function spl_handleCardState() { + var _ = navigator.mozL10n.get; + + var cardState = this.mobileConnection.cardState; + switch (cardState) { + case 'pinRequired': + this.lockType = 'pin'; + this.errorMsg.hidden = true; + this.inputFieldControl(true, false, false, false); + this.pinInput.focus(); + break; + case 'pukRequired': + this.lockType = 'puk'; + this.errorMsgHeader.textContent = _('simCardLockedMsg') || ''; + this.errorMsgHeader.dataset.l10nId = 'simCardLockedMsg'; + this.errorMsgBody.textContent = _('enterPukMsg') || ''; + this.errorMsgBody.dataset.l10nId = 'enterPukMsg'; + this.errorMsg.hidden = false; + this.inputFieldControl(false, true, false, true); + this.pukInput.focus(); + break; + case 'networkLocked': + this.lockType = 'nck'; + this.errorMsg.hidden = true; + this.inputFieldControl(false, false, true, false); + this.nckInput.focus(); + break; + default: + this.skip(); + break; + } + this.dialogTitle.textContent = _(this.lockType + 'Title') || ''; + this.dialogTitle.dataset.l10nId = this.lockType + 'Title'; + }, + + handleError: function spl_handleLockError(evt) { + var retry = (evt.retryCount) ? evt.retryCount : -1; + this.showErrorMsg(retry, evt.lockType); + if (retry === -1) { + this.skip(); + return; + } + if (evt.lockType === 'pin') { + this.pinInput.focus(); + } else if (evt.lockType === 'puk') { + this.pukInput.focus(); + } else { + this.nckInput.focus(); + } + }, + + showErrorMsg: function spl_showErrorMsg(retry, type) { + var _ = navigator.mozL10n.get; + + this.errorMsgHeader.textContent = _(type + 'ErrorMsg'); + this.errorMsgHeader.dataset.l10nId = type + 'ErrorMsg'; + + if (retry !== 1) { + var l10nArgs = { n: retry }; + this.errorMsgBody.dataset.l10nId = type + 'AttemptMsg'; + this.errorMsgBody.dataset.l10nArgs = JSON.stringify(l10nArgs); + this.errorMsgBody.textContent = _(type + 'AttemptMsg', l10nArgs); + } else { + this.errorMsgBody.dataset.l10nId = type + 'LastChanceMsg'; + this.errorMsgBody.textContent = _(type + 'LastChanceMsg'); + } + + this.errorMsg.hidden = false; + }, + + unlockPin: function spl_unlockPin() { + var pin = this.pinInput.value; + if (pin === '') + return; + + var options = {lockType: 'pin', pin: pin }; + this.unlockCardLock(options); + this.clear(); + }, + + unlockPuk: function spl_unlockPuk() { + var _ = navigator.mozL10n.get; + + var puk = this.pukInput.value; + var newPin = this.newPinInput.value; + var confirmPin = this.confirmPinInput.value; + if (puk === '' || newPin === '' || confirmPin === '') + return; + + if (newPin !== confirmPin) { + this.errorMsgHeader.textContent = _('newPinErrorMsg'); + this.errorMsgHeader.dataset.l10nId = 'newPinErrorMsg'; + this.errorMsgBody.textContent = ''; + this.errorMsg.hidden = false; + return; + } + var options = {lockType: 'puk', puk: puk, newPin: newPin }; + this.unlockCardLock(options); + this.clear(); + }, + + unlockNck: function spl_unlockNck() { + var nck = this.nckInput.value; + if (nck === '') + return; + + var options = {lockType: 'nck', pin: nck }; + this.unlockCardLock(options); + this.clear(); + }, + + unlockCardLock: function spl_unlockCardLock(options) { + var req = this.mobileConnection.unlockCardLock(options); + req.onsuccess = this.close.bind(this, 'success'); + }, + + enableLock: function spl_enableLock() { + var pin = this.pinInput.value; + if (pin === '') + return; + + var enabled = SimPinLock.simPinCheckBox.checked; + var options = {lockType: 'pin', pin: pin, enabled: enabled}; + this.setCardLock(options); + this.clear(); + }, + + changePin: function spl_changePin() { + var _ = navigator.mozL10n.get; + + var pin = this.pinInput.value; + var newPin = this.newPinInput.value; + var confirmPin = this.confirmPinInput.value; + if (pin === '' || newPin === '' || confirmPin === '') + return; + + if (newPin !== confirmPin) { + this.errorMsgHeader.textContent = _('newPinErrorMsg'); + this.errorMsgHeader.dataset.l10nId = 'newPinErrorMsg'; + this.errorMsgBody.textContent = ''; + this.errorMsg.hidden = false; + return; + } + var options = {lockType: 'pin', pin: pin, newPin: newPin}; + this.setCardLock(options); + this.clear(); + }, + + setCardLock: function spl_setCardLock(options) { + var req = this.mobileConnection.setCardLock(options); + req.onsuccess = this.close.bind(this, 'success'); + }, + inputFieldControl: function spl_inputField(isPin, isPuk, isNck, isNewPin) { + this.pinArea.hidden = !isPin; + this.pukArea.hidden = !isPuk; + this.nckArea.hidden = !isNck; + this.newPinArea.hidden = !isNewPin; + this.confirmPinArea.hidden = !isNewPin; + }, + + verify: function spl_verify() { + switch (this.action) { + case 'unlock': + if (this.lockType === 'pin') + this.unlockPin(); + else if (this.lockType === 'puk') { + this.unlockPuk(); + } else { + this.unlockNck(); + } + break; + case 'enable': + this.enableLock(); + break; + case 'changePin': + this.changePin(); + break; + } + return false; + }, + + onHide: function spl_onHide(reason) { + this.clear(); + if (this.onclose) + this.onclose(reason); + }, + + clear: function spl_clear() { + this.errorMsg.hidden = true; + this.pinInput.value = ''; + this.pinInput.blur(); + this.pukInput.value = ''; + this.pukInput.blur(); + this.newPinInput.value = ''; + this.confirmPinInput.value = ''; + }, + + onclose: null, + /** + * Show the SIM pin dialog + * @param {String} action Name of the action to execute, + * either: unlock, enable or changePin. + * @param {Function} title Optional function called when dialog is closed. + * Receive a single argument being the reason of + * dialog closing: success, skip, home or holdhome. + */ + show: function spl_show(action, onclose) { + var _ = navigator.mozL10n.get; + + this.systemDialog.show(); + this.dialogDone.disabled = true; + this.action = action; + this.lockType = 'pin'; + switch (action) { + case 'unlock': + this.handleCardState(); + break; + case 'enable': + this.inputFieldControl(true, false, false, false); + this.dialogTitle.textContent = _('pinTitle') || ''; + this.dialogTitle.dataset.l10nId = 'pinTitle'; + break; + case 'changePin': + this.inputFieldControl(true, false, false, true); + this.dialogTitle.textContent = _('newpinTitle') || ''; + this.dialogTitle.dataset.l10nId = 'newpinTitle'; + break; + } + + if (onclose && typeof onclose === 'function') + this.onclose = onclose; + }, + + close: function spl_close(reason) { + this.systemDialog.hide(reason); + }, + + skip: function spl_skip() { + this.close('skip'); + return false; + }, + + init: function spl_init() { + this.systemDialog = SystemDialog('simpin-dialog', { + onHide: this.onHide.bind(this) + }); + + this.mobileConnection = window.navigator.mozMobileConnection; + if (!this.mobileConnection) + return; + + this.mobileConnection.addEventListener('icccardlockerror', + this.handleError.bind(this)); + + this.dialogDone.onclick = this.verify.bind(this); + this.dialogClose.onclick = this.skip.bind(this); + this.pinInput = this.getNumberPasswordInputField('simpin'); + this.pukInput = this.getNumberPasswordInputField('simpuk'); + this.nckInput = this.getNumberPasswordInputField('nckpin'); + this.newPinInput = this.getNumberPasswordInputField('newSimpin'); + this.confirmPinInput = this.getNumberPasswordInputField('confirmNewSimpin'); + } +}; + +SimPinDialog.init(); + diff --git a/apps/system/js/sleep_menu.js b/apps/system/js/sleep_menu.js new file mode 100644 index 0000000..a97b772 --- /dev/null +++ b/apps/system/js/sleep_menu.js @@ -0,0 +1,276 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +var SleepMenu = { + // Indicate setting status of ril.radio.disabled + isFlightModeEnabled: false, + + // Indicate setting status of volume + isSilentModeEnabled: false, + + elements: {}, + + get visible() { + return this.elements.overlay.classList.contains('visible'); + }, + + getAllElements: function sm_getAllElements() { + this.elements.overlay = document.getElementById('sleep-menu'); + this.elements.container = + document.querySelector('#sleep-menu-container ul'); + this.elements.cancel = document.querySelector('#sleep-menu button'); + }, + + init: function sm_init() { + this.getAllElements(); + window.addEventListener('holdsleep', this.show.bind(this)); + window.addEventListener('click', this, true); + window.addEventListener('screenchange', this, true); + window.addEventListener('home', this); + this.elements.cancel.addEventListener('click', this); + + var self = this; + SettingsListener.observe('ril.radio.disabled', false, function(value) { + self.isFlightModeEnabled = value; + }); + + var settings = navigator.mozSettings; + SettingsListener.observe('audio.volume.notification', 7, function(value) { + settings.createLock().set({'ring.enabled': (value != 0)}); + }); + + SettingsListener.observe('ring.enabled', true, function(value) { + self.isSilentModeEnabled = !value; + }); + }, + + // Generate items + generateItems: function sm_generateItems() { + var items = []; + var _ = navigator.mozL10n.get; + var options = { + airplane: { + label: _('airplane'), + value: 'airplane', + icon: '/style/sleep_menu/images/airplane.png' + }, + airplaneOff: { + label: _('airplaneOff'), + value: 'airplane' + }, + silent: { + label: _('silent'), + value: 'silent', + icon: '/style/sleep_menu/images/vibration.png' + }, + silentOff: { + label: _('normal'), + value: 'silentOff' + }, + restart: { + label: _('restart'), + value: 'restart', + icon: '/style/sleep_menu/images/restart.png' + }, + power: { + label: _('power'), + value: 'power', + icon: '/style/sleep_menu/images/power-off.png' + } + }; + + if (this.isFlightModeEnabled) { + items.push(options.airplaneOff); + } else { + items.push(options.airplane); + } + + if (!this.isSilentModeEnabled) { + items.push(options.silent); + } else { + items.push(options.silentOff); + } + + items.push(options.restart); + items.push(options.power); + + return items; + }, + + show: function sm_show() { + this.elements.container.innerHTML = ''; + this.buildMenu(this.generateItems()); + this.elements.overlay.classList.add('visible'); + }, + + buildMenu: function sm_buildMenu(items) { + items.forEach(function traveseItems(item) { + var item_li = document.createElement('li'); + item_li.dataset.value = item.value; + item_li.textContent = item.label; + this.elements.container.appendChild(item_li); + }, this); + }, + + hide: function lm_hide() { + this.elements.overlay.classList.remove('visible'); + }, + + handleEvent: function sm_handleEvent(evt) { + switch (evt.type) { + case 'screenchange': + if (!evt.detail.screenEnabled) + this.hide(); + break; + + case 'click': + if (!this.visible) + return; + + if (evt.currentTarget === this.elements.cancel) { + this.hide(); + return; + } + + var action = evt.target.dataset.value; + if (!action) { + return; + } + this.hide(); + this.handler(action); + break; + + case 'home': + if (this.visible) { + this.hide(); + } + break; + } + }, + + handler: function sm_handler(action) { + switch (action) { + case 'airplane': + // Airplane mode should turn off + // + // Radio ('ril.radio.disabled'`) + // Data ('ril.data.enabled'`) + // Wifi + // Bluetooth + // Geolocation + // + // It should also save the status of the latter 4 items + // so when leaving the airplane mode we could know which one to turn on. + + if (!window.navigator.mozSettings) + return; + + SettingsListener.getSettingsLock().set({ + 'ril.radio.disabled': !this.isFlightModeEnabled + }); + + break; + + // About silent and silentOff + // * Turn on silent mode will cause: + // * Turn off ringtone no matter if ring is on or off + // * for sms and incoming calls. + // * Turn off silent mode will cause: + // * Turn on ringtone no matter if ring is on or off + // * for sms and incoming calls. + case 'silent': + if (!window.navigator.mozSettings) + return; + + SettingsListener.getSettingsLock().set({ + 'ring.enabled': false + }); + this.isSilentModeEnabled = true; + + break; + + case 'silentOff': + if (!window.navigator.mozSettings) + return; + + SettingsListener.getSettingsLock().set({ + 'ring.enabled': true + }); + this.isSilentModeEnabled = false; + + break; + + case 'restart': + this.startPowerOff(true); + + break; + + case 'power': + this.startPowerOff(false); + + break; + } + }, + + startPowerOff: function sm_startPowerOff(reboot) { + var power = navigator.mozPower; + if (!power) + return; + + // Early return if we are already shutting down. + if (document.getElementById('poweroff-splash')) + return; + + // Show shutdown animation before actually performing shutdown. + // * step1: fade-in poweroff-splash. + // * step2: The 3-rings animation is performed on the screen. + var div = document.createElement('div'); + div.dataset.zIndexLevel = 'poweroff-splash'; + div.id = 'poweroff-splash'; + + // The overall animation ends when the inner span of the bottom ring + // is animated, so we store it for detecting. + var inner; + + for (var i = 1; i <= 3; i++) { + var outer = document.createElement('span'); + outer.className = 'poweroff-ring'; + outer.id = 'poweroff-ring-' + i; + div.appendChild(outer); + + inner = document.createElement('span'); + outer.appendChild(inner); + } + + div.className = 'step1'; + + var nextAnimation = function nextAnimation(e) { + // Switch to next class + if (e.target == div) + div.className = 'step2'; + + if (e.target != inner) + return; + + // Actual poweroff/reboot + setTimeout(function powerOffAnimated() { + if (reboot) { + power.reboot(); + } else { + power.powerOff(); + } + }); + + // Paint screen to black before reboot/poweroff + ScreenManager.turnScreenOff(true); + }; + + div.addEventListener('animationend', nextAnimation); + + document.getElementById('screen').appendChild(div); + } +}; + +SleepMenu.init(); diff --git a/apps/system/js/sound_manager.js b/apps/system/js/sound_manager.js new file mode 100644 index 0000000..52dfc06 --- /dev/null +++ b/apps/system/js/sound_manager.js @@ -0,0 +1,242 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +(function() { + window.addEventListener('volumeup', function() { + if (onBTEarphoneConnected() && onCall()) { + changeVolume(1, 'bt_sco'); + } else { + changeVolume(1); + } + }); + window.addEventListener('volumedown', function() { + if (onBTEarphoneConnected() && onCall()) { + changeVolume(-1, 'bt_sco'); + } else { + changeVolume(-1); + } + }); + + // Store the current active channel; + // change with 'audio-channel-changed' mozChromeEvent + var currentChannel = 'notification'; + + var vibrationEnabled = true; + + // This event is generated in shell.js in response to bluetooth headset. + // Bluetooth headset always assign audio volume to a specific value when + // pressing its volume-up/volume-down buttons. + window.addEventListener('mozChromeEvent', function(e) { + var type = e.detail.type; + if (type == 'bluetooth-volumeset') { + changeVolume(e.detail.value - currentVolume['bt_sco'], 'bt_sco'); + } else if (type == 'audio-channel-changed') { + currentChannel = e.detail.channel; + } + }); + + function onCall() { + if (currentChannel == 'telephony') + return true; + + // XXX: This work should be removed + // once we could get telephony channel change event + // https://bugzilla.mozilla.org/show_bug.cgi?id=819858 + var telephony = window.navigator.mozTelephony; + if (!telephony) + return false; + + return telephony.calls.some(function callIterator(call) { + return (call.state == 'connected'); + }); + } + + function onBTEarphoneConnected() { + var bluetooth = navigator.mozBluetooth; + if (!bluetooth) + return false; + + // 0x111E is for querying earphone type. + return navigator.mozBluetooth.isConnected(0x111E); + }; + + // Platform doesn't provide the maximum value of each channel + // therefore, hard code here. + var MAX_VOLUME = { + 'alarm': 15, + 'notification': 15, + 'telephony': 5, + 'content': 15, + 'bt_sco': 15 + }; + + // Please refer https://wiki.mozilla.org/WebAPI/AudioChannels > Settings + var currentVolume = { + 'alarm': 15, + 'notification': 15, + 'telephony': 5, + 'content': 15, + 'bt_sco': 15 + }; + var pendingRequestCount = 0; + + // We have three virtual states here: + // OFF -> VIBRATION -> MUTE + var muteState = 'OFF'; + + for (var channel in currentVolume) { + (function(channel) { + var setting = 'audio.volume.' + channel; + SettingsListener.observe(setting, 5, function onSettingsChange(volume) { + if (pendingRequestCount) + return; + + var max = MAX_VOLUME[channel]; + currentVolume[channel] = + parseInt(Math.max(0, Math.min(max, volume)), 10); + }); + })(channel); + } + + SettingsListener.observe('vibration.enabled', true, function(vibration) { + if (pendingRequestCount) + return; + + vibrationEnabled = vibration; + }); + + var activeTimeout = 0; + + // When hardware volume key is pressed, we need to decide which channel we + // should toggle. + // This method returns the string for setting key 'audio.volume.*' represents + // that. + // Note: this string does not always equal to currentChannel since some + // different channels are grouped together to listen to the same setting. + function getChannel() { + if (onCall()) + return 'telephony'; + + switch (currentChannel) { + case 'normal': + case 'content': + return 'content'; + case 'telephony': + return 'telephony'; + case 'alarm': + return 'alarm'; + case 'notification': + case 'ringer': + default: + return 'notification'; + } + } + + function getVolumeState(currentVolume, delta, channel) { + if (channel == 'notification') { + if (currentVolume + delta <= 0) { + if (currentVolume == 0 && vibrationEnabled) { + vibrationEnabled = false; + } else if (currentVolume > 0 && !vibrationEnabled) { + vibrationEnabled = true; + } + return 'MUTE'; + } else { + return 'OFF'; + } + } else { + if (currentVolume + delta <= 0) { + return 'MUTE'; + } else { + return 'OFF'; + } + } + } + + function changeVolume(delta, channel) { + channel = channel ? channel : getChannel(); + + muteState = getVolumeState(currentVolume[channel], delta, channel); + + var volume = currentVolume[channel] + delta; + + currentVolume[channel] = volume = + Math.max(0, Math.min(MAX_VOLUME[channel], volume)); + + var overlay = document.getElementById('system-overlay'); + var notification = document.getElementById('volume'); + var overlayClasses = overlay.classList; + var classes = notification.classList; + + switch (muteState) { + case 'OFF': + classes.remove('mute'); + if (vibrationEnabled) { + classes.add('vibration'); + } else { + classes.remove('vibration'); + } + break; + case 'MUTE': + classes.add('mute'); + if (channel == 'notification') { + if (vibrationEnabled) { + classes.add('vibration'); + SettingsListener.getSettingsLock().set({ + 'vibration.enabled': true + }); + } else { + classes.remove('vibration'); + SettingsListener.getSettingsLock().set({ + 'vibration.enabled': false + }); + } + } + break; + } + + var steps = + Array.prototype.slice.call(notification.querySelectorAll('div'), 0); + + for (var i = 0; i < steps.length; i++) { + var step = steps[i]; + if (i < volume) { + step.classList.add('active'); + } else { + step.classList.remove('active'); + } + } + + overlayClasses.add('volume'); + classes.add('visible'); + window.clearTimeout(activeTimeout); + activeTimeout = window.setTimeout(function hideSound() { + overlayClasses.remove('volume'); + classes.remove('visible'); + }, 1500); + + if (!window.navigator.mozSettings) + return; + + pendingRequestCount++; + var req; + + notification.dataset.channel = channel; + + var settingObject = {}; + settingObject['audio.volume.' + channel] = volume; + + req = SettingsListener.getSettingsLock().set(settingObject); + + req.onsuccess = function onSuccess() { + pendingRequestCount--; + }; + + req.onerror = function onError() { + pendingRequestCount--; + }; + } +})(); + diff --git a/apps/system/js/source_view.js b/apps/system/js/source_view.js new file mode 100644 index 0000000..dc6b8e4 --- /dev/null +++ b/apps/system/js/source_view.js @@ -0,0 +1,67 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +var SourceView = { + get viewer() { + return document.getElementById('appViewsource'); + }, + + get active() { + return !this.viewer ? false : this.viewer.style.visibility === 'visible'; + }, + + init: function sv_init() { + window.addEventListener('home+volume', function() { + if (ScreenManager.screenEnabled) + SourceView.toggle(); + }); + window.addEventListener('locked', function() { + SourceView.hide(); + }); + }, + + show: function sv_show() { + var viewsource = this.viewer; + if (!viewsource) { + var style = '#appViewsource { ' + + ' position: absolute;' + + ' top: -moz-calc(10%);' + + ' left: -moz-calc(10%);' + + ' width: -moz-calc(80% - 2 * 15px);' + + ' height: -moz-calc(80% - 2 * 15px);' + + ' visibility: hidden;' + + ' margin: 15px;' + + ' background-color: white;' + + ' opacity: 0.92;' + + ' color: black;' + + ' z-index: 9999;' + + '}'; + document.styleSheets[0].insertRule(style, 0); + + viewsource = document.createElement('iframe'); + viewsource.id = 'appViewsource'; + document.body.appendChild(viewsource); + } + + var url = WindowManager.getDisplayedApp(); + if (!url) + // Assume the home screen is the visible app. + url = window.location.toString(); + viewsource.src = 'view-source: ' + url; + viewsource.style.visibility = 'visible'; + }, + + hide: function sv_hide() { + var viewsource = this.viewer; + if (viewsource) { + viewsource.style.visibility = 'hidden'; + viewsource.src = 'about:blank'; + } + }, + + toggle: function sv_toggle() { + this.active ? this.hide() : this.show(); + } +}; diff --git a/apps/system/js/statusbar.js b/apps/system/js/statusbar.js new file mode 100644 index 0000000..1d95d99 --- /dev/null +++ b/apps/system/js/statusbar.js @@ -0,0 +1,618 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +var StatusBar = { + /* all elements that are children nodes of the status bar */ + ELEMENTS: ['notification', 'time', + 'battery', 'wifi', 'data', 'flight-mode', 'signal', 'network-activity', + 'tethering', 'alarm', 'bluetooth', 'mute', 'headphones', + 'recording', 'sms', 'geolocation', 'usb', 'label', 'system-downloads', + 'call-forwarding'], + + /* Timeout for 'recently active' indicators */ + kActiveIndicatorTimeout: 60 * 1000, + + /* Whether or not status bar is actively updating or not */ + active: true, + + /* Some values that sync from mozSettings */ + settingValues: {}, + + /* Keep the DOM element references here */ + icons: {}, + + /* A mapping table between technology names + we would get from API v.s. the icon we want to show. */ + mobileDataIconTypes: { + 'lte': '4G', // 4G LTE + 'ehrpd': '4G', // 4G CDMA + 'hspa+': 'H+', // 3.5G HSPA+ + 'hsdpa': 'H', 'hsupa': 'H', 'hspa': 'H', // 3.5G HSDPA + 'evdo0': '3G', 'evdoa': '3G', 'evdob': '3G', '1xrtt': '3G', // 3G CDMA + 'umts': '3G', // 3G + 'edge': 'E', // EDGE + 'is95a': '2G', 'is95b': '2G', // 2G CDMA + 'gprs': '2G' + }, + + geolocationActive: false, + geolocationTimer: null, + + recordingActive: false, + recordingTimer: null, + + umsActive: false, + + headphonesActive: false, + + /** + * this keeps how many current installs/updates we do + * it triggers the icon "systemDownloads" + */ + systemDownloadsCount: 0, + + /* For other modules to acquire */ + get height() { + if (this.screen.classList.contains('fullscreen-app') || + document.mozFullScreen) { + return 0; + } else if (this.screen.classList.contains('active-statusbar')) { + return this.attentionBar.offsetHeight; + } else { + return this._cacheHeight || + (this._cacheHeight = this.element.getBoundingClientRect().height); + } + }, + + init: function sb_init() { + this.getAllElements(); + + var settings = { + 'ril.radio.disabled': ['signal', 'data'], + 'ril.data.enabled': ['data'], + 'wifi.enabled': ['wifi'], + 'bluetooth.enabled': ['bluetooth'], + 'tethering.usb.enabled': ['tethering'], + 'tethering.wifi.enabled': ['tethering'], + 'tethering.wifi.connectedClients': ['tethering'], + 'tethering.usb.connectedClients': ['tethering'], + 'ring.enabled': ['mute'], + 'alarm.enabled': ['alarm'], + 'vibration.enabled': ['vibration'], + 'ril.cf.enabled': ['callForwarding'] + }; + + var self = this; + for (var settingKey in settings) { + (function sb_setSettingsListener(settingKey) { + SettingsListener.observe(settingKey, false, + function sb_settingUpdate(value) { + self.settingValues[settingKey] = value; + settings[settingKey].forEach( + function sb_callUpdate(name) { + self.update[name].call(self); + } + ); + } + ); + self.settingValues[settingKey] = false; + })(settingKey); + } + + // Listen to 'screenchange' from screen_manager.js + window.addEventListener('screenchange', this); + + // Listen to 'geolocation-status' and 'recording-status' mozChromeEvent + window.addEventListener('mozChromeEvent', this); + + // Listen to 'bluetoothconnectionchange' from bluetooth.js + window.addEventListener('bluetoothconnectionchange', this); + + // Listen to 'moztimechange' + window.addEventListener('moztimechange', this); + + this.systemDownloadsCount = 0; + this.setActive(true); + }, + + handleEvent: function sb_handleEvent(evt) { + switch (evt.type) { + case 'screenchange': + this.setActive(evt.detail.screenEnabled); + break; + + case 'chargingchange': + case 'levelchange': + case 'statuschange': + this.update.battery.call(this); + break; + + case 'voicechange': + this.update.signal.call(this); + this.update.label.call(this); + break; + + case 'cardstatechange': + this.update.signal.call(this); + this.update.label.call(this); + this.update.data.call(this); + break; + + case 'callschanged': + this.update.signal.call(this); + break; + + case 'iccinfochange': + this.update.label.call(this); + break; + + case 'datachange': + this.update.data.call(this); + break; + + case 'bluetoothconnectionchange': + this.update.bluetooth.call(this); + break; + + case 'moztimechange': + this.update.time.call(this); + break; + + case 'mozChromeEvent': + switch (evt.detail.type) { + case 'geolocation-status': + this.geolocationActive = evt.detail.active; + this.update.geolocation.call(this); + break; + + case 'recording-status': + this.recordingActive = evt.detail.active; + this.update.recording.call(this); + break; + + case 'volume-state-changed': + this.umsActive = evt.detail.active; + this.update.usb.call(this); + break; + + case 'headphones-status-changed': + this.headphonesActive = (evt.detail.state != 'off'); + this.update.headphones.call(this); + break; + } + + break; + + case 'moznetworkupload': + case 'moznetworkdownload': + this.update.networkActivity.call(this); + break; + } + }, + + setActive: function sb_setActive(active) { + this.active = active; + if (active) { + this.update.time.call(this); + + var battery = window.navigator.battery; + if (battery) { + battery.addEventListener('chargingchange', this); + battery.addEventListener('levelchange', this); + battery.addEventListener('statuschange', this); + this.update.battery.call(this); + } + + var conn = window.navigator.mozMobileConnection; + if (conn) { + conn.addEventListener('voicechange', this); + conn.addEventListener('iccinfochange', this); + conn.addEventListener('datachange', this); + this.update.signal.call(this); + this.update.data.call(this); + } + + window.addEventListener('wifi-statuschange', + this.update.wifi.bind(this)); + this.update.wifi.call(this); + + window.addEventListener('moznetworkupload', this); + window.addEventListener('moznetworkdownload', this); + } else { + clearTimeout(this._clockTimer); + + var battery = window.navigator.battery; + if (battery) { + battery.removeEventListener('chargingchange', this); + battery.removeEventListener('levelchange', this); + battery.removeEventListener('statuschange', this); + } + + var conn = window.navigator.mozMobileConnection; + if (conn) { + conn.removeEventListener('voicechange', this); + conn.removeEventListener('iccinfochange', this); + conn.removeEventListener('datachange', this); + } + + window.removeEventListener('moznetworkupload', this); + window.removeEventListener('moznetworkdownload', this); + } + }, + + update: { + label: function sb_updateLabel() { + var conn = window.navigator.mozMobileConnection; + var label = this.icons.label; + var l10nArgs = JSON.parse(label.dataset.l10nArgs || '{}'); + + if (!conn || !conn.voice || !conn.voice.connected || + conn.voice.emergencyCallsOnly) { + delete l10nArgs.operator; + label.dataset.l10nArgs = JSON.stringify(l10nArgs); + + label.dataset.l10nId = ''; + label.textContent = l10nArgs.date; + + return; + } + + var operatorInfos = MobileOperator.userFacingInfo(conn); + l10nArgs.operator = operatorInfos.operator; + + if (operatorInfos.region) { + l10nArgs.operator += ' ' + operatorInfos.region; + } + + label.dataset.l10nArgs = JSON.stringify(l10nArgs); + + label.dataset.l10nId = 'statusbarLabel'; + label.textContent = navigator.mozL10n.get('statusbarLabel', l10nArgs); + }, + + time: function sb_updateTime() { + // Schedule another clock update when a new minute rolls around + var _ = navigator.mozL10n.get; + var f = new navigator.mozL10n.DateTimeFormat(); + var now = new Date(); + var sec = now.getSeconds(); + if (this._clockTimer) + window.clearTimeout(this._clockTimer); + this._clockTimer = + window.setTimeout((this.update.time).bind(this), (59 - sec) * 1000); + + var formated = f.localeFormat(now, _('shortTimeFormat')); + formated = formated.replace(/\s?(AM|PM)\s?/i, '<span>$1</span>'); + this.icons.time.innerHTML = formated; + + var label = this.icons.label; + var l10nArgs = JSON.parse(label.dataset.l10nArgs || '{}'); + l10nArgs.date = f.localeFormat(now, _('statusbarDateFormat')); + label.dataset.l10nArgs = JSON.stringify(l10nArgs); + this.update.label.call(this); + }, + + battery: function sb_updateBattery() { + var battery = window.navigator.battery; + if (!battery) + return; + + var icon = this.icons.battery; + + icon.hidden = false; + icon.dataset.charging = battery.charging; + icon.dataset.level = Math.floor(battery.level * 10) * 10; + }, + + networkActivity: function sb_updateNetworkActivity() { + // Each time we receive an update, make network activity indicator + // show up for 500ms. + + var icon = this.icons.networkActivity; + + clearTimeout(this._networkActivityTimer); + icon.hidden = false; + + this._networkActivityTimer = setTimeout(function hideNetActivityIcon() { + icon.hidden = true; + }, 500); + }, + + signal: function sb_updateSignal() { + var conn = window.navigator.mozMobileConnection; + if (!conn || !conn.voice) + return; + + var voice = conn.voice; + var icon = this.icons.signal; + var flightModeIcon = this.icons.flightMode; + var _ = navigator.mozL10n.get; + + if (this.settingValues['ril.radio.disabled']) { + // "Airplane Mode" + icon.hidden = true; + flightModeIcon.hidden = false; + return; + } + + flightModeIcon.hidden = true; + icon.hidden = false; + + if (conn.cardState === 'absent') { + // no SIM + delete icon.dataset.level; + delete icon.dataset.emergency; + delete icon.dataset.searching; + delete icon.dataset.roaming; + } else if (voice.connected || this.hasActiveCall()) { + // "Carrier" / "Carrier (Roaming)" + icon.dataset.level = Math.ceil(voice.relSignalStrength / 20); // 0-5 + icon.dataset.roaming = voice.roaming; + + delete icon.dataset.emergency; + delete icon.dataset.searching; + } else { + // "No Network" / "Emergency Calls Only (REASON)" / trying to connect + icon.dataset.level = -1; + // logically, we should have "&& !voice.connected" as well but we + // already know this. + icon.dataset.searching = (!voice.emergencyCallsOnly && + voice.state !== 'notSearching'); + icon.dataset.emergency = (voice.emergencyCallsOnly); + delete icon.dataset.roaming; + } + + if (voice.emergencyCallsOnly) { + this.addCallListener(); + } else { + this.removeCallListener(); + } + + }, + + data: function sb_updateSignal() { + var conn = window.navigator.mozMobileConnection; + if (!conn || !conn.data) + return; + + var data = conn.data; + var icon = this.icons.data; + + if (this.settingValues['ril.radio.disabled'] || + !this.settingValues['ril.data.enabled'] || + !this.icons.wifi.hidden || !data.connected) { + icon.hidden = true; + + return; + } + + icon.hidden = false; + icon.dataset.type = + this.mobileDataIconTypes[data.type] || 'circle'; + }, + + + wifi: function sb_updateWifi() { + var wifiManager = window.navigator.mozWifiManager; + if (!wifiManager) + return; + + var icon = this.icons.wifi; + var wasHidden = icon.hidden; + + if (!this.settingValues['wifi.enabled']) { + icon.hidden = true; + if (!wasHidden) + this.update.data.call(this); + + return; + } + + switch (wifiManager.connection.status) { + case 'disconnected': + icon.hidden = true; + + break; + + case 'connecting': + case 'associated': + icon.hidden = false; + icon.dataset.connecting = true; + icon.dataset.level = 0; + + break; + + case 'connected': + icon.hidden = false; + + var relSignalStrength = + wifiManager.connectionInformation.relSignalStrength; + icon.dataset.level = Math.floor(relSignalStrength / 25); + + break; + } + + if (icon.hidden !== wasHidden) + this.update.data.call(this); + }, + + tethering: function sb_updateTethering() { + var icon = this.icons.tethering; + icon.hidden = !(this.settingValues['tethering.usb.enabled'] || + this.settingValues['tethering.wifi.enabled']); + + icon.dataset.active = + (this.settingValues['tethering.wifi.connectedClients'] !== 0) || + (this.settingValues['tethering.usb.connectedClients'] !== 0); + }, + + bluetooth: function sb_updateBluetooth() { + var icon = this.icons.bluetooth; + + icon.hidden = !this.settingValues['bluetooth.enabled']; + icon.dataset.active = Bluetooth.connected; + }, + + alarm: function sb_updateAlarm() { + this.icons.alarm.hidden = !this.settingValues['alarm.enabled']; + }, + + mute: function sb_updateMute() { + this.icons.mute.hidden = + (this.settingValues['ring.enabled'] == true); + }, + + vibration: function sb_vibration() { + var vibrate = (this.settingValues['vibration.enabled'] == true); + if (vibrate) { + this.icons.mute.classList.add('vibration'); + } else { + this.icons.mute.classList.remove('vibration'); + } + }, + + recording: function sb_updateRecording() { + window.clearTimeout(this.recordingTimer); + + var icon = this.icons.recording; + icon.dataset.active = this.recordingActive; + + if (this.recordingActive) { + // Geolocation is currently active, show the active icon. + icon.hidden = false; + return; + } + + // Geolocation is currently inactive. + // Show the inactive icon and hide it after kActiveIndicatorTimeout + this.recordingTimer = window.setTimeout(function hideGeoIcon() { + icon.hidden = true; + }, this.kActiveIndicatorTimeout); + }, + + sms: function sb_updateSms() { + // We are not going to show this for v1 + + // this.icon.sms.hidden = ? + // this.icon.sms.dataset.num = ?; + }, + + geolocation: function sb_updateGeolocation() { + window.clearTimeout(this.geolocationTimer); + + var icon = this.icons.geolocation; + icon.dataset.active = this.geolocationActive; + + if (this.geolocationActive) { + // Geolocation is currently active, show the active icon. + icon.hidden = false; + return; + } + + // Geolocation is currently inactive. + // Show the inactive icon and hide it after kActiveIndicatorTimeout + this.geolocationTimer = window.setTimeout(function hideGeoIcon() { + icon.hidden = true; + }, this.kActiveIndicatorTimeout); + }, + + usb: function sb_updateUsb() { + var icon = this.icons.usb; + icon.hidden = !this.umsActive; + }, + + headphones: function sb_updateHeadphones() { + var icon = this.icons.headphones; + icon.hidden = !this.headphonesActive; + }, + + systemDownloads: function sb_updatesystemDownloads() { + var icon = this.icons.systemDownloads; + icon.hidden = (this.systemDownloadsCount === 0); + }, + + callForwarding: function sb_updateCallForwarding() { + var icon = this.icons.callForwarding; + icon.hidden = !this.settingValues['ril.cf.enabled']; + } + }, + + hasActiveCall: function sb_hasActiveCall() { + var telephony = navigator.mozTelephony; + + // will return true as soon as we begin dialing + return !!(telephony && telephony.active); + }, + + addCallListener: function sb_addCallListener() { + var telephony = navigator.mozTelephony; + if (telephony) { + telephony.addEventListener('callschanged', this); + } + }, + + removeCallListener: function sb_addCallListener() { + var telephony = navigator.mozTelephony; + if (telephony) { + telephony.removeEventListener('callschanged', this); + } + }, + + updateNotification: function sb_updateNotification(count) { + var icon = this.icons.notification; + if (!count) { + icon.hidden = true; + return; + } + + icon.hidden = false; + icon.dataset.num = count; + }, + + updateNotificationUnread: function sb_updateNotificationUnread(unread) { + this.icons.notification.dataset.unread = unread; + }, + + incSystemDownloads: function sb_incSystemDownloads() { + this.systemDownloadsCount++; + this.update.systemDownloads.call(this); + }, + + decSystemDownloads: function sb_decSystemDownloads() { + if (--this.systemDownloadsCount < 0) { + this.systemDownloadsCount = 0; + } + + this.update.systemDownloads.call(this); + }, + + getAllElements: function sb_getAllElements() { + // ID of elements to create references + + var toCamelCase = function toCamelCase(str) { + return str.replace(/\-(.)/g, function replacer(str, p1) { + return p1.toUpperCase(); + }); + }; + + this.ELEMENTS.forEach((function createElementRef(name) { + this.icons[toCamelCase(name)] = + document.getElementById('statusbar-' + name); + }).bind(this)); + + this.element = document.getElementById('statusbar'); + this.screen = document.getElementById('screen'); + this.attentionBar = document.getElementById('attention-bar'); + } +}; + +if (navigator.mozL10n.readyState == 'complete' || + navigator.mozL10n.readyState == 'interactive') { + StatusBar.init(); +} else { + window.addEventListener('localized', StatusBar.init.bind(StatusBar)); +} + + diff --git a/apps/system/js/storage.js b/apps/system/js/storage.js new file mode 100644 index 0000000..ff7b10e --- /dev/null +++ b/apps/system/js/storage.js @@ -0,0 +1,60 @@ +var Storage = { + + automounterDisable: 0, + automounterEnable: 1, + automounterDisableWhenUnplugged: 2, + + umsEnabled: 'ums.enabled', + umsMode: 'ums.mode', + + init: function storageInit() { + this.setMode(this.automounterDisable, 'init'); + window.addEventListener('lock', this); + window.addEventListener('unlock', this); + + SettingsListener.observe(this.umsEnabled, false, function umsChanged(val) { + if (LockScreen.locked) { + // covers startup + Storage.setMode(Storage.automounterDisable, 'screen locked'); + } else { + Storage.setMode(Storage.modeFromBool(val), 'change in ums.enabled'); + } + }); + }, + + modeFromBool: function storageModeFromBool(val) { + return val ? this.automounterEnable : this.automounterDisable; + }, + + setMode: function storageSetMode(val, reason) { + if (!window.navigator.mozSettings) + return; + + //console.info('Setting', this.umsMode, 'to', val, 'due to', reason); + var param = {}; + param[this.umsMode] = val; + SettingsListener.getSettingsLock().set(param); + }, + + handleEvent: function storageHandleEvent(e) { + switch (e.type) { + case 'lock': + this.setMode(this.automounterDisableWhenUnplugged, 'screen locked'); + break; + case 'unlock': + if (!window.navigator.mozSettings) + return; + + var req = SettingsListener.getSettingsLock().get(this.umsEnabled); + req.onsuccess = function umsEnabledFetched() { + var mode = Storage.modeFromBool(req.result[Storage.umsEnabled]); + Storage.setMode(mode, 'screen unlocked'); + }; + break; + default: + return; + } + } +}; + +Storage.init(); diff --git a/apps/system/js/system_banner.js b/apps/system/js/system_banner.js new file mode 100644 index 0000000..89028e2 --- /dev/null +++ b/apps/system/js/system_banner.js @@ -0,0 +1,33 @@ +'use strict'; + +var SystemBanner = { + banner: document.getElementById('system-banner'), + + // Shows a banner with a given message. + // Optionally shows a button with a given label/callback. + // buttonParams = { label: ..., callback: ... } + show: function sb_show(message, buttonParams) { + var banner = this.banner; + banner.firstElementChild.textContent = message; + + var button = banner.querySelector('button'); + if (buttonParams) { + banner.dataset.button = true; + button.textContent = buttonParams.label; + button.addEventListener('click', buttonParams.callback); + } + + banner.addEventListener('animationend', function animationend() { + banner.removeEventListener('animationend', animationend); + banner.classList.remove('visible'); + + if (buttonParams) { + banner.dataset.button = false; + button.removeEventListener('click', buttonParams.callback); + button.classList.remove('visible'); + } + }); + + banner.classList.add('visible'); + } +}; diff --git a/apps/system/js/system_dialog.js b/apps/system/js/system_dialog.js new file mode 100644 index 0000000..b0c32d8 --- /dev/null +++ b/apps/system/js/system_dialog.js @@ -0,0 +1,113 @@ +/* -*- Mode: js; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ +'use strict'; + +/** + * System app is made of a top-level `<div ="screen"></div>` DOM element + * which contain all possible screens displayed by the app. + * Multiple screens can be displayed at a time. We store the list of currently + * visible screens into this DOM element class attribute. + */ +var SystemScreen = { + screen: document.getElementById('screen'), + + show: function ss_show(screenName) { + this.screen.classList.add(screenName); + }, + + hide: function ss_show(screenName) { + this.screen.classList.remove(screenName); + }, + + isVisible: function ss_isVisible(screenName) { + return this.screen.classList.contains(screenName); + } +}; + +/** + * System app displays various kind of dialogs. + * A dialog is a system app 'screen' that has a high z-index and is used to be + * displayed on top of other apps. But it doesn't display over the status bar, + * nor the eventually displayed keyboard. + * + * `SystemDialog` except the dialog DOM Element `id`. + * This DOM Element has to have a DOM attribute 'role' set to 'dialog'. + * + * It also supports a second `options` object with following attributes: + * `onHide`: function called when dialog is hidden, either when `hide()` + * method is called, or when dialog is automatically hidden on + * home button press + */ +function SystemDialog(id, options) { + var overlay = document.getElementById('dialog-overlay'); + var dialog = document.getElementById(id); + var screenName = 'dialog'; + + // Listen to keyboard visibility changes and window resizing + // in order to resize the dialog accordingly + function updateHeight(keyboardHeight) { + if (SystemScreen.isVisible(screenName)) { + var height = window.innerHeight - + (keyboardHeight ? keyboardHeight : 0) - + StatusBar.height; + overlay.style.height = height + 'px'; + } + }; + function handleEvent(evt) { + switch (evt.type) { + case 'resize': + case 'keyboardhide': + updateHeight(); + break; + case 'keyboardchange': + updateHeight(evt.detail.height); + break; + case 'home': + case 'holdhome': + // Automatically hide the dialog on home button press + if (SystemScreen.isVisible(screenName)) { + hide(evt.type); + // Prevent WindowManager to shift homescreen to the first page + // when the dialog is on top of the homescreen + var displayedApp = WindowManager.getDisplayedApp(); + var displayedAppFrame = WindowManager.getAppFrame(displayedApp); + if (evt.type == 'home' && + displayedAppFrame.classList.contains('homescreen')) + evt.stopImmediatePropagation(); + } + break; + } + }; + window.addEventListener('resize', handleEvent); + window.addEventListener('keyboardchange', handleEvent); + window.addEventListener('keyboardhide', handleEvent); + window.addEventListener('home', handleEvent); + window.addEventListener('holdhome', handleEvent); + + function show() { + dialog.hidden = false; + dialog.classList.add(id); + SystemScreen.show(screenName); + updateHeight(); + } + + function hide(reason) { + dialog.hidden = true; + dialog.classList.remove(id); + SystemScreen.hide(screenName); + if (typeof(options.onHide) == 'function') + options.onHide(reason); + } + + function isVisible() { + return SystemScreen.isVisible(screenName) && + overlay.classList.contains(id); + } + + return { + show: show, + hide: hide, + isVisible: isVisible + }; +} + diff --git a/apps/system/js/trusted_ui.js b/apps/system/js/trusted_ui.js new file mode 100644 index 0000000..43bea21 --- /dev/null +++ b/apps/system/js/trusted_ui.js @@ -0,0 +1,334 @@ +/* -*- Mode: js2; js2-basic-offset: 2; indent-tabs-mode: nil -*- */ +/* vim: set ft=javascript sw=2 ts=2 autoindent cindent expandtab: */ + +'use strict'; + +var TrustedUIManager = { + + get currentStack() { + if (!this._dialogStacks[this._lastDisplayedApp]) { + this._dialogStacks[this._lastDisplayedApp] = []; + } + return this._dialogStacks[this._lastDisplayedApp]; + }, + + _dialogStacks: {}, + _lastDisplayedApp: null, + + overlay: document.getElementById('dialog-overlay'), + + popupContainer: document.getElementById('trustedui-container'), + + popupContainerInner: document.getElementById('trustedui-inner'), + + container: document.getElementById('trustedui-frame-container'), + + dialogTitle: document.getElementById('trustedui-title'), + + screen: document.getElementById('screen'), + + loadingIcon: document.getElementById('statusbar-loading'), + + throbber: document.getElementById('trustedui-throbber'), + + closeButton: document.getElementById('trustedui-close'), + + hasTrustedUI: function trui_hasTrustedUI(origin) { + return (this._dialogStacks[origin] && this._dialogStacks[origin].length); + }, + + getDialogFromOrigin: function trui_getDialogFromOrigin(origin) { + if (!origin || !this.hasTrustedUI(origin)) + return false; + var stack = this._dialogStacks[origin]; + return stack[stack.length - 1]; + }, + + init: function trui_init() { + window.addEventListener('home', this); + window.addEventListener('holdhome', this); + window.addEventListener('appwillopen', this); + window.addEventListener('appopen', this); + window.addEventListener('appwillclose', this); + window.addEventListener('appterminated', this); + window.addEventListener('keyboardhide', this); + window.addEventListener('keyboardchange', this); + window.addEventListener('mozbrowserloadstart', this); + window.addEventListener('mozbrowserloadend', this); + this.closeButton.addEventListener('click', this); + }, + + hideTrustedApp: function trui_hideTrustedApp() { + var self = this; + this.popupContainer.classList.add('closing'); + this.popupContainer.addEventListener('transitionend', function hide() { + this.removeEventListener('transitionend', hide); + self.hide(); + }); + }, + + reopenTrustedApp: function trui_reopenTrustedApp() { + this._hideAllFrames(); + var dialog = this._getTopDialog(); + this._makeDialogVisible(dialog); + this.popupContainer.classList.add('closing'); + this.show(); + this.popupContainer.classList.remove('closing'); + }, + + open: function trui_open(name, frame, chromeEventId, onCancelCB) { + screen.mozLockOrientation('portrait'); + this._hideAllFrames(); + if (this.currentStack.length) { + this._makeDialogHidden(this._getTopDialog()); + this._pushNewDialog(name, frame, chromeEventId, onCancelCB); + } else { + // first time, spin back to home screen first + this.popupContainer.classList.add('up'); + this.popupContainer.classList.remove('closing'); + WindowManager.hideCurrentApp(function openTrustedUI() { + this.popupContainer.classList.remove('up'); + this._pushNewDialog(name, frame, chromeEventId, onCancelCB); + }.bind(this)); + } + }, + + close: function trui_close(chromeEventId, callback) { + var stackSize = this.currentStack.length; + + this._restoreOrientation(); + + if (callback) + callback(); + + if (stackSize === 0) { + // nothing to close. what are you doing? + return; + } else if (stackSize === 1) { + // only one dialog, so transition back to main app + var self = this; + var container = this.popupContainer; + if (!CardsView.cardSwitcherIsShown()) { + WindowManager.restoreCurrentApp(); + container.addEventListener('transitionend', function wait(event) { + this.removeEventListener('transitionend', wait); + self._closeDialog(chromeEventId); + }); + } else { + WindowManager.restoreCurrentApp(this._lastDisplayedApp); + this._closeDialog(chromeEventId); + } + + // The css transition caused by the removal of the trustedui + // class by the hide() method will trigger a 'transitionend' + // event ultimately to be fired. + this.hide(); + + window.focus(); + } else { + this._closeDialog(chromeEventId); + } + }, + + _dispatchCloseEvent: function dispatchCloseEvent(eventId) { + var _ = navigator.mozL10n.get; + if (!eventId) + return; + var event = document.createEvent('customEvent'); + var details = { + id: eventId, + type: 'cancel', + errorMsg: _('dialog-closed') + }; + event.initCustomEvent('mozContentEvent', true, true, details); + window.dispatchEvent(event); + }, + + _getTopDialog: function trui_getTopDialog() { + // get the topmost dialog for the _lastDisplayedApp or null + return this.currentStack[this.currentStack.length - 1]; + }, + + _pushNewDialog: function trui_PushNewDialog(name, frame, chromeEventId, + onCancelCB) { + // add some data attributes to the frame + var dataset = frame.dataset; + dataset.frameType = 'popup'; + dataset.frameName = frame.name; + dataset.frameOrigin = this._lastDisplayedApp; + + // make a shiny new dialog object + var dialog = { + name: name, + frame: frame, + chromeEventId: chromeEventId, + onCancelCB: onCancelCB + }; + + // push and show + this.currentStack.push(dialog); + this.dialogTitle.textContent = dialog.name; + this.container.appendChild(dialog.frame); + this._makeDialogVisible(dialog); + }, + + _makeDialogVisible: function trui_makeDialogVisible(dialog) { + // make sure the trusty ui is visible + this.popupContainer.classList.remove('closing'); + this.show(); + + // ensure the frame is visible and the dialog title is correct. + dialog.frame.classList.add('selected'); + this.dialogTitle.textContent = dialog.name; + }, + + _makeDialogHidden: function trui_makeDialogHidden(dialog) { + if (!dialog) + return; + this._restoreOrientation(); + dialog.frame.classList.remove('selected'); + }, + + _restoreOrientation: function trui_restoreOrientation() { + var app = WindowManager.getDisplayedApp(); + WindowManager.setOrientationForApp(app); + }, + + /** + * close the dialog identified by the chromeEventId + */ + _closeDialog: function trui_closeDialog(chromeEventId) { + if (this.currentStack.length === 0) + return; + + var found = false; + for (var i = 0; i < this.currentStack.length; i++) { + if (this.currentStack[i].chromeEventId === chromeEventId) { + var dialog = this.currentStack.splice(i, 1)[0]; + this.container.removeChild(dialog.frame); + found = true; + break; + } + } + + if (found && this.currentStack.length) { + this._makeDialogVisible(this._getTopDialog()); + } + }, + + hide: function trui_hide() { + this.screen.classList.remove('trustedui'); + }, + + show: function trui_show() { + this.screen.classList.add('trustedui'); + }, + + isVisible: function trui_show() { + return this.screen.classList.contains('trustedui'); + }, + + setHeight: function trui_setHeight(height) { + this.overlay.style.height = height + 'px'; + }, + + /* + * _destroyDialog: internal method called when the dialog is closed + * by user action (canceled), or when 'appterminated' is received. + * In either case, notify the caller. + */ + _destroyDialog: function trui_destroyDialog(origin) { + var stack = this.currentStack; + if (origin) + stack = this._dialogStacks[origin]; + + if (stack.length === 0) + return; + + // If the user closed a trusty UI dialog, they probably meant + // to close every dialog. + for (var i = 0, toClose = stack.length; i < toClose; i++) { + var dialog = this._getTopDialog(); + + // First, send a chrome event saying we've been canceled + this._dispatchCloseEvent(dialog.chromeEventId); + + // Now close and fire the cancel callback, if it exists + this.close(dialog.chromeEventId, dialog.onCancelCB); + } + this.hide(); + this.popupContainer.classList.remove('closing'); + }, + + _hideAllFrames: function trui_hideAllFrames() { + var selectedFrames = this.container.querySelectorAll('iframe.selected'); + for (var i = 0; i < selectedFrames.length; i++) { + selectedFrames[i].classList.remove('selected'); + } + }, + + handleEvent: function trui_handleEvent(evt) { + switch (evt.type) { + case 'home': + case 'holdhome': + if (!this.isVisible()) + return; + + this.hideTrustedApp(); + break; + case 'click': + // Close-button clicked + this._destroyDialog(); + break; + case 'appterminated': + this._destroyDialog(evt.detail.origin); + break; + case 'appwillopen': + // Hiding trustedUI when coming from Activity + if (this.isVisible()) + this.hideTrustedApp(); + + // Ignore homescreen + if (evt.target.classList.contains('homescreen')) + return; + this._lastDisplayedApp = evt.detail.origin; + if (this.currentStack.length) { + // Reopening an app with trustedUI + this.popupContainer.classList.remove('up'); + this._makeDialogVisible(this._getTopDialog()); + WindowManager.hideCurrentApp(); + this.reopenTrustedApp(); + } + break; + case 'appopen': + if (this.currentStack.length) { + screen.mozLockOrientation('portrait'); + } + break; + case 'appwillclose': + if (this.isVisible()) + return; + var dialog = this._getTopDialog(); + this._makeDialogHidden(dialog); + this.hide(); + break; + case 'keyboardchange': + this.setHeight(window.innerHeight - + StatusBar.height - evt.detail.height); + break; + case 'keyboardhide': + this.setHeight(window.innerHeight - StatusBar.height); + break; + case 'mozbrowserloadstart': + this.throbber.classList.add('loading'); + break; + case 'mozbrowserloadend': + this.throbber.classList.remove('loading'); + break; + } + } + +}; + +TrustedUIManager.init(); + diff --git a/apps/system/js/ttlview.js b/apps/system/js/ttlview.js new file mode 100644 index 0000000..81503af --- /dev/null +++ b/apps/system/js/ttlview.js @@ -0,0 +1,50 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +var TTLView = { + element: null, + + get visible() { + return this.element && this.element.style.display === 'block'; + }, + + hide: function tv_hide() { + if (this.element) + this.element.style.visibility = 'hidden'; + }, + + show: function tv_show() { + var element = this.element; + if (!element) { + element = document.createElement('div'); + element.id = 'debug-ttl'; + element.innerHTML = '00000'; + element.dataset.zIndexLevel = 'debug-ttl'; + + this.element = element; + document.getElementById('screen').appendChild(element); + + // this is fired when the app launching is initialized + window.addEventListener('appwillopen', function willopen(e) { + element.innerHTML = '00000'; + }); + + window.addEventListener('apploadtime', function apploadtime(e) { + element.innerHTML = e.detail.time + ' [' + e.detail.type + ']'; + }); + } + + element.style.visibility = 'visible'; + }, + + toggle: function tv_toggle() { + this.visible ? this.hide() : this.show(); + } +}; + +SettingsListener.observe('debug.ttl.enabled', false, function(value) { + !!value ? TTLView.show() : TTLView.hide(); +}); + diff --git a/apps/system/js/updatable.js b/apps/system/js/updatable.js new file mode 100644 index 0000000..57ce737 --- /dev/null +++ b/apps/system/js/updatable.js @@ -0,0 +1,269 @@ +'use strict'; + +/* + * An Updatable object represents an application *or* system update. + * It takes care of the interaction with the UpdateManager and observes + * the update itself to handle success/error cases. + * + * - name of the update + * - size of the update + * - download() to start the download + * - cancelDownload() to cancel it + */ + +/* === App Updates === */ +function AppUpdatable(app) { + this._mgmt = navigator.mozApps.mgmt; + this.app = app; + + var manifest = app.manifest ? app.manifest : app.updateManifest; + this.name = new ManifestHelper(manifest).name; + + this.size = app.downloadSize; + this.progress = null; + + UpdateManager.addToUpdatableApps(this); + app.ondownloadavailable = this.availableCallBack.bind(this); + if (app.downloadAvailable) { + this.availableCallBack(); + } + if (app.readyToApplyDownload) { + this.applyUpdate(); + } +} + +AppUpdatable.prototype.download = function() { + UpdateManager.addToDownloadsQueue(this); + this.progress = 0; + + this.app.download(); +}; + +AppUpdatable.prototype.cancelDownload = function() { + this.app.cancelDownload(); +}; + +AppUpdatable.prototype.uninit = function() { + this.app.ondownloadavailable = null; + this.clean(); +}; + +AppUpdatable.prototype.clean = function() { + this.app.ondownloaderror = null; + this.app.ondownloadsuccess = null; + this.app.ondownloadapplied = null; + this.app.onprogress = null; + + this.progress = null; +}; + +AppUpdatable.prototype.availableCallBack = function() { + this.size = this.app.downloadSize; + + if (this.app.installState === 'installed') { + UpdateManager.addToUpdatesQueue(this); + + // we add these callbacks only now to prevent interfering + // with other modules (especially the AppInstallManager) + this.app.ondownloaderror = this.errorCallBack.bind(this); + this.app.ondownloadsuccess = this.successCallBack.bind(this); + this.app.ondownloadapplied = this.appliedCallBack.bind(this); + this.app.onprogress = this.progressCallBack.bind(this); + } +}; + +AppUpdatable.prototype.successCallBack = function() { + var app = this.app; + if (WindowManager.getDisplayedApp() !== app.origin) { + this.applyUpdate(); + } else { + var self = this; + window.addEventListener('appwillclose', function waitClose() { + window.removeEventListener('appwillclose', waitClose); + self.applyUpdate(); + }); + } + + UpdateManager.removeFromDownloadsQueue(this); + UpdateManager.removeFromUpdatesQueue(this); +}; + +AppUpdatable.prototype.applyUpdate = function() { + WindowManager.kill(this.app.origin); + this._mgmt.applyDownload(this.app); +}; + +AppUpdatable.prototype.appliedCallBack = function() { + this.clean(); +}; + +AppUpdatable.prototype.errorCallBack = function(e) { + var errorName = e.application.downloadError.name; + console.info('downloadError event, error code is', errorName); + UpdateManager.requestErrorBanner(); + UpdateManager.removeFromDownloadsQueue(this); + this.progress = null; +}; + +AppUpdatable.prototype.progressCallBack = function() { + if (this.progress === null) { + // this is the first progress + UpdateManager.addToDownloadsQueue(this); + this.progress = 0; + } + + var delta = this.app.progress - this.progress; + + this.progress = this.app.progress; + UpdateManager.downloadProgressed(delta); +}; + +/* + * System Updates + * Will be instanciated only once by the UpdateManager + * + */ +function SystemUpdatable() { + var _ = navigator.mozL10n.get; + this.name = _('systemUpdate'); + this.size = 0; + this.downloading = false; + this.paused = false; + + // XXX: this state should be kept on the platform side + // https://bugzilla.mozilla.org/show_bug.cgi?id=827090 + this.checkKnownUpdate(UpdateManager.checkForUpdates.bind(UpdateManager)); + + window.addEventListener('mozChromeEvent', this); +} + +SystemUpdatable.KNOWN_UPDATE_FLAG = 'known-sysupdate'; + +SystemUpdatable.prototype.download = function() { + if (this.downloading) { + return; + } + + this.downloading = true; + this.paused = false; + this._dispatchEvent('update-available-result', 'download'); + UpdateManager.addToDownloadsQueue(this); + this.progress = 0; +}; + +SystemUpdatable.prototype.cancelDownload = function() { + this._dispatchEvent('update-download-cancel'); + this.downloading = false; + this.paused = false; +}; + +SystemUpdatable.prototype.uninit = function() { + window.removeEventListener('mozChromeEvent', this); +}; + +SystemUpdatable.prototype.handleEvent = function(evt) { + if (evt.type !== 'mozChromeEvent') + return; + + var detail = evt.detail; + if (!detail.type) + return; + + switch (detail.type) { + case 'update-error': + this.errorCallBack(); + break; + case 'update-download-started': + // TODO UpdateManager glue + this.paused = false; + break; + case 'update-download-progress': + var delta = detail.progress - this.progress; + this.progress = detail.progress; + + UpdateManager.downloadProgressed(delta); + break; + case 'update-download-stopped': + // TODO UpdateManager glue + this.paused = detail.paused; + if (!this.paused) { + UpdateManager.startedUncompressing(); + } + break; + case 'update-downloaded': + this.downloading = false; + this.showApplyPrompt(); + break; + case 'update-prompt-apply': + this.showApplyPrompt(); + break; + } +}; + +SystemUpdatable.prototype.errorCallBack = function() { + UpdateManager.requestErrorBanner(); + UpdateManager.removeFromDownloadsQueue(this); + this.downloading = false; +}; + +SystemUpdatable.prototype.showApplyPrompt = function() { + var _ = navigator.mozL10n.get; + + // Update will be completed after restart + this.forgetKnownUpdate(); + + var cancel = { + title: _('later'), + callback: this.declineInstall.bind(this) + }; + + var confirm = { + title: _('installNow'), + callback: this.acceptInstall.bind(this) + }; + + UtilityTray.hide(); + CustomDialog.show(_('systemUpdateReady'), _('wantToInstall'), + cancel, confirm); +}; + +SystemUpdatable.prototype.declineInstall = function() { + CustomDialog.hide(); + this._dispatchEvent('update-prompt-apply-result', 'wait'); + + UpdateManager.removeFromDownloadsQueue(this); +}; + +SystemUpdatable.prototype.acceptInstall = function() { + CustomDialog.hide(); + this._dispatchEvent('update-prompt-apply-result', 'restart'); +}; + +SystemUpdatable.prototype.rememberKnownUpdate = function() { + asyncStorage.setItem(SystemUpdatable.KNOWN_UPDATE_FLAG, true); +}; + +SystemUpdatable.prototype.checkKnownUpdate = function(callback) { + if (typeof callback !== 'function') { + return; + } + + asyncStorage.getItem(SystemUpdatable.KNOWN_UPDATE_FLAG, function(value) { + callback(!!value); + }); +}; + +SystemUpdatable.prototype.forgetKnownUpdate = function() { + asyncStorage.removeItem(SystemUpdatable.KNOWN_UPDATE_FLAG); +}; + +SystemUpdatable.prototype._dispatchEvent = function(type, result) { + var event = document.createEvent('CustomEvent'); + var data = { type: type }; + if (result) { + data.result = result; + } + + event.initCustomEvent('mozContentEvent', true, true, data); + window.dispatchEvent(event); +}; diff --git a/apps/system/js/update_manager.js b/apps/system/js/update_manager.js new file mode 100644 index 0000000..8e37733 --- /dev/null +++ b/apps/system/js/update_manager.js @@ -0,0 +1,623 @@ +'use strict'; + +/* + * The UpdateManager is a central component for apps *and* system updates. + * The user can start or cancel all downloads at once. + * This component also makes sure of bothering the user to a minimum by + * showing active notifications for new updates/errors only once. + * + * It maintains 2 queues of Updatable objects. + * - updatesQueue for available updates + * - downloadsQueue for active downloads + */ + +var UpdateManager = { + _mgmt: null, + _downloading: false, + _uncompressing: false, + _downloadedBytes: 0, + _errorTimeout: null, + _wifiLock: null, + _systemUpdateDisplayed: false, + _isDataConnectionWarningDialogEnabled: true, + _settings: null, + _conn: null, + NOTIFICATION_BUFFERING_TIMEOUT: 30 * 1000, + TOASTER_TIMEOUT: 1200, + + container: null, + message: null, + toaster: null, + toasterMessage: null, + laterButton: null, + notnowButton: null, + downloadButton: null, + downloadViaDataConnectionButton: null, + downloadDialog: null, + downloadViaDataConnectionDialog: null, + downloadDialogTitle: null, + downloadDialogList: null, + lastUpdatesAvailable: 0, + _notificationTimeout: null, + + updatableApps: [], + systemUpdatable: null, + updatesQueue: [], + downloadsQueue: [], + + init: function um_init() { + if (!this._mgmt) { + this._mgmt = navigator.mozApps.mgmt; + } + + this._mgmt.getAll().onsuccess = (function gotAll(evt) { + var apps = evt.target.result; + apps.forEach(function appIterator(app) { + new AppUpdatable(app); + }); + }).bind(this); + + this._settings = navigator.mozSettings; + + this.systemUpdatable = new SystemUpdatable(); + + this.container = document.getElementById('update-manager-container'); + this.message = this.container.querySelector('.message'); + + this.toaster = document.getElementById('update-manager-toaster'); + this.toasterMessage = this.toaster.querySelector('.message'); + + this.laterButton = document.getElementById('updates-later-button'); + this.notnowButton = + document.getElementById('updates-viaDataConnection-notnow-button'); + this.downloadButton = document.getElementById('updates-download-button'); + this.downloadViaDataConnectionButton = + document.getElementById('updates-viaDataConnection-download-button'); + this.downloadDialog = document.getElementById('updates-download-dialog'); + this.downloadDialogTitle = this.downloadDialog.querySelector('h1'); + this.downloadDialogList = this.downloadDialog.querySelector('ul'); + this.downloadViaDataConnectionDialog = + document.getElementById('updates-viaDataConnection-dialog'); + + this.container.onclick = this.containerClicked.bind(this); + this.laterButton.onclick = this.cancelPrompt.bind(this); + this.downloadButton.onclick = this.requestDownloads.bind(this); + this.downloadDialogList.onchange = this.updateDownloadButton.bind(this); + this.notnowButton.onclick = + this.cancelDataConnectionUpdatesPrompt.bind(this); + this.downloadViaDataConnectionButton.onclick = + this.requestDownloads.bind(this); + + window.addEventListener('mozChromeEvent', this); + window.addEventListener('applicationinstall', this); + window.addEventListener('applicationuninstall', this); + window.addEventListener('online', this); + window.addEventListener('offline', this); + + SettingsListener.observe('gaia.system.checkForUpdates', false, + this.checkForUpdates.bind(this)); + + // We maintain the the edge and nowifi data attributes to show + // a warning on the download dialog + window.addEventListener('wifi-statuschange', this); + this.updateWifiStatus(); + this.updateOnlineStatus(); + + this._conn = window.navigator.mozMobileConnection; + if (this._conn) { + this._conn.addEventListener('datachange', this); + this.updateDataStatus(); + } + + window.asyncStorage. + getItem('gaia.system.isDataConnectionWarningDialogEnabled', + (function(value) { + value = value || true; + this._isDataConnectionWarningDialogEnabled = true; + this.downloadDialog.dataset.dataConnectionInlineWarning = !value; + }).bind(this)); + }, + + requestDownloads: function um_requestDownloads(evt) { + evt.preventDefault(); + + if (evt.target == this.downloadViaDataConnectionButton) { + window.asyncStorage. + setItem('gaia.system.isDataConnectionWarningDialogEnabled', false); + this._isDataConnectionWarningDialogEnabled = false; + this.downloadDialog.dataset.dataConnectionInlineWarning = true; + this.startDownloads(); + } else { + if (this._isDataConnectionWarningDialogEnabled && + this.downloadDialog.dataset.nowifi) { + this.downloadViaDataConnectionDialog.classList.add('visible'); + } else { + this.startDownloads(); + } + } + }, + + startDownloads: function um_startDownloads() { + this.downloadDialog.classList.remove('visible'); + this.downloadViaDataConnectionDialog.classList.remove('visible'); + + UtilityTray.show(); + + var checkValues = {}; + var dialog = this.downloadDialogList; + var checkboxes = dialog.querySelectorAll('input[type="checkbox"]'); + for (var i = 0; i < checkboxes.length; i++) { + var checkbox = checkboxes[i]; + checkValues[checkbox.dataset.position] = checkbox.checked; + } + + this.updatesQueue.forEach(function(updatable, index) { + // The user opted out of the download + if (updatable.app && !checkValues[index]) { + return; + } + + updatable.download(); + }); + + this._downloadedBytes = 0; + this.render(); + }, + + cancelAllDownloads: function um_cancelAllDownloads() { + CustomDialog.hide(); + + // We're emptying the array while iterating + while (this.downloadsQueue.length) { + var updatable = this.downloadsQueue[0]; + updatable.cancelDownload(); + this.removeFromDownloadsQueue(updatable); + } + }, + + requestErrorBanner: function um_requestErrorBanner() { + if (this._errorTimeout) + return; + + var _ = navigator.mozL10n.get; + var self = this; + this._errorTimeout = setTimeout(function waitForMore() { + SystemBanner.show(_('downloadError')); + self._errorTimeout = null; + }, this.NOTIFICATION_BUFFERING_TIMEOUT); + }, + + containerClicked: function um_containerClicker() { + var _ = navigator.mozL10n.get; + + if (this._downloading) { + var cancel = { + title: _('no'), + callback: this.cancelPrompt.bind(this) + }; + + var confirm = { + title: _('yes'), + callback: this.cancelAllDownloads.bind(this) + }; + + CustomDialog.show(_('cancelAllDownloads'), _('wantToCancelAll'), + cancel, confirm); + } else { + this.showDownloadPrompt(); + } + + UtilityTray.hide(); + }, + + showDownloadPrompt: function um_showDownloadPrompt() { + var _ = navigator.mozL10n.get; + + this._systemUpdateDisplayed = false; + this.downloadDialogTitle.textContent = _('numberOfUpdates', { + n: this.updatesQueue.length + }); + + var updateList = ''; + + // System update should always be on top + this.updatesQueue.sort(function sortUpdates(updatable, otherUpdatable) { + if (!updatable.app) + return -1; + if (!otherUpdatable.app) + return 1; + + if (updatable.name < otherUpdatable.name) + return -1; + if (updatable.name > otherUpdatable.name) + return 1; + return 0; + }); + + this.downloadDialogList.innerHTML = ''; + this.updatesQueue.forEach(function updatableIterator(updatable, index) { + var listItem = document.createElement('li'); + + // The user can choose not to update an app + var checkContainer = document.createElement('label'); + if (updatable instanceof SystemUpdatable) { + checkContainer.textContent = _('required'); + checkContainer.classList.add('required'); + this._systemUpdateDisplayed = true; + } else { + var checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.dataset.position = index; + checkbox.checked = true; + + var span = document.createElement('span'); + + checkContainer.appendChild(checkbox); + checkContainer.appendChild(span); + } + listItem.appendChild(checkContainer); + + var name = document.createElement('div'); + name.classList.add('name'); + name.textContent = updatable.name; + listItem.appendChild(name); + + if (updatable.size) { + var sizeItem = document.createElement('div'); + sizeItem.textContent = this._humanizeSize(updatable.size); + listItem.appendChild(sizeItem); + } else { + listItem.classList.add('nosize'); + } + + this.downloadDialogList.appendChild(listItem); + }, this); + + this.downloadDialog.classList.add('visible'); + }, + + updateDownloadButton: function() { + if (this._systemUpdateDisplayed) { + this.downloadButton.disabled = false; + return; + } + + var disabled = true; + + var dialog = this.downloadDialogList; + var checkboxes = dialog.querySelectorAll('input[type="checkbox"]'); + for (var i = 0; i < checkboxes.length; i++) { + if (checkboxes[i].checked) { + disabled = false; + break; + } + } + + this.downloadButton.disabled = disabled; + }, + + cancelPrompt: function um_cancelPrompt() { + CustomDialog.hide(); + this.downloadDialog.classList.remove('visible'); + }, + + cancelDataConnectionUpdatesPrompt: function um_cancelDCUpdatesPrompt() { + CustomDialog.hide(); + this.downloadViaDataConnectionDialog.classList.remove('visible'); + this.downloadDialog.classList.remove('visible'); + }, + + downloadProgressed: function um_downloadProgress(bytes) { + if (bytes > 0) { + this._downloadedBytes += bytes; + this.render(); + } + }, + + startedUncompressing: function um_startedUncompressing() { + this._uncompressing = true; + this.render(); + }, + + render: function um_render() { + var _ = navigator.mozL10n.get; + + this.toasterMessage.innerHTML = + _('updateAvailableInfo', { + n: this.updatesQueue.length - this.lastUpdatesAvailable + }); + + var message = ''; + if (this._downloading) { + if (this._uncompressing && this.downloadsQueue.length === 1) { + message = _('uncompressingMessage'); + } else { + var humanProgress = this._humanizeSize(this._downloadedBytes); + message = _('downloadingUpdateMessage', { + progress: humanProgress + }); + } + } else { + message = _('updateAvailableInfo', { + n: this.updatesQueue.length + }); + } + + this.message.innerHTML = message; + var css = this.container.classList; + this._downloading ? css.add('downloading') : css.remove('downloading'); + }, + + addToUpdatableApps: function um_addtoUpdatableapps(updatableApp) { + this.updatableApps.push(updatableApp); + }, + + removeFromAll: function um_removeFromAll(updatableApp) { + var removeIndex = this.updatableApps.indexOf(updatableApp); + if (removeIndex === -1) + return; + + var removedApp = this.updatableApps[removeIndex]; + this.removeFromUpdatesQueue(removedApp); + + removedApp.uninit(); + this.updatableApps.splice(removeIndex, 1); + }, + + addToUpdatesQueue: function um_addToUpdatesQueue(updatable) { + if (this._downloading) { + return; + } + + if (updatable.app && + updatable.app.installState !== 'installed') { + return; + } + + if (updatable.app && + this.updatableApps.indexOf(updatable) === -1) { + return; + } + + var alreadyThere = this.updatesQueue.some(function lookup(u) { + return (u.app === updatable.app); + }); + if (alreadyThere) { + return; + } + + this.updatesQueue.push(updatable); + + if (this._notificationTimeout === null) { + this._notificationTimeout = setTimeout(this.displayNotificationAndToaster.bind(this), + this.NOTIFICATION_BUFFERING_TIMEOUT); + } + this.render(); + }, + + displayNotificationAndToaster: function um_displayNotificationAndToaster() { + this._notificationTimeout = null; + if (this.updatesQueue.length && !this._downloading) { + this.lastUpdatesAvailable = this.updatesQueue.length; + StatusBar.updateNotificationUnread(true); + this.displayNotificationIfHidden(); + this.toaster.classList.add('displayed'); + var self = this; + setTimeout(function waitToHide() { + self.toaster.classList.remove('displayed'); + }, this.TOASTER_TIMEOUT); + } + }, + + removeFromUpdatesQueue: function um_removeFromUpdatesQueue(updatable) { + var removeIndex = this.updatesQueue.indexOf(updatable); + if (removeIndex === -1) + return; + + this.updatesQueue.splice(removeIndex, 1); + this.lastUpdatesAvailable = this.updatesQueue.length; + + if (this.updatesQueue.length === 0) { + this.hideNotificationIfDisplayed(); + } + + this.render(); + }, + + addToDownloadsQueue: function um_addToDownloadsQueue(updatable) { + if (updatable.app && + this.updatableApps.indexOf(updatable) === -1) { + return; + } + + var alreadyThere = this.downloadsQueue.some(function lookup(u) { + return (u.app === updatable.app); + }); + if (alreadyThere) { + return; + } + + this.downloadsQueue.push(updatable); + + if (this.downloadsQueue.length === 1) { + this._downloading = true; + StatusBar.incSystemDownloads(); + this._wifiLock = navigator.requestWakeLock('wifi'); + + this.displayNotificationIfHidden(); + this.render(); + } + }, + + removeFromDownloadsQueue: function um_removeFromDownloadsQueue(updatable) { + var removeIndex = this.downloadsQueue.indexOf(updatable); + if (removeIndex === -1) + return; + + this.downloadsQueue.splice(removeIndex, 1); + + if (this.downloadsQueue.length === 0) { + this._downloading = false; + StatusBar.decSystemDownloads(); + this._downloadedBytes = 0; + this.checkStatuses(); + + if (this._wifiLock) { + try { + this._wifiLock.unlock(); + } catch (e) { + // this can happen if the lock is already unlocked + console.error('error during unlock', e); + } + + this._wifiLock = null; + } + + this.render(); + } + }, + + hideNotificationIfDisplayed: function() { + if (this.container.classList.contains('displayed')) { + this.container.classList.remove('displayed'); + NotificationScreen.decExternalNotifications(); + } + }, + + displayNotificationIfHidden: function() { + if (!this.container.classList.contains('displayed')) { + this.container.classList.add('displayed'); + NotificationScreen.incExternalNotifications(); + } + }, + + checkStatuses: function um_checkStatuses() { + this.updatableApps.forEach(function(updatableApp) { + var app = updatableApp.app; + if (app.downloadAvailable) { + this.addToUpdatesQueue(updatableApp); + } + }, this); + }, + + oninstall: function um_oninstall(evt) { + var app = evt.application; + var updatableApp = new AppUpdatable(app); + }, + + onuninstall: function um_onuninstall(evt) { + this.updatableApps.some(function appIterator(updatableApp, index) { + // The application object we get from the event + // has only origin and manifestURL properties + if (updatableApp.app.manifestURL === evt.application.manifestURL) { + this.removeFromAll(updatableApp); + return true; + } + return false; + }, this); + }, + + handleEvent: function um_handleEvent(evt) { + if (!evt.type) + return; + + switch (evt.type) { + case 'applicationinstall': + this.oninstall(evt.detail); + break; + case 'applicationuninstall': + this.onuninstall(evt.detail); + break; + case 'datachange': + this.updateDataStatus(); + break; + case 'offline': + this.updateOnlineStatus(); + break; + case 'online': + this.updateOnlineStatus(); + break; + case 'wifi-statuschange': + this.updateWifiStatus(); + break; + } + + if (evt.type !== 'mozChromeEvent') + return; + + var detail = evt.detail; + + if (detail.type && detail.type === 'update-available') { + this.systemUpdatable.size = detail.size; + this.systemUpdatable.rememberKnownUpdate(); + this.addToUpdatesQueue(this.systemUpdatable); + } + }, + + updateOnlineStatus: function su_updateOnlineStatus() { + var online = (navigator && 'onLine' in navigator) ? navigator.onLine : true; + this.downloadDialog.dataset.online = online; + + if (online) { + this.laterButton.classList.remove('full'); + } else { + this.laterButton.classList.add('full'); + } + }, + + updateWifiStatus: function su_updateWifiStatus() { + var wifiManager = window.navigator.mozWifiManager; + if (!wifiManager) + return; + + this.downloadDialog.dataset.nowifi = + (wifiManager.connection.status != 'connected'); + }, + + checkForUpdates: function su_checkForUpdates(shouldCheck) { + if (!shouldCheck) { + return; + } + + this._dispatchEvent('force-update-check'); + + if (!this._settings) { + return; + } + + var lock = this._settings.createLock(); + lock.set({ + 'gaia.system.checkForUpdates': false + }); + }, + + _dispatchEvent: function um_dispatchEvent(type, result) { + var event = document.createEvent('CustomEvent'); + var data = { type: type }; + if (result) { + data.result = result; + } + + event.initCustomEvent('mozContentEvent', true, true, data); + window.dispatchEvent(event); + }, + + // This is going to be part of l10n.js + _humanizeSize: function um_humanizeSize(bytes) { + var _ = navigator.mozL10n.get; + var units = ['bytes', 'kB', 'MB', 'GB', 'TB', 'PB']; + + if (!bytes) + return '0.00 ' + _(units[0]); + + var e = Math.floor(Math.log(bytes) / Math.log(1024)); + return (bytes / Math.pow(1024, Math.floor(e))).toFixed(2) + ' ' + + _(units[e]); + } +}; + +window.addEventListener('localized', function startup(evt) { + window.removeEventListener('localized', startup); + + UpdateManager.init(); +}); diff --git a/apps/system/js/utility_tray.js b/apps/system/js/utility_tray.js new file mode 100644 index 0000000..13f124d --- /dev/null +++ b/apps/system/js/utility_tray.js @@ -0,0 +1,148 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +var UtilityTray = { + shown: false, + + active: false, + + overlay: document.getElementById('utility-tray'), + + statusbar: document.getElementById('statusbar'), + + screen: document.getElementById('screen'), + + init: function ut_init() { + var touchEvents = ['touchstart', 'touchmove', 'touchend']; + + // XXX: Always use Mouse2Touch here. + // We cannot reliably detect touch support normally + // by evaluate (document instanceof DocumentTouch) on Desktop B2G. + touchEvents.forEach(function bindEvents(name) { + // window.addEventListener(name, this); + Mouse2Touch.addEventHandler(window, name, this); + }, this); + + window.addEventListener('screenchange', this); + window.addEventListener('home', this); + + this.overlay.addEventListener('transitionend', this); + }, + + handleEvent: function ut_handleEvent(evt) { + switch (evt.type) { + case 'home': + if (this.shown) { + this.hide(); + } + break; + + case 'screenchange': + if (this.shown && !evt.detail.screenEnabled) + this.hide(true); + + break; + + case 'touchstart': + if (LockScreen.locked) + return; + if (evt.target !== this.overlay && + evt.target !== this.statusbar) + return; + + this.active = true; + // XXX: required for Mouse2Touch fake events to function + evt.target.setCapture(true); + + this.onTouchStart(evt.touches[0]); + break; + + case 'touchmove': + if (!this.active) + return; + + this.onTouchMove(evt.touches[0]); + break; + + case 'touchend': + if (!this.active) + return; + + this.active = false; + // XXX: required for Mouse2Touch fake events to function + document.releaseCapture(); + + this.onTouchEnd(evt.changedTouches[0]); + break; + + case 'transitionend': + if (!this.shown) + this.screen.classList.remove('utility-tray'); + break; + } + }, + + onTouchStart: function ut_onTouchStart(touch) { + this.startX = touch.pageX; + this.startY = touch.pageY; + this.screen.classList.add('utility-tray'); + this.onTouchMove({ pageY: touch.pageY + this.statusbar.offsetHeight }); + }, + + onTouchMove: function ut_onTouchMove(touch) { + var screenHeight = this.overlay.getBoundingClientRect().height; + var y = touch.pageY; + if (y > this.lastY) + this.opening = true; + else if (y < this.lastY) + this.opening = false; + this.lastY = y; + var dy = -(this.startY - y); + if (this.shown) + dy += screenHeight; + dy = Math.min(screenHeight, dy); + + var style = this.overlay.style; + style.MozTransition = ''; + style.MozTransform = 'translateY(' + dy + 'px)'; + }, + + onTouchEnd: function ut_onTouchEnd(touch) { + this.opening ? this.show() : this.hide(); + }, + + hide: function ut_hide(instant) { + var alreadyHidden = !this.shown; + var style = this.overlay.style; + style.MozTransition = instant ? '' : '-moz-transform 0.2s linear'; + style.MozTransform = 'translateY(0)'; + this.shown = false; + if (instant) + this.screen.classList.remove('utility-tray'); + + if (!alreadyHidden) { + var evt = document.createEvent('CustomEvent'); + evt.initCustomEvent('utilitytrayhide', true, true, null); + window.dispatchEvent(evt); + } + }, + + show: function ut_show(dy) { + var alreadyShown = this.shown; + var style = this.overlay.style; + style.MozTransition = '-moz-transform 0.2s linear'; + style.MozTransform = 'translateY(100%)'; + this.shown = true; + this.screen.classList.add('utility-tray'); + + if (!alreadyShown) { + var evt = document.createEvent('CustomEvent'); + evt.initCustomEvent('utilitytrayshow', true, true, null); + window.dispatchEvent(evt); + } + } +}; + +UtilityTray.init(); diff --git a/apps/system/js/value_selector/date_picker.js b/apps/system/js/value_selector/date_picker.js new file mode 100644 index 0000000..9cdd484 --- /dev/null +++ b/apps/system/js/value_selector/date_picker.js @@ -0,0 +1,568 @@ +/** + * DatePicker is a html/js "widget" which will display + * all the days of a given month and allow selection of + * one specific day. It also implements controls to travel + * between months and jump into arbitrary time. + * + * The DatePicker itself contains no UI for the controls. + * + * Example usage: + * + * // the container will have elements for the month + * // added and removed from it. + * var picker = new DatePicker(container); + * + * // EVENTS: + * + * // called when the user clicks a day in the calendar. + * picker.onvaluechange = function(date) {} + * + * // called when the month of the calendar changes. + * // NOTE: at this time this can only happen programmatically + * // so there is only for control flow. + * picker.onmonthchange = function(date) {} + * + * // display a given year/month/date on the calendar the month + * // is zero based just like the JS date constructor. + * picker.display(2012, 0, 2); + * + * // move to the next month. + * picker.next(); + * + * // move to the previous month + * picker.previous(); + * + */ +var DatePicker = (function() { + 'use strict'; + + const SELECTED = 'selected'; + + var Calc = { + + NEXT_MONTH: 'next-month', + + OTHER_MONTH: 'other-month', + + PRESENT: 'present', + + FUTURE: 'future', + + PAST: 'past', + + get today() { + return new Date(); + }, + + daysInWeek: function() { + //XXX: We need to localize this... + return 7; + }, + + /** + * Checks is given date is today. + * + * @param {Date} date compare. + * @return {Boolean} true when today. + */ + isToday: function(date) { + return Calc.isSameDate(date, Calc.today); + }, + + /** + * Checks if two date objects occur + * on the same date (in the same month, year, day). + * Disregards time. + * + * @param {Date} first date. + * @param {Date} second date. + * @return {Boolean} true when they are the same date. + */ + isSameDate: function(first, second) { + return first.getMonth() == second.getMonth() && + first.getDate() == second.getDate() && + first.getFullYear() == second.getFullYear(); + }, + + /** + * Returns an identifier for a specific + * date in time for a given date + * + * @param {Date} date to get id for. + * @return {String} identifier. + */ + getDayId: function(date) { + return [ + date.getFullYear(), + date.getMonth(), + date.getDate() + ].join('-'); + }, + + /** + * Returns a date object from + * a string id for a date. + * + * @param {String} id identifier for date. + * @return {Date} date output. + */ + dateFromId: function(id) { + var parts = id.split('-'); + return new Date(parts[0], parts[1], parts[2]); + }, + + createDay: function(date, day, month, year) { + return new Date( + typeof year !== 'undefined' ? year : date.getFullYear(), + typeof month !== 'undefined' ? month : date.getMonth(), + typeof day !== 'undefined' ? day : date.getDate() + ); + }, + + /** + * Finds localized week start date of given date. + * + * @param {Date} date any day the week. + * @return {Date} first date in the week of given date. + */ + getWeekStartDate: function(date) { + var currentDay = date.getDay(); + var startDay = date.getDate() - currentDay; + + return Calc.createDay(date, startDay); + }, + + getWeekEndDate: function(date) { + // TODO: There are localization problems + // with this approach as we assume a 7 day week. + var start = Calc.getWeekStartDate(date); + start.setDate(start.getDate() + 7); + start.setMilliseconds(-1); + + return start; + }, + + /** + * Returns an array of dates objects. + * Inclusive. First and last are + * the given instances. + * + * @param {Date} start starting day. + * @param {Date} end ending day. + * @param {Boolean} includeTime include times start/end ? + */ + daysBetween: function(start, end, includeTime) { + if (!(start instanceof Date)) { + throw new Error('start date must be an instanceof Date'); + } + + if (!(end instanceof Date)) { + throw new Error('end date must be an instanceof Date'); + } + + if (start > end) { + var tmp = end; + end = start; + start = tmp; + tmp = null; + } + + var list = []; + var last = start.getDate(); + var cur; + + // for infinite loop protection. + var max = 500; + var macInc = 0; + + while (macInc++ < max) { + var next = new Date( + start.getFullYear(), + start.getMonth(), + ++last + ); + + if (next > end) { + throw new Error( + 'sanity fails next is greater then end' + ); + } + + if (!Calc.isSameDate(next, end)) { + list.push(next); + continue; + } + + break; + } + + if (includeTime) { + list.unshift(start); + list.push(end); + } else { + list.unshift(this.createDay(start)); + list.push(this.createDay(end)); + } + + return list; + }, + + /** + * Checks if date is in the past + * + * @param {Date} date to check. + * @return {Boolean} true when date is in the past. + */ + isPast: function(date) { + return (date.valueOf() < Calc.today.valueOf()); + }, + + /** + * Checks if date is in the future + * + * @param {Date} date to check. + * @return {Boolean} true when date is in the future. + */ + isFuture: function(date) { + return !Calc.isPast(date); + }, + + /** + * Based on the input date + * will return one of the following states + * + * past, present, future + * + * @param {Date} day for compare. + * @param {Date} month comparison month. + * @return {String} state. + */ + relativeState: function(day, month) { + var states; + //var today = Calc.today; + + // 1. the date is today (real time) + if (Calc.isToday(day)) { + return Calc.PRESENT; + } + + // 2. the date is in the past (real time) + if (Calc.isPast(day)) { + states = Calc.PAST; + // 3. the date is in the future (real time) + } else { + states = Calc.FUTURE; + } + + // 4. the date is not in the current month (relative time) + if (day.getMonth() !== month.getMonth()) { + states += ' ' + Calc.OTHER_MONTH; + } + + return states; + } + + }; + + /* expose calc */ + DatePicker.Calc = Calc; + + /** + * Initialize a date picker widget. + * + * @param {HTMLELement} element target of widget creation. + */ + function DatePicker(element) { + this.element = element; + // default time is set so next/previous work + // but we do not render the initial display here. + this._position = new Date(); + + // register events + element.addEventListener('click', this); + + //XXX: When the document is localized again + // we must also re-render the month because + // the week days may have changed? + // This will only happen when we change timezones + // unless we add this information to the locales. + } + + DatePicker.prototype = { + + /** + * Internal value not exposed so we can fire events + * when the getter/setter's are used. + * + * @type Date + */ + _value: null, + + SELECTED: 'selected', + + /** + * Gets current value + * + * @return {Null|Date} date or null. + */ + get value() { + return this._value; + }, + + /** + * Sets the current value of the date picker. + * When value differs from the currently set the + * `onvaluechange` event will be fired with the new/old value. + */ + set value(value) { + var old = this._value; + if (old !== value) { + this._value = value; + this._clearSelectedDay(value); + this.onvaluechange(value, old); + } + }, + + /** + * Clears the currently selected date of its 'selected' class. + * @private + */ + _clearSelectedDay: function(value) { + var target = this.element.querySelector('.' + SELECTED); + if (target) { + target.classList.remove(SELECTED); + } + }, + + handleEvent: function(e) { + switch (e.type) { + case 'click': + var target = e.target; + //XXX: if the html of the date elements changes + // this may also need to be altered as it + // assumes that there is no nesting of elements. + if (target.dataset.date) { + var date = Calc.dateFromId(target.dataset.date); + // order here is important as setting value will + // clear all the past selected dates... + this.value = date; + this._position = date; + // must come after setting selected date + target.classList.add(SELECTED); + } + break; + } + }, + + /** + * Getter is used for date normalization. + */ + get year() { + return this._position.getFullYear(); + }, + + /** + * Getter is used for date normalization. + */ + get month() { + return this._position.getMonth(); + }, + + get date() { + return this._position.getDate(); + }, + + /** + * Find the number of days in the given month/year. + * Month is zero based like the JS date constructor. + * + * @param {Numeric} year year value. + * @param {Numeric} month month value. + * @return {Numeric} number of days in month. + */ + _daysInMonth: function(year, month) { + var end = new Date(year, month + 1); + end.setMilliseconds(-1); + return end.getDate(); + }, + + /** + * Build the container for a day element. + * Each element has classes added to it based + * on what date it is created for. + * + * _today_ is based on today's actual date. + * Each date element also contains a data-date attribute + * with its current date as a string represented in + * the following format: "yyyy-mm-dd". + * + * Possible classes: + * - past + * - present (today) + * - future + * - other-month (day of another month but falls within same week) + * + * @param {Date} date date desired. + * @return {HTMLElement} dom element for day. + */ + _renderDay: function(date) { + var dayContainer = document.createElement('li'); + var dayEl = document.createElement('span'); + + dayContainer.className = Calc.relativeState( + date, + this._position + ); + + dayEl.dataset.date = Calc.getDayId(date); + dayEl.textContent = date.getDate(); + + dayContainer.appendChild(dayEl); + + return dayContainer; + }, + + /** + * Renders a set of dates and returns an ol element + * containing each date. + * + * @private + * @param {Array[Date]} dates array of dates. + * @return {HTMLELement} container for week. + */ + _renderWeek: function(dates) { + var container = document.createElement('ol'); + var i = 0; + var len = dates.length; + + for (; i < len; i++) { + container.appendChild( + this._renderDay(dates[i]) + ); + } + + return container; + }, + + /** + * Finds all dates in a given month by week. + * Includes leading and trailing days that occur + * outside the given year/month combination. + * + * @private + * @param {Numeric} year target year. + * @param {Numeric} month target month. + * @return {Array[Date]} array of dates. + */ + _getMonthDays: function(year, month) { + var date = new Date(year, month); + var dateEnd = new Date(year, month + 1); + dateEnd.setMilliseconds(-1); + + var start = Calc.getWeekStartDate(date); + var end = Calc.getWeekEndDate(dateEnd); + return Calc.daysBetween(start, end); + }, + + /** + * Returns a section element with all + * the days of the given month/year pair. + * + * Each month has a class for the number of weeks + * it contains. + * + * Possible values: + * - weeks-4 + * - weeks-5 + * - weeks-6 + * + * @private + */ + _renderMonth: function(year, month) { + var container = document.createElement('section'); + var days = this._getMonthDays(year, month); + var daysInWeek = Calc.daysInWeek(); + var weeks = days.length / daysInWeek; + var i = 0; + + container.classList.add('weeks-' + weeks); + + for (; i < weeks; i++) { + container.appendChild(this._renderWeek( + days.splice(0, daysInWeek) + )); + } + + return container; + }, + + /** + * Moves calendar one month into the future. + */ + next: function() { + this.display(this.year, this.month + 1, this.date); + }, + + /** + * Moves calendar one month into the past. + */ + previous: function() { + this.display(this.year, this.month - 1, this.date); + }, + + /** + * Primary method to display given month. + * Will remove the current display and replace + * it with the given month. + * + * @param {Numeric} year year to display. + * @param {Numeric} month month to display. + * @param {Numeric} date date to display. + */ + display: function(year, month, date) { + + // reset the date to the last date if overflow + var lastDate = new Date(year, month + 1, 0).getDate(); + if (lastDate < date) + date = lastDate; + + // Should come before render month + this._position = new Date(year, month, date); + + var element = this._renderMonth(year, month); + + if (this.monthDisplay) { + this.monthDisplay.parentNode.removeChild( + this.monthDisplay + ); + } + + this.monthDisplay = element; + this.element.appendChild(this.monthDisplay); + + this.onmonthchange(this._position); + + // Set the date as selected if presented + this._clearSelectedDay(); + if (date) { + var dayId = Calc.getDayId(this._position); + this.value = this._position; + var selector = '[data-date="' + dayId + '"]'; + var dateElement = document.querySelector(selector); + dateElement.classList.add(SELECTED); + } + }, + + /** + * Called when the month is changed. + */ + onmonthchange: function(month, year) {}, + + /** + * Called when the selected day changes. + */ + onvaluechange: function(date) {} + }; + + return DatePicker; +}()); diff --git a/apps/system/js/value_selector/input_parser.js b/apps/system/js/value_selector/input_parser.js new file mode 100644 index 0000000..5eef320 --- /dev/null +++ b/apps/system/js/value_selector/input_parser.js @@ -0,0 +1,160 @@ +/** + * Stateless object for input parser functions.. + * The intent is the methods here will only relate to the parsing + * of input[type="date|time"] + */ + +ValueSelector.InputParser = (function() { + + var InputParser = { + _dateParts: ['year', 'month', 'date'], + _timeParts: ['hours', 'minutes', 'seconds'], + + /** + * Import HTML5 input[type="time"] string value + * + * @param {String} value 23:20:50.52, 17:39:57. + * @return {Object} { hours: 23, minutes: 20, seconds: 50 }. + */ + importTime: function(value) { + var result = { + hours: 0, + minutes: 0, + seconds: 0 + }; + + var parts = value.split(':'); + var part; + var partName; + + var i = 0; + var len = InputParser._timeParts.length; + + for (; i < len; i++) { + partName = InputParser._timeParts[i]; + part = parts[i]; + if (part) { + result[partName] = parseInt(part.slice(0, 2), 10) || 0; + } + } + + return result; + }, + + /** + * Export date to HTML5 input[type="time"] + * + * @param {Date} value export value. + * @return {String} 17:39:57. + */ + exportTime: function(value) { + var hour = value.getHours(); + var minute = value.getMinutes(); + var second = value.getSeconds(); + + var result = ''; + + result += InputParser.padNumber(hour) + ':'; + result += InputParser.padNumber(minute) + ':'; + result += InputParser.padNumber(second); + + return result; + }, + + /** + * Import HTML5 input[type="time"] to object. + * + * @param {String} value 1997-12-19. + * @return {Object} { year: 1997, month: 12, date: 19 }. + */ + importDate: function(value) { + var result = { + year: 0, + month: 0, + date: 0 + }; + + var parts = value.split('-'); + var part; + var partName; + + var i = 0; + var len = InputParser._dateParts.length; + + for (; i < len; i++) { + partName = InputParser._dateParts[i]; + part = parts[i]; + if (part) { + result[partName] = parseInt(part, 10); + } + } + + if (result.month > 0) { + result.month = result.month - 1; + } + + result.date = result.date || 1; + + return result; + }, + + /** + * Export js date to HTML5 input[type="date"] + * + * @param {Date} value export value. + * @return {String} date string (1997-12-19). + */ + exportDate: function(value) { + var year = value.getFullYear(); + var month = value.getMonth() + 1; + var date = value.getDate(); + + var result = ''; + + result += InputParser.padNumber(year) + '-'; + result += InputParser.padNumber(month) + '-'; + result += InputParser.padNumber(date); + + return result; + }, + + /** + * Designed to take a date & time value from + * html5 input types and returns a JS Date. + * + * @param {String} date input date. + * @param {String} time input time. + * + * @return {Date} full date object from date/time. + */ + formatInputDate: function(date, time) { + time = InputParser.importTime(time); + date = InputParser.importDate(date); + + return new Date( + date.year, + date.month, + date.date, + time.hours, + time.minutes, + time.seconds + ); + }, + + /** + * @param {Numeric} numeric value. + * @return {String} Pad the numeric with a leading zero if < 10. + */ + padNumber: function(numeric) { + var value = String(numeric); + if (numeric < 10) { + return '0' + value; + } + + return value; + } + }; + + return InputParser; + +}()); diff --git a/apps/system/js/value_selector/spin_date_picker.js b/apps/system/js/value_selector/spin_date_picker.js new file mode 100644 index 0000000..feb0602 --- /dev/null +++ b/apps/system/js/value_selector/spin_date_picker.js @@ -0,0 +1,341 @@ +/** + * SpinDatePicker is a html/js "widget" which enables users + * pick a specific date. It display the date in the way based + * on the language setting. + * + * The SpinDatePicker itself contains no UI for the controls. + * + * Example usage: + * + * // All necessary UI elements are contained in the root element. + * var picker = new SpinDatePicker(root); + * picker.value = new Date(); + * // after users pick a date + * var newDate = picker.value; + */ +var SpinDatePicker = (function SpinDatePicker() { + 'use strict'; + + var FIRST_YEAR = 1900; + var LAST_YEAR = 2099; + + function getYearText() { + var yearText = []; + var dateTimeFormat = navigator.mozL10n.DateTimeFormat(); + + for (var i = FIRST_YEAR; i <= LAST_YEAR; i++) { + var date = new Date(i, 0, 1); + yearText.push(dateTimeFormat.localeFormat(date, '%Y')); + } + + return yearText; + } + + function getMonthText() { + var monthText = []; + var date = new Date(0); + var dateTimeFormat = navigator.mozL10n.DateTimeFormat(); + + for (var i = 0; i < 12; i++) { + date.setMonth(i); + monthText.push(dateTimeFormat.localeFormat(date, '%B')); + } + + return monthText; + } + + function getDateText(days) { + var dateText = []; + var date = new Date(0); + var dateTimeFormat = navigator.mozL10n.DateTimeFormat(); + + for (var i = 1; i <= days; i++) { + date.setDate(i); + dateText.push(dateTimeFormat.localeFormat(date, '%d')); + } + + return dateText; + } + + function getDaysInMonth(year, month) { + var date = new Date(year, month + 1, 0); + return date.getDate(); + } + + /** + * Get the order of date components. + * + * @param {String} date format. + */ + function getDateComponentOrder(format) { + var format = navigator.mozL10n.get('dateTimeFormat_%x'); + var order = ''; + var tokens = format.match(/(%E.|%O.|%.)/g); + + if (tokens) { + tokens.forEach(function(token) { + switch (token) { + case '%Y': + case '%y': + case '%Oy': + case 'Ey': + case 'EY': + order += 'Y'; + break; + case '%B': + case '%b': + case '%m': + case '%Om': + order += 'M'; + break; + case '%d': + case '%e': + case '%Od': + case '%Oe': + order += 'D'; + break; + } + }); + } + + if (order.length != 3) + order = 'DMY'; + + return order; + } + + /** + * Initialize a date picker widget. + * + * @param {HTMLELement} element target of widget creation. + */ + function SpinDatePicker(element) { + this.element = element; + + this.yearPicker = null; + this.monthPicker = null; + this.datePickers = { + '28': null, + '29': null, + '30': null, + '31': null + }; + + //XXX: When the document is localized again + // we must also re-render the month because + // the week days may have changed? + // This will only happen when we change timezones + // unless we add this information to the locales. + + var pickerContainer = + element.querySelector('.picker-container'); + var yearPickerContainer = + element.querySelector('.value-picker-year'); + var monthPickerContainer = + element.querySelector('.value-picker-month'); + var tmpDatePickerContainers = + element.querySelectorAll('.value-picker-date'); + var datePickerContainers = { + '28': tmpDatePickerContainers[0], + '29': tmpDatePickerContainers[1], + '30': tmpDatePickerContainers[2], + '31': tmpDatePickerContainers[3] + }; + + var updateCurrentValue = function spd_updateCurrentValue() { + var selectedYear = this.yearPicker.getSelectedIndex() + FIRST_YEAR; + var selectedMonth = this.monthPicker.getSelectedIndex(); + var days = getDaysInMonth(selectedYear, selectedMonth); + var datePicker = this.datePickers[days]; + var selectedDate = datePicker.getSelectedIndex() + 1; + + this._value = new Date(selectedYear, selectedMonth, selectedDate); + }; + + var updateDatePickerVisibility = + function spd_updateDatePickerVisibility() { + var days = getDaysInMonth(this.yearPicker.getSelectedIndex() + + FIRST_YEAR, this.monthPicker.getSelectedIndex()); + for (var i = 28; i <= 31; i++) { + datePickerContainers[i].hidden = true; + this.datePickers[i].setSelectedIndex(this._currentSelectedDateIndex); + } + datePickerContainers[days].hidden = false; + }; + + var onvaluechangeInternal = + function spd_onvaluechangeInternal(newDateValue) { + this.yearPicker.setSelectedIndex(newDateValue.getFullYear() - FIRST_YEAR); + this.monthPicker.setSelectedIndex(newDateValue.getMonth()); + for (var i = 28; i <= 31; i++) { + this.datePickers[i].setSelectedIndex(newDateValue.getDate() - 1); + } + updateDatePickerVisibility.apply(this); + updateCurrentValue.apply(this); + }; + + var onSelectedYearChanged = + function spd_onSelectedYearChanged(selectedYear) { + updateDatePickerVisibility.apply(this); + updateCurrentValue.apply(this); + }; + + var onSelectedMonthChanged = + function spd_onSelectedMonthChanged(selectedMonth) { + updateDatePickerVisibility.apply(this); + updateCurrentValue.apply(this); + }; + + var onSelectedDateChanged = + function spd_onSelectedDateChanged(selectedDate) { + this._currentSelectedDateIndex = selectedDate; + updateCurrentValue.apply(this); + }; + + var unitClassName = 'picker-unit'; + + // year value picker + var yearUnitStyle = { + valueDisplayedText: getYearText(), + className: unitClassName + }; + if (this.yearPicker) + this.yearPicker.uninit(); + this.yearPicker = new ValuePicker(yearPickerContainer, yearUnitStyle); + this.yearPicker.onselectedindexchange = onSelectedYearChanged.bind(this); + + // month value picker + var monthUnitStyle = { + valueDisplayedText: getMonthText(), + className: unitClassName + }; + if (this.monthPicker) + this.monthPicker.uninit(); + this.monthPicker = + new ValuePicker(monthPickerContainer, monthUnitStyle); + this.monthPicker.onselectedindexchange = onSelectedMonthChanged.bind(this); + + // date value picker + for (var i = 28; i <= 31; i++) { + var datePickerContainer = datePickerContainers[i]; + var dateUnitStyle = { + valueDisplayedText: getDateText(i), + className: unitClassName + }; + var datePicker = this.datePickers[i]; + + if (datePicker) + datePicker.uninit(); + datePickerContainer.hidden = false; + this.datePickers[i] = new ValuePicker(datePickerContainer, dateUnitStyle); + this.datePickers[i].onselectedindexchange = + onSelectedDateChanged.bind(this); + } + + // set component order + var dateComponentOrder = getDateComponentOrder(); + var pickerClassList = pickerContainer.classList; + pickerClassList.remove('YMD'); + pickerClassList.remove('DMY'); + pickerClassList.remove('MDY'); + pickerClassList.add(dateComponentOrder); + + // Prevent focus being taken away by us for time picker. + // The event listener on outer box will not be triggered cause + // there is a evt.stopPropagation() in value_picker.js + this.pickerElements = [monthPickerContainer, yearPickerContainer]; + for (var i = 28; i <= 31; i++) { + this.pickerElements.push(datePickerContainers[i]); + } + + this.pickerElements.forEach((function pickerElements_forEach(picker) { + picker.addEventListener('mousedown', this); + }).bind(this)); + + this.onvaluechangeInternal = onvaluechangeInternal.bind(this); + } + + SpinDatePicker.prototype = { + + /** + * Internal value not exposed so we can fire events + * when the getter/setter's are used. + * + * @type Date + */ + _value: null, + + /** + * Gets current value + * + * @return {Null|Date} date or null. + */ + get value() { + return this._value; + }, + + /** + * Sets the current value of the date picker. + * When value differs from the currently set the + * `onvaluechange` event will be fired with the new/old value. + */ + set value(value) { + var old = this._value; + if (old !== value) { + this._value = value; + this.onvaluechangeInternal(value); + } + }, + + /** + * Getter is used for date normalization. + */ + get year() { + return this._value.getFullYear(); + }, + + /** + * Getter is used for date normalization. + */ + get month() { + return this._value.getMonth(); + }, + + get date() { + return this._value.getDate(); + }, + + handleEvent: function vs_handleEvent(evt) { + switch (evt.type) { + case 'mousedown': + // Prevent focus being taken away by us. + evt.preventDefault(); + break; + } + }, + + uninit: function() { + if (this.yearPicker) + this.yearPicker.uninit(); + if (this.monthPicker) + this.monthPicker.uninit(); + if (this.datePickers) { + for (var i = 28; i <= 31; i++) { + var datePicker = this.datePickers[i]; + datePicker.uninit(); + } + } + + this.pickerElements.forEach((function pickerElements_forEach(picker) { + picker.removeEventListener('mousedown', this); + }).bind(this)); + }, + + /** + * Called when the selected date changes. + */ + onvaluechangeInternal: function(date) {} + }; + + return SpinDatePicker; +}()); diff --git a/apps/system/js/value_selector/value_picker.js b/apps/system/js/value_selector/value_picker.js new file mode 100644 index 0000000..34e686f --- /dev/null +++ b/apps/system/js/value_selector/value_picker.js @@ -0,0 +1,222 @@ +var ValuePicker = (function() { + // + // Constructor + // + function VP(e, unitStyle) { + this.element = e; + this._valueDisplayedText = unitStyle.valueDisplayedText; + this._unitClassName = unitStyle.className; + this._lower = 0; + this._upper = unitStyle.valueDisplayedText.length - 1; + this._range = unitStyle.valueDisplayedText.length; + this._currentIndex = 0; + this.init(); + } + + // + // Public methods + // + VP.prototype.getSelectedIndex = function() { + var selectedIndex = this._currentIndex; + return selectedIndex; + }; + + VP.prototype.getSelectedDisplayedText = function() { + var displayedText = this._valueDisplayedText[this._currentIndex]; + return displayedText; + }; + + VP.prototype.setSelectedIndex = function(tunedIndex, ignorePicker) { + if ((tunedIndex % 1) > 0.5) { + tunedIndex = Math.floor(tunedIndex) + 1; + } else { + tunedIndex = Math.floor(tunedIndex); + } + + if (tunedIndex < this._lower) { + tunedIndex = this._lower; + } + + if (tunedIndex > this._upper) { + tunedIndex = this._upper; + } + + if (this._currentIndex != tunedIndex) { + this._currentIndex = tunedIndex; + this.onselectedindexchange(this._currentIndex); + } + this.updateUI(tunedIndex, ignorePicker); + + return tunedIndex; + }; + + VP.prototype.setSelectedIndexByDisplayedText = function(displayedText) { + var newIndex = this._valueDisplayedText.indexOf(displayedText); + if (newIndex != -1) { + if (this._currentIndex != newIndex) { + this._currentIndex = newIndex; + this.onselectedindexchange(this._currentIndex); + } + this.updateUI(newIndex); + } + }; + + // + // Internal methods + // + VP.prototype.init = function() { + this.initUI(); + this.setSelectedIndex(0); // Default Index is zero + this.mousedonwHandler = vp_mousedown.bind(this); + this.mousemoveHandler = vp_mousemove.bind(this); + this.mouseupHandler = vp_mouseup.bind(this); + this.addEventListeners(); + }; + + VP.prototype.initUI = function() { + var lower = this._lower; + var upper = this._upper; + var unitCount = this._valueDisplayedText.length; + for (var i = 0; i < unitCount; ++i) { + this.addPickerUnit(i); + } + // cache the size of picker + this._pickerUnits = this.element.children; + this._pickerUnitsHeight = this._pickerUnits[0].clientHeight; + this._pickerHeight = this._pickerUnits[0].clientHeight * + this._pickerUnits.length; + this._space = this._pickerHeight / this._range; + }; + + VP.prototype.addPickerUnit = function(index) { + var html = this._valueDisplayedText[index]; + var unit = document.createElement('div'); + unit.className = this._unitClassName; + unit.innerHTML = html; + this.element.appendChild(unit); + }; + + VP.prototype.updateUI = function(index, ignorePicker) { + if (true !== ignorePicker) { + this.element.style.top = + (this._lower - index) * this._space + 'px'; + } + }; + + VP.prototype.addEventListeners = function() { + this.element.addEventListener('mousedown', this.mousedonwHandler, false); + }; + + VP.prototype.removeEventListeners = function() { + this.element.removeEventListener('mouseup', this.mouseupHandler, false); + this.element.removeEventListener('mousemove', this.mousemoveHandler, false); + }; + + VP.prototype.uninit = function() { + this.element.removeEventListener('mousedown', this.mousedonwHandler, false); + this.element.removeEventListener('mouseup', this.mouseupHandler, false); + this.element.removeEventListener('mousemove', this.mousemoveHandler, false); + this.element.style.top = '0px'; + this.onselectedindexchange = null; + empty(this.element); + }; + + VP.prototype.onselectedindexchange = function(index) {}; + + function cloneEvent(evt) { + if ('touches' in evt) { + evt = evt.touches[0]; + } + return { x: evt.pageX, y: evt.pageY, timestamp: evt.timeStamp }; + } + + function empty(element) { + while (element.hasChildNodes()) + element.removeChild(element.lastChild); + element.innerHTML = ''; + } + + // + // Tuneable parameters + // + var SPEED_THRESHOLD = 0.1; + var currentEvent, startEvent, currentSpeed; + var tunedIndex = 0; + + function toFixed(value) { + return parseFloat(value.toFixed(1)); + } + + function calcSpeed() { + var movingSpace = startEvent.y - currentEvent.y; + var deltaTime = currentEvent.timestamp - startEvent.timestamp; + var speed = movingSpace / deltaTime; + currentSpeed = parseFloat(speed.toFixed(2)); + } + + function calcTargetIndex(space) { + return tunedIndex - getMovingSpace() / space; + } + + // If the user swap really slow, narrow down the moving space + // So the user can fine tune value. + function getMovingSpace() { + var movingSpace = currentEvent.y - startEvent.y; + var reValue = Math.abs(currentSpeed) > SPEED_THRESHOLD ? + movingSpace : movingSpace / 4; + return reValue; + } + + function vp_mousemove(event) { + event.stopPropagation(); + event.target.setCapture(true); + currentEvent = cloneEvent(event); + + calcSpeed(); + + // move selected index + this.element.style.top = parseFloat(this.element.style.top) + + getMovingSpace() + 'px'; + + tunedIndex = calcTargetIndex(this._space); + var roundedIndex = Math.round(tunedIndex * 10) / 10; + + if (roundedIndex != this._currentIndex) { + this.setSelectedIndex(toFixed(roundedIndex), true); + } + + startEvent = currentEvent; + } + + function vp_mouseup(event) { + event.stopPropagation(); + this.removeEventListeners(); + + // Add animation back + this.element.classList.add('animation-on'); + + // Add momentum if speed is higher than a given threshold. + if (Math.abs(currentSpeed) > SPEED_THRESHOLD) { + var direction = currentSpeed > 0 ? 1 : -1; + tunedIndex += Math.min(Math.abs(currentSpeed) * 5, 5) * direction; + } + tunedIndex = this.setSelectedIndex(toFixed(tunedIndex)); + currentSpeed = 0; + } + + function vp_mousedown(event) { + event.stopPropagation(); + + // Stop animation + this.element.classList.remove('animation-on'); + + startEvent = currentEvent = cloneEvent(event); + tunedIndex = this._currentIndex; + + this.removeEventListeners(); + this.element.addEventListener('mousemove', this.mousemoveHandler, false); + this.element.addEventListener('mouseup', this.mouseupHandler, false); + } + + return VP; +}()); diff --git a/apps/system/js/value_selector/value_selector.js b/apps/system/js/value_selector/value_selector.js new file mode 100644 index 0000000..b3381b3 --- /dev/null +++ b/apps/system/js/value_selector/value_selector.js @@ -0,0 +1,526 @@ +/* -*- Mode: js; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +var ValueSelector = { + + _containers: {}, + _popups: {}, + _buttons: {}, + _datePicker: null, + + debug: function(msg) { + var debugFlag = false; + if (debugFlag) { + console.log('[ValueSelector] ', msg); + } + }, + + init: function vs_init() { + + var self = this; + + window.navigator.mozKeyboard.onfocuschange = function onfocuschange(evt) { + var typeToHandle = ['select-one', 'select-multiple', 'date', + 'time', 'datetime', 'datetime-local', 'blur']; + + var type = evt.detail.type; + // handle the <select> element and inputs with type of date/time + // in system app for now + if (typeToHandle.indexOf(type) == -1) + return; + + var currentValue = evt.detail.value; + + switch (evt.detail.type) { + case 'select-one': + case 'select-multiple': + self.debug('select triggered' + JSON.stringify(evt.detail)); + self._currentPickerType = evt.detail.type; + self.showOptions(evt.detail); + break; + + case 'date': + self.showDatePicker(currentValue); + break; + + case 'time': + self.showTimePicker(currentValue); + break; + + case 'datetime': + case 'datetime-local': + // TODO + break; + case 'blur': + self.hide(); + break; + } + }; + + this._element = document.getElementById('value-selector'); + this._element.addEventListener('mousedown', this); + this._containers['select'] = + document.getElementById('value-selector-container'); + this._containers['select'].addEventListener('click', this); + ActiveEffectHelper.enableActive(this._containers['select']); + + this._popups['select'] = + document.getElementById('select-option-popup'); + this._popups['select'].addEventListener('submit', this); + this._popups['time'] = + document.getElementById('time-picker-popup'); + this._popups['date'] = + document.getElementById('spin-date-picker-popup'); + + this._buttons['select'] = document.getElementById('select-options-buttons'); + this._buttons['select'].addEventListener('click', this); + + this._buttons['time'] = document.getElementById('time-picker-buttons'); + this._buttons['time'].addEventListener('click', this); + this._buttons['date'] = document.getElementById('spin-date-picker-buttons'); + + this._buttons['date'].addEventListener('click', this); + + this._containers['time'] = document.getElementById('picker-bar'); + this._containers['date'] = document.getElementById('spin-date-picker'); + + ActiveEffectHelper.enableActive(this._buttons['select']); + ActiveEffectHelper.enableActive(this._buttons['time']); + ActiveEffectHelper.enableActive(this._buttons['date']); + + // Prevent focus being taken away by us for time picker. + // The event listener on outer box will not be triggered cause + // there is a evt.stopPropagation() in value_picker.js + var pickerElements = ['value-picker-hours', 'value-picker-minutes', + 'value-picker-hour24-state']; + + pickerElements.forEach((function pickerElements_forEach(id) { + var element = document.getElementById(id); + element.addEventListener('mousedown', this); + }).bind(this)); + + window.addEventListener('appopen', this); + window.addEventListener('appwillclose', this); + + // invalidate the current spin date picker when language setting changes + navigator.mozSettings.addObserver('language.current', + (function language_change(e) { + if (this._datePicker) { + this._datePicker.uninit(); + this._datePicker = null; + }}).bind(this)); + }, + + handleEvent: function vs_handleEvent(evt) { + switch (evt.type) { + case 'appopen': + case 'appwillclose': + this.hide(); + break; + + case 'click': + var currentTarget = evt.currentTarget; + switch (currentTarget) { + case this._buttons['select']: + case this._buttons['time']: + case this._buttons['date']: + var target = evt.target; + if (target.dataset.type == 'cancel') { + this.cancel(); + } else if (target.dataset.type == 'ok') { + this.confirm(); + } + break; + + case this._containers['select']: + this.handleSelect(evt.target); + break; + } + break; + + case 'submit': + // Prevent the form from submit. + case 'mousedown': + // Prevent focus being taken away by us. + evt.preventDefault(); + break; + + default: + this.debug('no event handler defined for' + evt.type); + break; + } + }, + + handleSelect: function vs_handleSelect(target) { + + if (target.dataset === undefined || + (target.dataset.optionIndex === undefined && + target.dataset.optionValue === undefined)) + return; + + if (this._currentPickerType === 'select-one') { + var selectee = this._containers['select']. + querySelectorAll('[aria-checked="true"]'); + for (var i = 0; i < selectee.length; i++) { + selectee[i].removeAttribute('aria-checked'); + } + + target.setAttribute('aria-checked', 'true'); + } else if (target.getAttribute('aria-checked') === 'true') { + target.removeAttribute('aria-checked'); + } else { + target.setAttribute('aria-checked', 'true'); + } + + // setValue here to trigger change event + var singleOptionIndex; + var optionIndices = []; + + var selectee = this._containers['select']. + querySelectorAll('[aria-checked="true"]'); + + if (this._currentPickerType === 'select-one') { + + if (selectee.length > 0) + singleOptionIndex = selectee[0].dataset.optionIndex; + + window.navigator.mozKeyboard.setSelectedOption(singleOptionIndex); + + } else if (this._currentPickerType === 'select-multiple') { + // Multiple select case + for (var i = 0; i < selectee.length; i++) { + + var index = parseInt(selectee[i].dataset.optionIndex); + optionIndices.push(index); + } + + window.navigator.mozKeyboard.setSelectedOptions(optionIndices); + } + + }, + + show: function vs_show(detail) { + this._element.hidden = false; + }, + + showPanel: function vs_showPanel(type) { + for (var p in this._containers) { + if (p === type) { + this._popups[p].hidden = false; + } else { + this._popups[p].hidden = true; + } + } + }, + + hide: function vs_hide() { + this._element.hidden = true; + }, + + cancel: function vs_cancel() { + this.debug('cancel invoked'); + window.navigator.mozKeyboard.removeFocus(); + this.hide(); + }, + + confirm: function vs_confirm() { + + if (this._currentPickerType === 'time') { + + var timeValue = TimePicker.getTimeValue(); + this.debug('output value: ' + timeValue); + + window.navigator.mozKeyboard.setValue(timeValue); + } else if (this._currentPickerType === 'date') { + var dateValue = this._datePicker.value; + // The format should be 2012-09-19 + dateValue = dateValue.toLocaleFormat('%Y-%m-%d'); + this.debug('output value: ' + dateValue); + window.navigator.mozKeyboard.setValue(dateValue); + } + + window.navigator.mozKeyboard.removeFocus(); + this.hide(); + }, + + showOptions: function vs_showOptions(detail) { + + var options = null; + if (detail.choices && detail.choices.choices) + options = detail.choices.choices; + + if (options) + this.buildOptions(options); + + this.show(); + this.showPanel('select'); + }, + + buildOptions: function(options) { + + var optionHTML = ''; + + function escapeHTML(str) { + var span = document.createElement('span'); + span.textContent = str; + return span.innerHTML; + } + + for (var i = 0, n = options.length; i < n; i++) { + + var checked = options[i].selected ? ' aria-checked="true"' : ''; + + // This for attribute is created only to avoid applying + // a general rule in building block + var forAttribute = ' for="gaia-option-' + options[i].optionIndex + '"'; + + optionHTML += '<li data-option-index="' + options[i].optionIndex + '"' + + checked + '>' + + '<label' + forAttribute + '> <span>' + + escapeHTML(options[i].text) + + '</span></label>' + + '</li>'; + } + + var optionsContainer = document.querySelector( + '#value-selector-container ol'); + if (!optionsContainer) + return; + + optionsContainer.innerHTML = optionHTML; + + + // Apply different style when the options are more than 1 page + if (options.length > 5) { + this._containers['select'].classList.add('scrollable'); + } else { + this._containers['select'].classList.remove('scrollable'); + } + + // Change the title for multiple select + var titleL10nId = 'choose-options'; + if (this._currentPickerType === 'select-one') + titleL10nId = 'choose-option'; + + var optionsTitle = document.querySelector( + '#value-selector-container h1'); + + if (optionsTitle) { + optionsTitle.dataset.l10nId = titleL10nId; + optionsTitle.textContent = navigator.mozL10n.get(titleL10nId); + } + }, + + showTimePicker: function vs_showTimePicker(currentValue) { + this._currentPickerType = 'time'; + this.show(); + this.showPanel('time'); + + if (!this._timePickerInitialized) { + TimePicker.initTimePicker(); + this._timePickerInitialized = true; + } + + var time; + if (!currentValue) { + var now = new Date(); + time = { + hours: now.getHours(), + minutes: now.getMinutes() + }; + } else { + var inputParser = ValueSelector.InputParser; + if (!inputParser) + console.error('Cannot get input parser for value selector'); + + time = inputParser.importTime(currentValue); + } + + var timePicker = TimePicker.timePicker; + // Set the value of time picker according to the current value + if (timePicker.is12hFormat) { + var hour = (time.hours % 12); + hour = (hour == 0) ? 12 : hour; + // 24-hour state value selector: AM = 0, PM = 1 + var hour24State = (time.hours >= 12) ? 1 : 0; + timePicker.hour.setSelectedIndexByDisplayedText(hour); + timePicker.hour24State.setSelectedIndex(hour24State); + } else { + timePicker.hour.setSelectedIndex(time.hours); + } + + timePicker.minute.setSelectedIndex(time.minutes); + }, + + showDatePicker: function vs_showDatePicker(currentValue) { + this._currentPickerType = 'date'; + this.show(); + this.showPanel('date'); + + if (!this._datePicker) { + this._datePicker = new SpinDatePicker(this._containers['date']); + } + + // Show current date as default value + var date = new Date(); + if (currentValue) { + var inputParser = ValueSelector.InputParser; + if (!inputParser) + console.error('Cannot get input parser for value selector'); + + date = inputParser.formatInputDate(currentValue, ''); + } + this._datePicker.value = date; + } + +}; + +var TimePicker = { + timePicker: { + hour: null, + minute: null, + hour24State: null, + is12hFormat: false + }, + + get hourSelector() { + delete this.hourSelector; + return this.hourSelector = + document.getElementById('value-picker-hours'); + }, + + get minuteSelector() { + delete this.minuteSelector; + return this.minuteSelector = + document.getElementById('value-picker-minutes'); + }, + + get hour24StateSelector() { + delete this.hour24StateSelector; + return this.hour24StateSelector = + document.getElementById('value-picker-hour24-state'); + }, + + initTimePicker: function tp_initTimePicker() { + var localeTimeFormat = navigator.mozL10n.get('dateTimeFormat_%X'); + var is12hFormat = (localeTimeFormat.indexOf('%p') >= 0); + this.timePicker.is12hFormat = is12hFormat; + this.setTimePickerStyle(); + var startHour = is12hFormat ? 1 : 0; + var endHour = is12hFormat ? (startHour + 12) : (startHour + 12 * 2); + var unitClassName = 'picker-unit'; + var hourDisplayedText = []; + for (var i = startHour; i < endHour; i++) { + var value = i; + hourDisplayedText.push(value); + } + var hourUnitStyle = { + valueDisplayedText: hourDisplayedText, + className: unitClassName + }; + this.timePicker.hour = new ValuePicker(this.hourSelector, hourUnitStyle); + + var minuteDisplayedText = []; + for (var i = 0; i < 60; i++) { + var value = (i < 10) ? '0' + i : i; + minuteDisplayedText.push(value); + } + var minuteUnitStyle = { + valueDisplayedText: minuteDisplayedText, + className: unitClassName + }; + this.timePicker.minute = + new ValuePicker(this.minuteSelector, minuteUnitStyle); + + if (is12hFormat) { + var hour24StateUnitStyle = { + valueDisplayedText: ['AM', 'PM'], + className: unitClassName + }; + this.timePicker.hour24State = + new ValuePicker(this.hour24StateSelector, hour24StateUnitStyle); + } + }, + + setTimePickerStyle: function tp_setTimePickerStyle() { + var style = (this.timePicker.is12hFormat) ? 'format12h' : 'format24h'; + document.getElementById('picker-bar').classList.add(style); + }, + + // return a string for the time value, format: "16:37" + getTimeValue: function tp_getTimeValue() { + var hour = 0; + if (this.timePicker.is12hFormat) { + var hour24Offset = 12 * this.timePicker.hour24State.getSelectedIndex(); + hour = this.timePicker.hour.getSelectedDisplayedText(); + hour = (hour == 12) ? 0 : hour; + hour = hour + hour24Offset; + } else { + hour = this.timePicker.hour.getSelectedIndex(); + } + var minute = this.timePicker.minute.getSelectedDisplayedText(); + + return hour + ':' + minute; + } +}; + +var ActiveEffectHelper = (function() { + + var lastActiveElement = null; + + function _setActive(element, isActive) { + if (isActive) { + element.classList.add('active'); + lastActiveElement = element; + } else { + element.classList.remove('active'); + if (lastActiveElement) { + lastActiveElement.classList.remove('active'); + lastActiveElement = null; + } + } + } + + function _onMouseDown(evt) { + var target = evt.target; + + _setActive(target, true); + target.addEventListener('mouseleave', _onMouseLeave); + } + + function _onMouseUp(evt) { + var target = evt.target; + + _setActive(target, false); + target.removeEventListener('mouseleave', _onMouseLeave); + } + + function _onMouseLeave(evt) { + var target = evt.target; + _setActive(target, false); + target.removeEventListener('mouseleave', _onMouseLeave); + } + + var _events = { + 'mousedown': _onMouseDown, + 'mouseup': _onMouseUp + }; + + function _enableActive(element) { + // Attach event listeners + for (var event in _events) { + var callback = _events[event] || null; + if (callback) + element.addEventListener(event, callback); + } + } + + return { + enableActive: _enableActive + }; + +})(); + +ValueSelector.init(); diff --git a/apps/system/js/voicemail.js b/apps/system/js/voicemail.js new file mode 100644 index 0000000..dea5116 --- /dev/null +++ b/apps/system/js/voicemail.js @@ -0,0 +1,93 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +// Custom voicemail notification -- This can be removed once DesktopNotification +// supports removing notifications via API +var Voicemail = { + + icon: null, + notification: null, + // A random starting point that is unlikely to be used by other notifications + notificationId: 3000 + Math.floor(Math.random() * 999), + + init: function vm_init() { + var voicemail = window.navigator.mozVoicemail; + if (!voicemail) + return; + + voicemail.addEventListener('statuschanged', this); + + this.icon = window.location.protocol + '//' + + window.location.hostname + '/style/icons/voicemail.png'; + }, + + handleEvent: function vm_handleEvent(evt) { + var voicemail = window.navigator.mozVoicemail; + if (!voicemail.status) + return; + + this.updateNotification(voicemail.status); + }, + + updateNotification: function vm_updateNotification(status) { + var _ = window.navigator.mozL10n.get; + var title = status.returnMessage; + var showCount = status.hasMessages && status.messageCount > 0; + + if (!title) { + title = showCount ? _('newVoicemails', { n: status.messageCount }) : + _('newVoicemailsUnknown'); + } + + var text = title; + var voicemailNumber = navigator.mozVoicemail.number; + if (voicemailNumber) { + text = _('dialNumber', { number: voicemailNumber }); + } + + this.hideNotification(); + if (status.hasMessages) { + this.showNotification(title, text, voicemailNumber); + } + }, + + showNotification: function vm_showNotification(title, text, voicemailNumber) { + this.notificationId++; + this.notification = NotificationScreen.addNotification({ + id: this.notificationId, title: title, text: text, icon: this.icon + }); + + if (!voicemailNumber) { + return; + } + + var self = this; + function vmNotification_onTap(event) { + self.notification.removeEventListener('tap', vmNotification_onTap); + + var telephony = window.navigator.mozTelephony; + if (!telephony) { + return; + } + + telephony.dial(voicemailNumber); + } + + this.notification.addEventListener('tap', vmNotification_onTap); + }, + + hideNotification: function vm_hideNotification() { + if (!this.notification) { + return; + } + + if (this.notification.parentNode) { + NotificationScreen.removeNotification(this.notificationId); + } + + this.notification = null; + this.notificationId = 0; + } +}; + +Voicemail.init(); diff --git a/apps/system/js/wifi.js b/apps/system/js/wifi.js new file mode 100644 index 0000000..3456fdf --- /dev/null +++ b/apps/system/js/wifi.js @@ -0,0 +1,223 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +var Wifi = { + wifiWakeLocked: false, + + wifiEnabled: true, + + wifiDisabledByWakelock: false, + + // Without wake lock, wait for kOffTime milliseconds and turn wifi off + // after the conditions are met. + kOffTime: 60 * 1000, + + // if Wifi is enabled but disconnected, try to scan for networks every + // kScanInterval ms. + kScanInterval: 20 * 1000, + + _scanTimer: null, + + init: function wf_init() { + window.addEventListener('screenchange', this); + + var battery = window.navigator.battery; + battery.addEventListener('chargingchange', this); + + if (!window.navigator.mozSettings) + return; + + // If wifi is turned off by us and phone got rebooted, + // bring wifi back. + var name = 'wifi.disabled_by_wakelock'; + var req = SettingsListener.getSettingsLock().get(name); + req.onsuccess = function gotWifiDisabledByWakelock() { + if (!req.result[name]) + return; + + // Re-enable wifi and reset wifi.disabled_by_wakelock + // SettingsListener.getSettingsLock() always return invalid lock + // in our usage here. + // See https://bugzilla.mozilla.org/show_bug.cgi?id=793239 + var lock = navigator.mozSettings.createLock(); + lock.set({ 'wifi.enabled': true }); + lock.set({ 'wifi.disabled_by_wakelock': false }); + }; + + var self = this; + var wifiManager = window.navigator.mozWifiManager; + // when wifi is really enabled, emit event to notify QuickSettings + wifiManager.onenabled = function onWifiEnabled() { + var evt = document.createEvent('CustomEvent'); + evt.initCustomEvent('wifi-enabled', + /* canBubble */ true, /* cancelable */ false, null); + window.dispatchEvent(evt); + }; + + // when wifi is really disabled, emit event to notify QuickSettings + wifiManager.ondisabled = function onWifiDisabled() { + var evt = document.createEvent('CustomEvent'); + evt.initCustomEvent('wifi-disabled', + /* canBubble */ true, /* cancelable */ false, null); + window.dispatchEvent(evt); + }; + + // when wifi status change, emit event to notify StatusBar/UpdateManager + wifiManager.onstatuschange = function onWifiDisabled() { + var evt = document.createEvent('CustomEvent'); + evt.initCustomEvent('wifi-statuschange', + /* canBubble */ true, /* cancelable */ false, null); + window.dispatchEvent(evt); + }; + + // Track the wifi.enabled mozSettings value + SettingsListener.observe('wifi.enabled', true, function(value) { + if (!wifiManager && value) { + self.wifiEnabled = false; + + // roll back the setting value to notify the UIs + // that wifi interface is not available + if (value) { + SettingsListener.getSettingsLock().set({ + 'wifi.enabled': false + }); + } + + return; + } + + self.wifiEnabled = value; + + clearTimeout(self._scanTimer); + if (!value) + return; + + // If wifi is enabled but disconnected. + // we would need to call getNetworks() continuously + // so we could join known wifi network + self._scanTimer = setInterval(function wifi_scan() { + if (wifiManager.connection.status == 'disconnected') + wifiManager.getNetworks(); + }); + }); + + var power = navigator.mozPower; + power.addWakeLockListener(function wifi_handleWakeLock(topic, state) { + if (topic !== 'wifi') + return; + + self.wifiWakeLocked = (state == 'locked-foreground' || + state == 'locked-background'); + + self.maybeToggleWifi(); + }); + }, + + handleEvent: function wifi_handleEvent(evt) { + this.maybeToggleWifi(); + }, + + // Check the status of screen, wifi wake lock and power source + // and turn on/off wifi accordingly + maybeToggleWifi: function wifi_maybeToggleWifi() { + var battery = window.navigator.battery; + var wifiManager = window.navigator.mozWifiManager; + if (!battery || !wifiManager || + (!this.wifiEnabled && !this.wifiDisabledByWakelock)) + return; + + + // Let's quietly turn off wifi if there is no wake lock and + // the screen is off and we are not on a power source. + if (!ScreenManager.screenEnabled && + !this.wifiWakeLocked && !battery.charging) { + // We don't need to do anything if wifi is not enabled currently + if (!this.wifiEnabled) + return; + + // We still need to turn of wifi even if there is no Alarm API + if (!navigator.mozAlarms) { + console.warn('Turning off wifi without sleep timer because' + + ' Alarm API is not available'); + this.sleep(); + + return; + } + + // Set System Message Handler, so we will be notified when alarm goes off. + this.setSystemMessageHandler(); + + // Start with a timer, only turn off wifi till timeout. + var date = new Date(Date.now() + this.kOffTime); + var self = this; + var req = navigator.mozAlarms.add(date, 'ignoreTimezone', 'wifi-off'); + req.onsuccess = function wifi_offAlarmSet() { + self._alarmId = req.result; + }; + req.onerror = function wifi_offAlarmSetFailed() { + console.warn('Fail to set wifi sleep timer on Alarm API. ' + + 'Turn off wifi immediately.'); + self.sleep(); + }; + } + // ... and quietly turn it back on or cancel the timer otherwise + else { + if (this._alarmId) { + navigator.mozAlarms.remove(this._alarmId); + this._alarmId = null; + } + + // If wifi is enabled but disconnected. + // we would need to call getNetworks() so we could join known wifi network + if (this.wifiEnabled && wifiManager.connection.status == 'disconnected') { + wifiManager.getNetworks(); + } + + // We don't need to do anything if we didn't disable wifi at first place. + if (!this.wifiDisabledByWakelock) + return; + + var lock = SettingsListener.getSettingsLock(); + // turn wifi back on. + lock.set({ 'wifi.enabled': true }); + + this.wifiDisabledByWakelock = false; + lock.set({ 'wifi.disabled_by_wakelock': false }); + } + }, + + // Quietly turn off wifi for real, set wifiDisabledByWakelock to true + // so we will turn it back on. + sleep: function wifi_sleep() { + var lock = SettingsListener.getSettingsLock(); + // Actually turn off the wifi + lock.set({ 'wifi.enabled': false }); + + // Remember that it was turned off by us. + this.wifiDisabledByWakelock = true; + + // Keep this value in disk so if the phone reboots we'll + // be able to turn the wifi back on. + lock.set({ 'wifi.disabled_by_wakelock': true }); + }, + + // Register for handling system message, + // this cannot be done during |init()| because of bug 797803 + setSystemMessageHandler: function wifi_setSystemMessageHandler() { + if (this._systemMessageHandlerRegistered) + return; + + this._systemMessageHandlerRegistered = true; + var self = this; + navigator.mozSetMessageHandler('alarm', function gotAlarm(message) { + if (message.data !== 'wifi-off') + return; + + self.sleep(); + }); + } +}; + +Wifi.init(); diff --git a/apps/system/js/window.js b/apps/system/js/window.js new file mode 100644 index 0000000..a9109dd --- /dev/null +++ b/apps/system/js/window.js @@ -0,0 +1,152 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +(function(window){ + + var _ = navigator.mozL10n.get; + + var ENABLE_LOG = false; + + // Use mutation observer to monitor appWindow status change + window.AppLog = function AppLog(app) { + // select the target node + var target = app.frame; + + // create an observer instance + var observer = new MutationObserver(function(mutations) { + mutations.forEach(function(mutation) { + console.log(mutation.target.id, + mutation.target.className, + mutation.attributeName); + }); + }); + + // configuration of the observer: + var config = { attributes: true }; + + // pass in the target node, as well as the observer options + observer.observe(target, config); + } + + window.AppError = function AppError(app) { + var self = this; + this.app = app; + this.app.frame.addEventListener('mozbrowsererror', function (evt) { + if (evt.detail.type != 'other') + return; + + console.warn('app of [' + self.app.origin + '] got a mozbrowsererror event.'); + + if (self.injected) { + self.update(); + } else { + self.render(); + } + self.show(); + self.injected = true; + }); + return this; + }; + + AppError.className = 'appError'; + + AppError.prototype.hide = function() { + this.element.classList.remove('visible'); + } + + AppError.prototype.show = function() { + this.element.classList.add('visible'); + } + + AppError.prototype.render = function() { + this.app.frame.insertAdjacentHTML('beforeend', this.view()); + this.closeButton = this.app.frame.querySelector('.' + AppError.className + ' .close'); + this.reloadButton = this.app.frame.querySelector('.' + AppError.className + ' .reload'); + this.titleElement = this.app.frame.querySelector('.' + AppError.className + ' .title'); + this.messageElement = this.app.frame.querySelector('.' + AppError.className + ' .message'); + this.element = this.app.frame.querySelector('.' + AppError.className); + var self = this; + this.closeButton.onclick = function() { + self.app.kill(); + } + + this.reloadButton.onclick = function() { + self.hide(); + self.app.reload(); + } + } + + AppError.prototype.update = function() { + this.titleElement.textContent = this.getTitle(); + this.messageElement.textContent = this.getMessage(); + } + + AppError.prototype.id = function() { + return AppError.className + '-' + this.app.frame.id; + } + + AppError.prototype.getTitle = function() { + if (AirplaneMode.enabled) { + return _('airplane-is-on'); + } else if (!navigator.onLine) { + return _('network-connection-unavailable'); + } else { + return _('error-title', { name: this.app.name }); + } + } + + AppError.prototype.getMessage = function() { + if (AirplaneMode.enabled) { + return _('airplane-is-turned-on', { name: this.app.name }); + } else if (!navigator.onLine) { + return _('network-error', { name: this.app.name }); + } else { + return _('error-message', { name: this.app.name }); + } + } + + AppError.prototype.view = function() { + return '<div id="' + this.id() + '" class="' + AppError.className + ' visible" role="dialog">' + + '<div class="modal-dialog-message-container inner">' + + '<h3 data-l10n-id="error-title" class="title">' + this.getTitle() + '</h3>' + + '<p>' + + '<span data-l10n-id="error-message" class="message">' + this.getMessage() + '</span>' + + '</p>' + + '</div>' + + '<menu data-items="2">' + + '<button class="close" data-l10n-id="try-again">' + _('close') + '</button>' + + '<button class="reload" data-l10n-id="try-again">' + _('try-again') + '</button>' + + '</menu>' + + '</div>'; + } + + window.AppWindow = function AppWindow(configuration) { + for (var key in configuration) { + this[key] = configuration[key]; + } + + // We keep the appError object here for the purpose that + // we may need to export the error state of AppWindow instance to the other module + // in the future. + this.appError = new AppError(this); + if (ENABLE_LOG) + this.appLog = new AppLog(this); + + return this; + }; + + AppWindow.prototype.reload = function() { + this.iframe.reload(true); + } + + AppWindow.prototype.kill = function() { + // XXX: A workaround because a AppWindow instance shouldn't reference Window Manager directly here. + // In the future we should make every app maintain and execute the events in itself. + // Like resize, setVisibility... + // And Window Manager is in charge of cross app management. + WindowManager.kill(this.origin); + } + +}(this)); diff --git a/apps/system/js/window_manager.js b/apps/system/js/window_manager.js new file mode 100644 index 0000000..e53605e --- /dev/null +++ b/apps/system/js/window_manager.js @@ -0,0 +1,2011 @@ +/* -*- Mode: js; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +// +// This file calls getElementById without waiting for an onload event, so it +// must have a defer attribute or be included at the end of the <body>. +// +// This module is responsible for launching apps and for allowing +// the user to switch among apps and kill apps. Specifically, it handles: +// launching apps, +// killing apps +// keeping track of the set of running apps (which we call tasks here) +// keeping track of which task is displayed (the foreground task) +// changing the foreground task +// hiding all apps to display the homescreen +// displaying the app switcher to allow the user to switch and kill apps +// performing appropriate transition animations between: +// the homescreen and an app +// the homescreen and the switcher +// an app and the homescreen +// the switcher and the homescreen +// the switcher and the current foreground task +// the switcher and a different task +// Handling Home key events to switch to the homescreen and the switcher +// +// The public API of the module is small. It defines an WindowManager object +// with these methods: +// +// launch(origin): switch to the specified running app +// kill(origin, callback): stop specified app +// reload(origin): reload the given app +// getDisplayedApp(): return the origin of the currently displayed app +// setOrientationForApp(origin): set the phone to orientation to a given app +// getAppFrame(origin): returns the iframe element for the specified origin +// which is assumed to be running. This is only currently used +// for tests and chrome stuff: see the end of the file +// getRunningApps(): get the app references of the running apps. +// +// TODO +// The "origin" does not actually refer to app's origin but rather a identifier +// of the app reference that one gets from |getDisplayedApp()| or +// iterates |getRunningApps|. The string is make up of the specified +// launching entry point, origin, or the website url launched by wrapper. +// It would be ideal if the variable get correctly named and it's rule is being +// properly documented. +// See https://bugzilla.mozilla.org/show_bug.cgi?id=796629 +// + +var WindowManager = (function() { + 'use strict'; + + function debug(str) { + dump('WindowManager: ' + str + '\n'); + } + + // Holds the origin of the home screen, which should be the first + // app we launch through web activity during boot + var homescreen = null; + var homescreenURL = ''; + var homescreenManifestURL = ''; + var ftu = null; + var ftuManifestURL = ''; + var ftuURL = ''; + var isRunningFirstRunApp = false; + // keep the reference of inline activity frame here + var inlineActivityFrames = []; + var activityCallerOrigin = ''; + + // Some document elements we use + var windows = document.getElementById('windows'); + var screenElement = document.getElementById('screen'); + var wrapperHeader = document.querySelector('#wrapper-activity-indicator'); + var wrapperFooter = document.querySelector('#wrapper-footer'); + var kTransitionTimeout = 1000; + + // Set this to true to debugging the transitions and state change + var slowTransition = false; + if (slowTransition) { + windows.classList.add('slow-transition'); + } + + // + // The set of running apps. + // This is a map from app origin to an object like this: + // { + // name: the app's name + // manifest: the app's manifest object + // frame: the iframe element that the app is displayed in + // launchTime: last time when app gets active + // } + // + var runningApps = {}; + var numRunningApps = 0; // appendFrame() and removeFrame() maintain this count + var nextAppId = 0; // to give each app's iframe a unique id attribute + + // The origin of the currently displayed app, or null if there isn't one + var displayedApp = null; + + // Function to hide init starting logo + function handleInitlogo(callback) { + var initlogo = document.getElementById('initlogo'); + initlogo.classList.add('hide'); + initlogo.addEventListener('transitionend', function delInitlogo() { + initlogo.removeEventListener('transitionend', delInitlogo); + initlogo.parentNode.removeChild(initlogo); + if (callback) { + callback(); + } + }); + }; + + // Public function. Return the origin of the currently displayed app + // or null if there is none. + function getDisplayedApp() { + return displayedApp || null; + } + + function requireFullscreen(origin) { + var app = runningApps[origin]; + if (!app) + return false; + + var manifest = app.manifest; + if (manifest.entry_points && manifest.type == 'certified') { + var entryPoint = manifest.entry_points[origin.split('/')[3]]; + if (entryPoint) + return entryPoint.fullscreen; + return false; + } else { + return manifest.fullscreen; + } + } + + // Make the specified app the displayed app. + // Public function. Pass null to make the homescreen visible + function launch(origin) { + // If the origin is indeed valid we make that app as the displayed app. + if (isRunning(origin)) { + setDisplayedApp(origin); + return; + } + + // If the origin is null, make the homescreen visible. + if (origin == null) { + setDisplayedApp(homescreen); + return; + } + + // At this point, we have no choice but to show the homescreen. + // We cannot launch/relaunch a given app based on the "origin" because + // we would need the manifest URL and the specific entry point. + console.warn('No running app is being identified as "' + origin + '". ' + + 'Showing home screen instead.'); + setDisplayedApp(homescreen); + } + + function isRunning(origin) { + return runningApps.hasOwnProperty(origin); + } + + function getAppFrame(origin) { + if (isRunning(origin)) + return runningApps[origin].frame; + else + return null; + } + + // Set the size of the app's iframe to match the size of the screen. + // We have to call this on resize events (which happen when the + // phone orientation is changed). And also when an app is launched + // and each time an app is brought to the front, since the + // orientation could have changed since it was last displayed + function setAppSize(origin, changeActivityFrame) { + var app = runningApps[origin]; + if (!app) + return; + + var frame = app.frame; + var manifest = app.manifest; + + var cssWidth = window.innerWidth + 'px'; + var cssHeight = window.innerHeight - StatusBar.height; + if ('wrapper' in frame.dataset) { + cssHeight -= 10; + } + cssHeight += 'px'; + + if (!screenElement.classList.contains('attention') && + requireFullscreen(origin)) { + cssHeight = window.innerHeight + 'px'; + } + + frame.style.width = cssWidth; + frame.style.height = cssHeight; + + // We will call setInlineActivityFrameSize() + // if changeActivityFrame is not explicitly set to false. + if (changeActivityFrame !== false) + setInlineActivityFrameSize(); + } + + // App's height is relevant to keyboard height + function setAppHeight(keyboardHeight) { + var app = runningApps[displayedApp]; + if (!app) + return; + + var frame = app.frame; + var manifest = app.manifest; + + var cssHeight = + window.innerHeight - StatusBar.height - keyboardHeight + 'px'; + + if (!screenElement.classList.contains('attention') && + requireFullscreen(displayedApp)) { + cssHeight = window.innerHeight - keyboardHeight + 'px'; + } + + frame.style.height = cssHeight; + + setInlineActivityFrameSize(); + } + + // Copy the dimension of the currently displayed app + function setInlineActivityFrameSize() { + if (!inlineActivityFrames.length) + return; + + var app = runningApps[displayedApp]; + var appFrame = app.frame; + var frame = inlineActivityFrames[inlineActivityFrames.length - 1]; + + frame.style.width = appFrame.style.width; + + if (document.mozFullScreen) { + frame.style.height = window.innerHeight + 'px'; + frame.style.top = '0px'; + } else { + if ('wrapper' in appFrame.dataset) { + frame.style.height = window.innerHeight - StatusBar.height + 'px'; + } else { + frame.style.height = appFrame.style.height; + } + frame.style.top = appFrame.offsetTop + 'px'; + } + } + + function setFrameBackgroundBlob(frame, blob, transparent) { + URL.revokeObjectURL(frame.dataset.bgObjectURL); + delete frame.dataset.bgObjectURL; + + var objectURL = URL.createObjectURL(blob); + frame.dataset.bgObjectURL = objectURL; + var backgroundCSS = + '-moz-linear-gradient(top, rgba(0,0,0,0.5) 0%, rgba(0,0,0,0.5) 100%),' + + 'url(' + objectURL + '),' + + ((transparent) ? 'transparent' : '#fff'); + + frame.style.background = backgroundCSS; + } + + function clearFrameBackground(frame) { + if (!('bgObjectURL' in frame.dataset)) + return; + + URL.revokeObjectURL(frame.dataset.bgObjectURL); + delete frame.dataset.bgObjectURL; + frame.style.background = ''; + } + + var openFrame = null; + var closeFrame = null; + var openCallback = null; + var closeCallback = null; + var transitionOpenCallback = null; + var transitionCloseCallback = null; + + // Use setOpenFrame() to reset the CSS classes set + // to the current openFrame (before overwriting the reference) + function setOpenFrame(frame) { + if (openFrame) { + removeFrameClasses(openFrame); + } + + openFrame = frame; + } + + // Use setCloseFrame() to reset the CSS classes set + // to the current closeFrame (before overwriting the reference) + function setCloseFrame(frame) { + if (closeFrame) { + removeFrameClasses(closeFrame); + // closeFrame should not be set to active + closeFrame.classList.remove('active'); + } + + closeFrame = frame; + } + + // Remove these visible className from frame so we will not ended + // up having a frozen frame in the middle of the transition + function removeFrameClasses(frame) { + var classNames = ['opening', 'closing', 'opening-switching', + 'opening-card', 'closing-card']; + + var classList = frame.classList; + + classNames.forEach(function removeClass(className) { + classList.remove(className); + }); + } + + windows.addEventListener('transitionend', function frameTransitionend(evt) { + var prop = evt.propertyName; + var frame = evt.target; + if (prop !== 'transform') + return; + + var classList = frame.classList; + + if (classList.contains('inlineActivity')) { + if (classList.contains('active')) { + if (openFrame) + openFrame.firstChild.focus(); + + setOpenFrame(null); + } else { + windows.removeChild(frame); + } + + return; + } + + if (screenElement.classList.contains('switch-app')) { + if (classList.contains('closing')) { + classList.remove('closing'); + classList.add('closing-card'); + + if (openFrame) { + if (openFrame.classList.contains('opening-card')) { + openFrame.classList.remove('opening-card'); + openFrame.classList.add('opening-switching'); + } else { + // Skip the opening-card and opening-switching transition + // because the closing-card transition had already finished here. + if (openFrame.classList.contains('fullscreen-app')) { + screenElement.classList.add('fullscreen-app'); + } + openFrame.classList.add('opening'); + } + } + } else if (classList.contains('closing-card')) { + windowClosed(frame); + setTimeout(closeCallback); + closeCallback = null; + + } else if (classList.contains('opening-switching')) { + // If the opening app need to be full screen, switch to full screen + if (classList.contains('fullscreen-app')) { + screenElement.classList.add('fullscreen-app'); + } + + classList.remove('opening-switching'); + classList.add('opening'); + } else if (classList.contains('opening')) { + windowOpened(frame); + + setTimeout(openCallback); + openCallback = null; + + setCloseFrame(null); + setOpenFrame(null); + screenElement.classList.remove('switch-app'); + } + + return; + } + + if (classList.contains('opening')) { + windowOpened(frame); + + setTimeout(openCallback); + openCallback = null; + + setOpenFrame(null); + } else if (classList.contains('closing')) { + windowClosed(frame); + + setTimeout(closeCallback); + closeCallback = null; + + setCloseFrame(null); + } + }); + + // Executes when the opening transition scale the app + // to full size. + function windowOpened(frame) { + var iframe = frame.firstChild; + + frame.classList.add('active'); + windows.classList.add('active'); + + if ('wrapper' in frame.dataset) { + wrapperFooter.classList.add('visible'); + } + + // Take the focus away from the currently displayed app + var app = runningApps[displayedApp]; + if (app && app.iframe) + app.iframe.blur(); + + // Give the focus to the frame + iframe.focus(); + + if (!TrustedUIManager.isVisible() && !isRunningFirstRunApp) { + // Set homescreen visibility to false + toggleHomescreen(false); + } + + // Set displayedApp to the new value + displayedApp = iframe.dataset.frameOrigin; + + // Set orientation for the new app + setOrientationForApp(displayedApp); + + // Dispatch an 'appopen' event. + var manifestURL = runningApps[displayedApp].manifestURL; + var evt = document.createEvent('CustomEvent'); + evt.initCustomEvent('appopen', true, false, { + manifestURL: manifestURL, + origin: displayedApp + }); + frame.dispatchEvent(evt); + } + + // Executes when app closing transition finishes. + function windowClosed(frame) { + var iframe = frame.firstChild; + + // If the FTU is closing, make sure we save this state + if (iframe.src == ftuURL) { + isRunningFirstRunApp = false; + document.getElementById('screen').classList.remove('ftu'); + window.asyncStorage.setItem('ftu.enabled', false); + // Done with FTU, letting everyone know + var evt = document.createEvent('CustomEvent'); + evt.initCustomEvent('ftudone', + /* canBubble */ true, /* cancelable */ false, {}); + window.dispatchEvent(evt); + } + + frame.classList.remove('active'); + windows.classList.remove('active'); + + // set the closed frame visibility to false + if ('setVisible' in iframe) + iframe.setVisible(false); + + screenElement.classList.remove('fullscreen-app'); + } + + // The following things needs to happen when firstpaint happens. + // We centralize all that here but not all of them applies. + windows.addEventListener('mozbrowserfirstpaint', function firstpaint(evt) { + var iframe = evt.target; + var frame = iframe.parentNode; + + // remove the unpainted flag + delete iframe.dataset.unpainted; + + setTimeout(function firstpainted() { + // Save the screenshot + // Remove the background only until we actually got the screenshot, + // because the getScreenshot() call will be pushed back by + // painting/loading in the child process; when we got the screenshot, + // that means the app is mostly loaded. + // (as opposed to plain white firstpaint) + saveAppScreenshot(frame, function screenshotTaken() { + // Remove the default background + frame.classList.remove('default-background'); + + // Remove the screenshot from frame + clearFrameBackground(frame); + }); + }); + }); + + // setFrameBackground() will attach the screenshot background to + // the given frame. + // The callback could be sync or async (depend on whether we need + // the screenshot from database or not) + function setFrameBackground(frame, callback, transparent) { + var iframe = frame.firstChild; + // If the frame is painted, or there is already background image present + // start the transition right away. + if (!('unpainted' in iframe.dataset) || + ('bgObjectURL' in frame.dataset)) { + callback(); + return; + } + + // Get the screenshot from the database + getAppScreenshotFromDatabase(iframe.src || iframe.dataset.frameOrigin, + function(screenshot) { + // If firstpaint is faster than database, we will not transition + // with screenshot. + if (!('unpainted' in iframe.dataset)) { + callback(); + return; + } + + if (!screenshot) { + // put a default background + frame.classList.add('default-background'); + callback(); + return; + } + + // set the screenshot as the background of the frame itself. + // we are safe to do so since there is nothing on it yet. + setFrameBackgroundBlob(frame, screenshot, transparent); + + // start the transition + callback(); + }); + } + + // On-disk database for window manager. + // It's only for app screenshots right now. + var database = null; + var DB_SCREENSHOT_OBJSTORE = 'screenshots'; + + (function openDatabase() { + var DB_VERSION = 2; + var DB_NAME = 'window_manager'; + + var req = window.indexedDB.open(DB_NAME, DB_VERSION); + req.onerror = function() { + console.error('Window Manager: opening database failed.'); + }; + req.onupgradeneeded = function databaseUpgradeneeded() { + database = req.result; + + if (database.objectStoreNames.contains(DB_SCREENSHOT_OBJSTORE)) + database.deleteObjectStore(DB_SCREENSHOT_OBJSTORE); + + var store = database.createObjectStore( + DB_SCREENSHOT_OBJSTORE, { keyPath: 'url' }); + }; + + req.onsuccess = function databaseSuccess() { + database = req.result; + }; + })(); + + function putAppScreenshotToDatabase(url, data) { + if (!database) + return; + + var txn = database.transaction(DB_SCREENSHOT_OBJSTORE, 'readwrite'); + txn.onerror = function() { + console.warn( + 'Window Manager: transaction error while trying to save screenshot.'); + }; + var store = txn.objectStore(DB_SCREENSHOT_OBJSTORE); + var req = store.put({ + url: url, + screenshot: data + }); + req.onerror = function(evt) { + console.warn( + 'Window Manager: put error while trying to save screenshot.'); + }; + } + + function getAppScreenshotFromDatabase(url, callback) { + if (!database) { + console.warn( + 'Window Manager: Neither database nor app frame is ' + + 'ready for getting screenshot.'); + + callback(); + return; + } + + var req = database.transaction(DB_SCREENSHOT_OBJSTORE) + .objectStore(DB_SCREENSHOT_OBJSTORE).get(url); + req.onsuccess = function() { + if (!req.result) { + console.log('Window Manager: No screenshot in database. ' + + 'This is expected from a fresh installed app.'); + callback(); + + return; + } + + callback(req.result.screenshot, true); + } + req.onerror = function(evt) { + console.warn('Window Manager: get screenshot from database failed.'); + callback(); + }; + } + + function deleteAppScreenshotFromDatabase(url) { + var txn = database.transaction(DB_SCREENSHOT_OBJSTORE); + var store = txn.objectStore(DB_SCREENSHOT_OBJSTORE); + + store.delete(url); + } + + function getAppScreenshotFromFrame(frame, callback) { + if (!frame) { + callback(); + return; + } + + var iframe = frame.firstChild; + var req = iframe.getScreenshot(iframe.offsetWidth, iframe.offsetHeight); + + req.onsuccess = function gotScreenshotFromFrame(evt) { + var result = evt.target.result; + callback(result, false); + }; + + req.onerror = function gotScreenshotFromFrameError(evt) { + console.warn('Window Manager: getScreenshot failed.'); + callback(); + }; + } + + // Meta method for get the screenshot from the app frame, + // and save it to database. + function saveAppScreenshot(frame, callback) { + getAppScreenshotFromFrame(frame, function gotScreenshot(screenshot) { + if (callback) + callback(screenshot); + + if (!screenshot) + return; + + var iframe = frame.firstChild; + putAppScreenshotToDatabase(iframe.src || iframe.dataset.frameOrigin, + screenshot); + }); + } + + // Perform an "open" animation for the app's iframe + function openWindow(origin, callback) { + var app = runningApps[origin]; + setOpenFrame(app.frame); + + openCallback = callback || function() {}; + + // set the size of the opening app + setAppSize(origin); + + if (origin === homescreen) { + // We cannot apply background screenshot to home screen app since + // the screenshot is encoded in JPEG and the alpha channel is + // not perserved. See + // https://bugzilla.mozilla.org/show_bug.cgi?id=801676#c33 + // If that resolves, + // setFrameBackground(openFrame, gotBackground, true); + // will simply work here. + + // Call the openCallback only once. We have to use tmp var as + // openCallback can be a method calling the callback + // (like the `removeFrame` callback in `kill()` ). + var tmpCallback = openCallback; + openCallback = null; + tmpCallback(); + + windows.classList.add('active'); + openFrame.classList.add('homescreen'); + openFrame.firstChild.focus(); + setOpenFrame(null); + displayedApp = origin; + + return; + } + + if (requireFullscreen(origin)) + screenElement.classList.add('fullscreen-app'); + + transitionOpenCallback = function startOpeningTransition() { + // We have been canceled by another transition. + if (!openFrame || transitionOpenCallback != startOpeningTransition) + return; + + // Make sure we're not called twice. + transitionOpenCallback = null; + + if (!screenElement.classList.contains('switch-app')) { + openFrame.classList.add('opening'); + } else if (!openFrame.classList.contains('opening')) { + openFrame.classList.add('opening-card'); + } + }; + + if ('unpainted' in openFrame.firstChild.dataset) { + waitForNextPaintOrBackground(openFrame, transitionOpenCallback); + } else { + waitForNextPaint(openFrame, transitionOpenCallback); + } + + // Set the frame to be visible. + if ('setVisible' in openFrame.firstChild) { + if (!AttentionScreen.isFullyVisible()) { + openFrame.firstChild.setVisible(true); + } else { + // If attention screen is fully visible now, + // don't give the open frame visible. + // This is the case that homescreen is restarted behind attention screen + openFrame.firstChild.setVisible(false); + } + } + } + + function waitForNextPaintOrBackground(frame, callback) { + var waiting = true; + function proceed() { + if (waiting) { + waiting = false; + callback(); + } + } + + waitForNextPaint(frame, proceed); + setFrameBackground(frame, proceed); + } + + function waitForNextPaint(frame, callback) { + function onNextPaint() { + clearTimeout(timeout); + callback(); + } + + var iframe = frame.firstChild; + + // Register a timeout in case we don't receive + // nextpaint in an acceptable time frame. + var timeout = setTimeout(function() { + if ('removeNextPaintListener' in iframe) + iframe.removeNextPaintListener(onNextPaint); + callback(); + }, kTransitionTimeout); + + if ('addNextPaintListener' in iframe) + iframe.addNextPaintListener(onNextPaint); + } + + // Perform a "close" animation for the app's iframe + function closeWindow(origin, callback) { + var app = runningApps[origin]; + setCloseFrame(app.frame); + closeCallback = callback || function() {}; + + // Animate the window close. Ensure the homescreen is in the + // foreground since it will be shown during the animation. + var homescreenFrame = ensureHomescreen(); + + // invoke openWindow to show homescreen here + openWindow(homescreen, null); + + // Take keyboard focus away from the closing window + closeFrame.firstChild.blur(); + + // set orientation for homescreen app + setOrientationForApp(homescreen); + + // Set the size of both homescreen app and the closing app + // since the orientation had changed. + setAppSize(homescreen); + setAppSize(origin); + + // Send a synthentic 'appwillclose' event. + // The keyboard uses this and the appclose event to know when to close + // See https://github.com/andreasgal/gaia/issues/832 + var evt = document.createEvent('CustomEvent'); + evt.initCustomEvent('appwillclose', true, false, { origin: origin }); + closeFrame.dispatchEvent(evt); + + if ('wrapper' in closeFrame.dataset) { + wrapperHeader.classList.remove('visible'); + wrapperFooter.classList.remove('visible'); + } + + transitionCloseCallback = function startClosingTransition() { + // We have been canceled by another transition. + if (!closeFrame || transitionCloseCallback != startClosingTransition) + return; + + // Make sure we're not called twice. + transitionCloseCallback = null; + + // Start the transition + closeFrame.classList.add('closing'); + closeFrame.classList.remove('active'); + }; + + waitForNextPaint(homescreenFrame, transitionCloseCallback); + } + + // Perform a "switching" animation for the closing frame and the opening frame + function switchWindow(origin, callback) { + // This will trigger different transition to both openWindow() + // and closeWindow() transition. + screenElement.classList.add('switch-app'); + + // Ask closeWindow() to start closing the displayedApp + closeWindow(displayedApp, callback); + + // Ask openWindow() to show a card on the right waiting to be opened + openWindow(origin); + } + + // Ensure the homescreen is loaded and return its frame. Restarts + // the homescreen app if it was killed in the background. + // Note: this function would not invoke openWindow(homescreen), + // which should be handled in setDisplayedApp and in closeWindow() + function ensureHomescreen(reset) { + // If the url of the homescreen is not known at this point do nothing. + if (!homescreen || !homescreenManifestURL) { + return null; + } + + if (!isRunning(homescreen)) { + var app = Applications.getByManifestURL(homescreenManifestURL); + appendFrame(null, homescreen, homescreenURL, + app.manifest.name, app.manifest, app.manifestURL); + runningApps[homescreen].iframe.dataset.start = Date.now(); + setAppSize(homescreen); + } else if (reset) { + runningApps[homescreen].iframe.src = homescreenURL; + setAppSize(homescreen); + } + + return runningApps[homescreen].frame; + } + + function retrieveHomescreen(callback) { + var lock = navigator.mozSettings.createLock(); + var setting = lock.get('homescreen.manifestURL'); + setting.onsuccess = function() { + var app = + Applications.getByManifestURL(this.result['homescreen.manifestURL']); + + // XXX This is a one-day workaround to not break everybody and make sure + // work can continue. + if (!app) { + var tmpURL = document.location.toString() + .replace('system', 'homescreen') + .replace('index.html', 'manifest.webapp'); + app = Applications.getByManifestURL(tmpURL); + } + + if (app) { + homescreenManifestURL = app.manifestURL; + homescreen = app.origin; + homescreenURL = app.origin + '/index.html#root'; + + callback(app); + } + } + } + + function skipFTU() { + document.getElementById('screen').classList.remove('ftuStarting'); + handleInitlogo(); + setDisplayedApp(homescreen); + } + + // Check if the FTU was executed or not, if not, get a + // reference to the app and launch it. + function retrieveFTU() { + window.asyncStorage.getItem('ftu.enabled', function getItem(launchFTU) { + document.getElementById('screen').classList.add('ftuStarting'); + if (launchFTU === false) { + skipFTU(); + return; + } + var lock = navigator.mozSettings.createLock(); + var req = lock.get('ftu.manifestURL'); + req.onsuccess = function() { + ftuManifestURL = this.result['ftu.manifestURL']; + if (!ftuManifestURL) { + dump('FTU manifest cannot be found skipping.\n'); + skipFTU(); + return; + } + ftu = Applications.getByManifestURL(ftuManifestURL); + if (!ftu) { + dump('Opps, bogus FTU manifest.\n'); + skipFTU(); + return; + } + ftuURL = ftu.origin + ftu.manifest.entry_points['ftu'].launch_path; + ftu.launch('ftu'); + }; + req.onerror = function() { + dump('Couldn\'t get the ftu manifestURL.\n'); + skipFTU(); + }; + }); + } + + // Hide current app + function hideCurrentApp(callback) { + if (displayedApp == null || displayedApp == homescreen) + return; + + toggleHomescreen(true); + var frame = getAppFrame(displayedApp); + frame.classList.add('back'); + frame.classList.remove('restored'); + if (callback) { + frame.addEventListener('transitionend', function execCallback() { + frame.style.visibility = 'hidden'; + frame.removeEventListener('transitionend', execCallback); + callback(); + }); + } + } + + // If app parameter is passed, + // it means there's a specific app needs to be restored + // instead of current app + function restoreCurrentApp(app) { + if (app) { + // Restore app visibility immediately but don't open it. + var frame = getAppFrame(app); + frame.style.visibility = 'visible'; + frame.classList.remove('back'); + } else { + app = displayedApp; + toggleHomescreen(false); + var frame = getAppFrame(app); + frame.style.visibility = 'visible'; + frame.classList.remove('back'); + frame.classList.add('restored'); + frame.addEventListener('transitionend', function removeRestored() { + frame.removeEventListener('transitionend', removeRestored); + frame.classList.remove('restored'); + }); + } + } + + function toggleHomescreen(visible) { + var homescreenFrame = ensureHomescreen(); + if (homescreenFrame && 'setVisible' in homescreenFrame.firstChild) + homescreenFrame.firstChild.setVisible(visible); + } + + // Switch to a different app + function setDisplayedApp(origin, callback) { + var currentApp = displayedApp, newApp = origin || homescreen; + var isFirstRunApplication = !currentApp && (origin == ftuURL); + + var homescreenFrame = null; + if (!isFirstRunApplication) { + // Returns the frame reference of the home screen app. + // Restarts the homescreen app if it was killed in the background. + homescreenFrame = ensureHomescreen(); + } + + // Cancel transitions waiting to be started. + transitionOpenCallback = null; + transitionCloseCallback = null; + + // Discard any existing activity + stopInlineActivity(true); + + // Before starting a new transition, let's make sure current transitions + // are stopped and the state classes are cleaned up. + // visibility status should also be reset. + if (openFrame && 'setVisible' in openFrame.firstChild) + openFrame.firstChild.setVisible(false); + if (closeFrame && 'setVisible' in closeFrame.firstChild) + closeFrame.firstChild.setVisible(false); + + if (!isFirstRunApplication && newApp == homescreen && !AttentionScreen.isFullyVisible()) { + toggleHomescreen(true); + } + + setOpenFrame(null); + setCloseFrame(null); + screenElement.classList.remove('switch-app'); + screenElement.classList.remove('fullscreen-app'); + + // Dispatch an appwillopen event only when we open an app + if (newApp != currentApp) { + var evt = document.createEvent('CustomEvent'); + evt.initCustomEvent('appwillopen', true, true, { origin: newApp }); + + var app = runningApps[newApp]; + // Allows listeners to cancel app opening and so stay on homescreen + if (!app.frame.dispatchEvent(evt)) { + if (typeof(callback) == 'function') + callback(); + return; + } + + var iframe = app.iframe; + + // unpainted means that the app is cold booting + // if it is, we're going to listen for Browser API's loadend event + // which indicates that the iframe's document load is complete + // + // if the app is not cold booting (is in memory) we will listen + // to appopen event, which is fired when the transition to the + // app window is complete. + // + // [w] - warm boot (app is in memory, just transition to it) + // [c] - cold boot (app has to be booted, we show it's document load + // time) + var type; + if ('unpainted' in iframe.dataset) { + type = 'mozbrowserloadend'; + } else { + iframe.dataset.start = Date.now(); + type = 'appopen'; + } + + app.frame.addEventListener(type, function apploaded(e) { + e.target.removeEventListener(e.type, apploaded); + + var evt = document.createEvent('CustomEvent'); + evt.initCustomEvent('apploadtime', true, false, { + time: parseInt(Date.now() - iframe.dataset.start), + type: (e.type == 'appopen') ? 'w' : 'c' + }); + iframe.dispatchEvent(evt); + }); + } + + // Case 1: the app is already displayed + if (currentApp && currentApp == newApp) { + if (newApp == homescreen) { + // relaunch homescreen + openWindow(homescreen, callback); + } else if (callback) { + // Just run the callback right away if it is not homescreen + callback(); + } + } + // Case 2: null --> app + else if (isFirstRunApplication) { + isRunningFirstRunApp = true; + openWindow(newApp, function windowOpened() { + handleInitlogo(function() { + var mainScreen = document.getElementById('screen'); + mainScreen.classList.add('ftu'); + mainScreen.classList.remove('ftuStarting'); + }); + }); + } + // Case 3: null->homescreen || homescreen->app + else if ((!currentApp && newApp == homescreen) || + (currentApp == homescreen && newApp)) { + openWindow(newApp, callback); + } + // Case 4: app->homescreen + else if (currentApp && currentApp != homescreen && newApp == homescreen) { + // For screenshot to catch current window size + closeWindow(currentApp, callback); + } + // Case 5: app-to-app transition + else { + switchWindow(newApp, callback); + } + // Set homescreen as active, + // to control the z-index between homescreen & keyboard iframe + if ((newApp == homescreen) && homescreenFrame) { + homescreenFrame.classList.add('active'); + } else { + homescreenFrame.classList.remove('active'); + } + + // Record the time when app was launched, + // need this to display apps in proper order on CardsView. + // We would also need this to determined the freshness of the frame + // for making screenshots. + if (newApp) + runningApps[newApp].launchTime = Date.now(); + + // If the app has a attention screen open, displaying it + AttentionScreen.showForOrigin(newApp); + } + + function setOrientationForApp(origin) { + if (origin == null) { // No app is currently running. + screen.mozLockOrientation('portrait-primary'); + return; + } + + var app = runningApps[origin]; + if (!app) + return; + var manifest = app.manifest; + + if (manifest.orientation) { + var rv = screen.mozLockOrientation(manifest.orientation); + if (rv === false) { + console.warn('screen.mozLockOrientation() returned false for', + origin, 'orientation', manifest.orientation); + } + } + else { // If no orientation was requested, then let it rotate + screen.mozUnlockOrientation(); + } + } + + var isOutOfProcessDisabled = false; + SettingsListener.observe('debug.oop.disabled', false, function(value) { + isOutOfProcessDisabled = value; + }); + + function createFrame(origFrame, origin, url, name, manifest, manifestURL) { + var iframe = origFrame || document.createElement('iframe'); + iframe.setAttribute('mozallowfullscreen', 'true'); + + var frame = document.createElement('div'); + frame.appendChild(iframe); + frame.className = 'appWindow'; + + iframe.dataset.frameOrigin = origin; + // Save original frame URL in order to restore it on frame load error + iframe.dataset.frameURL = url; + + // Note that we don't set the frame size here. That will happen + // when we display the app in setDisplayedApp() + + // frames are began unpainted. + iframe.dataset.unpainted = true; + + if (!manifestURL) { + frame.setAttribute('data-wrapper', 'true'); + return frame; + } + + // Most apps currently need to be hosted in a special 'mozbrowser' iframe. + // They also need to be marked as 'mozapp' to be recognized as apps by the + // platform. + iframe.setAttribute('mozbrowser', 'true'); + + // These apps currently have bugs preventing them from being + // run out of process. All other apps will be run OOP. + // + var outOfProcessBlackList = [ + 'Browser' + // Requires nested content processes (bug 761935). This is not + // on the schedule for v1. + ]; + + if (!isOutOfProcessDisabled && + outOfProcessBlackList.indexOf(name) === -1) { + // FIXME: content shouldn't control this directly + iframe.setAttribute('remote', 'true'); + } + + iframe.setAttribute('mozapp', manifestURL); + iframe.src = url; + return frame; + } + + function appendFrame(origFrame, origin, url, name, manifest, manifestURL) { + // Create the <iframe mozbrowser mozapp> that hosts the app + var frame = + createFrame(origFrame, origin, url, name, manifest, manifestURL); + var iframe = frame.firstChild; + frame.id = 'appframe' + nextAppId++; + iframe.dataset.frameType = 'window'; + + // Give a name to the frame for differentiating between main frame and + // inline frame. With the name we can get frames of the same app using the + // window.open method. + iframe.name = 'main'; + + // If this frame corresponds to the homescreen, set mozapptype=homescreen + // so we're less likely to kill this frame's process when we're running low + // on memory. + // + // We must do this before we the appendChild() call below. Once + // we add this frame to the document, we can't change its app type. + if (origin === homescreen) { + iframe.setAttribute('mozapptype', 'homescreen'); + } + + // Add the iframe to the document + windows.appendChild(frame); + + // And map the app origin to the info we need for the app + var app = new AppWindow({ + origin: origin, + name: name, + manifest: manifest, + manifestURL: manifestURL, + frame: frame, + iframe: iframe, + launchTime: 0 + }); + runningApps[origin] = app; + + if (requireFullscreen(origin)) { + frame.classList.add('fullscreen-app'); + } + if (origin === ftuURL) { + // Add a way to identify ftu app + // (Used by SimLock) + frame.classList.add('ftu'); + } + + // A frame should start with visible false + if ('setVisible' in iframe) + iframe.setVisible(false); + + numRunningApps++; + + return app; + } + + function startInlineActivity(origin, url, name, manifest, manifestURL) { + // Create the <iframe mozbrowser mozapp> that hosts the app + var frame = createFrame(null, origin, url, name, manifest, manifestURL); + var iframe = frame.firstChild; + frame.classList.add('inlineActivity'); + iframe.dataset.frameType = 'inline-activity'; + + // Give a name to the frame for differentiating between main frame and + // inline frame. With the name we can get frames of the same app using the + // window.open method. + iframe.name = 'inline'; + + // Save the reference + inlineActivityFrames.push(frame); + + // Set the size + setInlineActivityFrameSize(); + + // Add the iframe to the document + windows.appendChild(frame); + + // Open the frame, first, store the reference + openFrame = frame; + + // set the frame to visible state + if ('setVisible' in iframe) + iframe.setVisible(true); + + setFrameBackground(openFrame, function gotBackground() { + // Start the transition when this async/sync callback is called. + openFrame.classList.add('active'); + if (inlineActivityFrames.length == 1) + activityCallerOrigin = displayedApp; + if ('wrapper' in runningApps[displayedApp].frame.dataset) { + wrapperFooter.classList.remove('visible'); + wrapperHeader.classList.remove('visible'); + } + }); + } + + function removeFrame(origin) { + var app = runningApps[origin]; + var frame = app.frame; + + if (frame) { + windows.removeChild(frame); + clearFrameBackground(frame); + } + + if (openFrame == frame) { + setOpenFrame(null); + setTimeout(openCallback); + openCallback = null; + } + if (closeFrame == frame) { + setCloseFrame(null); + setTimeout(closeCallback); + closeCallback = null; + } + + delete runningApps[origin]; + numRunningApps--; + } + + function removeInlineFrame(frame) { + // If frame is transitioning we should remove the reference + if (openFrame == frame) + setOpenFrame(null); + + // If frame is never set visible, we can remove the frame directly + // without closing transition + if (!frame.classList.contains('active')) { + windows.removeChild(frame); + return; + } + // Take keyboard focus away from the closing window + frame.firstChild.blur(); + // Remove the active class and start the closing transition + frame.classList.remove('active'); + } + + // If all is not specified, + // remove the top most frame + function stopInlineActivity(all) { + if (!inlineActivityFrames.length) + return; + + if (!all) { + var frame = inlineActivityFrames.pop(); + removeInlineFrame(frame); + } else { + // stop all activity frames + // Remore the inlineActivityFrame reference + for (var frame of inlineActivityFrames) { + removeInlineFrame(frame); + } + inlineActivityFrames = []; + } + + if (!inlineActivityFrames.length) { + // Give back focus to the displayed app + var app = runningApps[displayedApp]; + if (app && app.iframe) { + app.iframe.focus(); + if ('wrapper' in app.frame.dataset) { + wrapperFooter.classList.add('visible'); + } + } + screenElement.classList.remove('inline-activity'); + } + } + + // Watch activity completion here instead of activity.js + // Because we know when and who to re-launch when activity ends. + window.addEventListener('mozChromeEvent', function(e) { + if (e.detail.type == 'activity-done') { + // Remove the top most frame every time we get an 'activity-done' event. + stopInlineActivity(); + if (!inlineActivityFrames.length) { + setDisplayedApp(activityCallerOrigin); + activityCallerOrigin = ''; + } + } + }); + + // There are two types of mozChromeEvent we need to handle + // in order to launch the app for Gecko + window.addEventListener('mozChromeEvent', function(e) { + var startTime = Date.now(); + + var manifestURL = e.detail.manifestURL; + if (!manifestURL) + return; + + var app = Applications.getByManifestURL(manifestURL); + if (!app) + return; + + var manifest = app.manifest; + var name = new ManifestHelper(manifest).name; + var origin = app.origin; + + // Check if it's a virtual app from a entry point. + // If so, change the app name and origin to the + // entry point. + var entryPoints = manifest.entry_points; + if (entryPoints && manifest.type == 'certified') { + var givenPath = e.detail.url.substr(origin.length); + + // Workaround here until the bug (to be filed) is fixed + // Basicly, gecko is sending the URL without launch_path sometimes + for (var ep in entryPoints) { + var currentEp = entryPoints[ep]; + var path = givenPath; + if (path.indexOf('?') != -1) { + path = path.substr(0, path.indexOf('?')); + } + + //Remove the origin and / to find if if the url is the entry point + if (path.indexOf('/' + ep) == 0 && + (currentEp.launch_path == path)) { + origin = origin + currentEp.launch_path; + name = new ManifestHelper(currentEp).name; + } + } + } + switch (e.detail.type) { + // mozApps API is asking us to launch the app + // We will launch it in foreground + case 'webapps-launch': + if (origin == homescreen) { + // No need to append a frame if is homescreen + setDisplayedApp(); + } else { + if (!isRunning(origin)) { + appendFrame(null, origin, e.detail.url, + name, app.manifest, app.manifestURL); + } + runningApps[origin].iframe.dataset.start = startTime; + setDisplayedApp(origin, null, 'window'); + } + break; + // System Message Handler API is asking us to open the specific URL + // that handles the pending system message. + // We will launch it in background if it's not handling an activity. + case 'open-app': + // If the system message goes to System app, + // we should not be launching that in a frame. + if (e.detail.url === window.location.href) + return; + + if (e.detail.isActivity && e.detail.target.disposition && + e.detail.target.disposition == 'inline') { + // Inline activities behaves more like a dialog, + // let's deal them here. + + startInlineActivity(origin, e.detail.url, + name, manifest, app.manifestURL); + + return; + } + + if (isRunning(origin)) { + // If the app is in foreground, it's too risky to change it's + // URL. We'll ignore this request. + if (displayedApp !== origin) { + var iframe = getAppFrame(origin).firstChild; + + // If the app is opened and it is loaded to the correct page, + // then there is nothing to do. + if (iframe.src !== e.detail.url) { + // Rewrite the URL of the app frame to the requested URL. + // XXX: We could ended opening URls not for the app frame + // in the app frame. But we don't care. + iframe.src = e.detail.url; + } + } + } else if (origin !== homescreen) { + // XXX: We could ended opening URls not for the app frame + // in the app frame. But we don't care. + appendFrame(null, origin, e.detail.url, + name, manifest, app.manifestURL); + + // set the size of the iframe + // so Cards View will get a correct screenshot of the frame + if (!e.detail.isActivity) + setAppSize(origin, false); + } else { + ensureHomescreen(); + } + + // We will only bring web activity handling apps to the foreground + if (!e.detail.isActivity) + return; + + // XXX: the correct way would be for UtilityTray to close itself + // when there is a appwillopen/appopen event. + UtilityTray.hide(); + + setDisplayedApp(origin); + + break; + } + }); + + // If the application tried to close themselves by calling window.close() + // we will handle that here. + // XXX: this event is fired twice: + // https://bugzilla.mozilla.org/show_bug.cgi?id=814583 + window.addEventListener('mozbrowserclose', function(e) { + if (!'frameType' in e.target.dataset) + return; + + switch (e.target.dataset.frameType) { + case 'window': + kill(e.target.dataset.frameOrigin); + break; + + case 'inline-activity': + stopInlineActivity(true); + break; + } + }); + + // Deal with locationchange + window.addEventListener('mozbrowserlocationchange', function(e) { + if (!'frameType' in e.target.dataset) + return; + + e.target.dataset.url = e.detail; + }); + + // Deal with application uninstall event + // if the application is being uninstalled, we ensure it stop running here. + window.addEventListener('applicationuninstall', function(e) { + kill(e.detail.application.origin); + + deleteAppScreenshotFromDatabase(e.detail.application.origin); + }); + + // When an UI layer is overlapping the current app, + // WindowManager should set the visibility of app iframe to false + // And reset to true when the layer is gone. + // We may need to handle windowclosing, windowopened in the future. + var attentionScreenTimer = null; + + var overlayEvents = [ + 'lock', + 'will-unlock', + 'attentionscreenshow', + 'attentionscreenhide', + 'status-active', + 'status-inactive' + ]; + + function overlayEventHandler(evt) { + if (attentionScreenTimer) + clearTimeout(attentionScreenTimer); + switch (evt.type) { + case 'status-active': + case 'attentionscreenhide': + case 'will-unlock': + if (LockScreen.locked) + return; + if (inlineActivityFrames.length) { + setVisibilityForInlineActivity(true); + } else { + setVisibilityForCurrentApp(true); + } + break; + case 'lock': + setVisibilityForCurrentApp(false); + break; + + /* + * Because in-transition is needed in attention screen, + * We set a timer here to deal with visibility change + */ + case 'status-inactive': + if (!AttentionScreen.isVisible()) + return; + case 'attentionscreenshow': + if (evt.detail && evt.detail.origin && + evt.detail.origin != displayedApp) { + attentionScreenTimer = setTimeout(function setVisibility() { + if (inlineActivityFrames.length) { + setVisibilityForInlineActivity(false); + } else { + setVisibilityForCurrentApp(false); + } + }, 3000); + + // Immediatly blur the frame in order to ensure hiding the keyboard + var app = runningApps[displayedApp]; + if (app) + app.iframe.blur(); + } + break; + } + } + + overlayEvents.forEach(function overlayEventIterator(event) { + window.addEventListener(event, overlayEventHandler); + }); + + function setVisibilityForInlineActivity(visible) { + if (!inlineActivityFrames.length) + return; + + var topFrame = inlineActivityFrames[inlineActivityFrames.length - 1].firstChild; + if ('setVisible' in topFrame) { + topFrame.setVisible(visible); + } + + // Restore/give away focus on visiblity change + // so that the app can take back its focus + if (visible) { + topFrame.focus(); + } else { + topFrame.blur(); + } + } + + function setVisibilityForCurrentApp(visible) { + var app = runningApps[displayedApp]; + if (!app) + return; + if ('setVisible' in app.iframe) + app.iframe.setVisible(visible); + + // Restore/give away focus on visiblity change + // so that the app can take back its focus + if (visible) + app.iframe.focus(); + else + app.iframe.blur(); + } + + function handleAppCrash(origin, manifestURL) { + if (origin && manifestURL) { + // When inline activity frame crashes, + // query the localized name from manifest + var app = Applications.getByManifestURL(manifestURL); + CrashReporter.setAppName(getAppName(origin, app.manifest)); + } else { + var app = runningApps[displayedApp]; + CrashReporter.setAppName(app.name); + } + } + + function getAppName(origin, manifest) { + if (!manifest) + return ''; + + if (manifest.entry_points && manifest.type == 'certified') { + var entryPoint = manifest.entry_points[origin.split('/')[3]]; + return new ManifestHelper(entryPoint).name; + } + return new ManifestHelper(manifest).name; + } + + // Deal with crashed apps + window.addEventListener('mozbrowsererror', function(e) { + if (!'frameType' in e.target.dataset) + return; + + var origin = e.target.dataset.frameOrigin; + var manifestURL = e.target.getAttribute('mozapp'); + + if (e.target.dataset.frameType == 'inline-activity') { + stopInlineActivity(true); + handleAppCrash(origin, manifestURL); + return; + } + + if (e.target.dataset.frameType !== 'window') + return; + + /* + detail.type = error (Server Not Found case) + is handled in Modal Dialog + */ + if (e.detail.type !== 'fatal') + return; + + // If the crashing app is currently displayed, we will present + // the user with a banner notification. + if (displayedApp == origin) + handleAppCrash(); + + // If the crashing app is the home screen app and it is the displaying app + // we will need to relaunch it right away. + // Alternatively, if home screen is not the displaying app, + // we will not relaunch it until the foreground app is closed. + // (to be dealt in setDisplayedApp(), not here) + if (displayedApp == homescreen) { + kill(origin, function relaunchHomescreen() { + setDisplayedApp(homescreen); + }); + return; + } + + // Actually remove the frame, and trigger the closing transition + // if the app is currently displaying + kill(origin); + }); + + + function hasPermission(app, permission) { + var mozPerms = navigator.mozPermissionSettings; + if (!mozPerms) + return false; + + var value = mozPerms.get(permission, app.manifestURL, app.origin, false); + + return (value === 'allow'); + } + + // Use a setting in order to be "called" by settings app + navigator.mozSettings.addObserver( + 'clear.remote-windows.data', + function clearRemoteWindowsData(setting) { + var shouldClear = setting.settingValue; + if (!shouldClear) + return; + + // Delete all storage and cookies from our content processes + var request = navigator.mozApps.getSelf(); + request.onsuccess = function() { + request.result.clearBrowserData(); + }; + + // Reset the setting value to false + var lock = navigator.mozSettings.createLock(); + lock.set({'clear.remote-windows.data': false}); + }); + + // Watch for window.open usages in order to open wrapper frames + window.addEventListener('mozbrowseropenwindow', function handleWrapper(evt) { + var detail = evt.detail; + var features; + try { + features = JSON.parse(detail.features); + } catch (e) { + features = {}; + } + + // Handles only call to window.open with `{remote: true}` feature. + if (!features.remote) + return; + + // XXX bug 819882: for now, only allows homescreen to open oop windows + var callerIframe = evt.target; + var callerFrame = callerIframe.parentNode; + var manifestURL = callerIframe.getAttribute('mozapp'); + var callerApp = Applications.getByManifestURL(manifestURL); + if (!callerApp || !callerFrame.classList.contains('homescreen')) + return; + var callerOrigin = callerApp.origin; + + // So, we are going to open a remote window. + // Now, avoid PopupManager listener to be fired. + evt.stopImmediatePropagation(); + + var name = detail.name; + var url = detail.url; + + // Use fake origin for named windows in order to be able to reuse them, + // otherwise always open a new window for '_blank'. + var origin = null; + var app = null; + if (name == '_blank') { + origin = url; + + // Just bring on top if a wrapper window is already running with this url + if (origin in runningApps && + runningApps[origin].windowName == '_blank') { + setDisplayedApp(origin); + return; + } + } else { + origin = 'window:' + name + ',source:' + callerOrigin; + + var runningApp = runningApps[origin]; + if (runningApp && runningApp.windowName === name) { + if (runningApp.iframe.src === url) { + // If the url is already loaded, just display the app + setDisplayedApp(origin); + return; + } else { + // Wrapper context shouldn't be shared between two apps -> killing + kill(origin); + } + } + } + + var title = '', icon = '', remote = false, useAsyncPanZoom = false; + var originName, originURL, searchName, searchURL; + + try { + var features = JSON.parse(detail.features); + var regExp = new RegExp(' ', 'g'); + + title = features.name.replace(regExp, ' ') || url; + icon = features.icon || ''; + + if (features.origin) { + originName = features.origin.name.replace(regExp, ' '); + originURL = decodeURIComponent(features.origin.url); + } + + if (features.search) { + searchName = features.search.name.replace(regExp, ' '); + searchURL = decodeURIComponent(features.search.url); + } + + if (features.useAsyncPanZoom) + useAsyncPanZoom = true; + } catch (ex) { } + + // If we don't reuse an existing app, open a brand new one + var iframe; + if (!app) { + // Bug 807438: Move new window document OOP + // Ignore `event.detail.frameElement` for now in order + // to create a remote system app frame. + // So that new window documents are going to share + // system app content processes data jar. + iframe = document.createElement('iframe'); + iframe.setAttribute('mozbrowser', 'true'); + iframe.setAttribute('remote', 'true'); + + iframe.addEventListener('mozbrowserloadstart', function start() { + iframe.dataset.loading = true; + wrapperHeader.classList.add('visible'); + }); + + iframe.addEventListener('mozbrowserloadend', function end() { + delete iframe.dataset.loading; + wrapperHeader.classList.remove('visible'); + }); + + // `mozasyncpanzoom` only works when added before attaching the iframe + // node to the document. + if (useAsyncPanZoom) { + iframe.dataset.useAsyncPanZoom = true; + iframe.setAttribute('mozasyncpanzoom', 'true'); + } + + var app = appendFrame(iframe, origin, url, title, { + 'name': title + }, null); + + // Set the window name in order to reuse this app if we try to open + // a new window with same name + app.windowName = name; + } else { + iframe = app.iframe; + + // Update app name for the card view + app.manifest.name = title; + } + + iframe.dataset.name = title; + iframe.dataset.icon = icon; + + if (originName) + iframe.dataset.originName = originName; + if (originURL) + iframe.dataset.originURL = originURL; + + if (searchName) + iframe.dataset.searchName = searchName; + if (searchURL) + iframe.dataset.searchURL = searchURL; + + // First load blank page in order to hide previous website + iframe.src = url; + + setDisplayedApp(origin); + }, true); // Use capture in order to catch the event before PopupManager does + + + // Stop running the app with the specified origin + function kill(origin, callback) { + if (!isRunning(origin)) + return; + + // As we can't immediatly remove runningApps entry, + // we flag it as being killed in order to avoid trying to remove it twice. + // (Check required because of bug 814583) + if (runningApps[origin].killed) + return; + runningApps[origin].killed = true; + + // If the app is the currently displayed app, switch to the homescreen + if (origin === displayedApp) { + // when the homescreen is displayed and being + // killed we need to forcibly restart it... + if (origin === homescreen) { + removeFrame(origin); + + // XXX workaround bug 810431. + // we need this here and not in other situations + // as it is expected that homescreen frame is available. + setTimeout(function() { + setDisplayedApp(); + + if (callback) { + callback(); + } + }); + } else { + setDisplayedApp(homescreen, function() { + removeFrame(origin); + if (callback) + setTimeout(callback); + }); + } + + } else { + removeFrame(origin); + } + + // Send a synthentic 'appterminated' event. + // Let other system app module know an app is + // being killed, removed or crashed. + var evt = document.createEvent('CustomEvent'); + evt.initCustomEvent('appterminated', true, false, { origin: origin }); + window.dispatchEvent(evt); + } + + // Reload the frame of the running app + function reload(origin) { + if (!isRunning(origin)) + return; + + var app = runningApps[origin]; + app.reload(); + } + + // When a resize event occurs, resize the running app, if there is one + // When the status bar is active it doubles in height so we need a resize + var appResizeEvents = ['resize', 'status-active', 'status-inactive', + 'keyboardchange', 'keyboardhide', + 'attentionscreenhide']; + appResizeEvents.forEach(function eventIterator(event) { + window.addEventListener(event, function on(evt) { + if (event == 'keyboardchange') { + // Cancel fullscreen if keyboard pops + if (document.mozFullScreen) + document.mozCancelFullScreen(); + + setAppHeight(evt.detail.height); + } else if (displayedApp) { + setAppSize(displayedApp); + } + }); + }); + + window.addEventListener('home', function(e) { + // If the lockscreen is active, it will stop propagation on this event + // and we'll never see it here. Similarly, other overlays may use this + // event to hide themselves and may prevent the event from getting here. + // Note that for this to work, the lockscreen and other overlays must + // be included in index.html before this one, so they can register their + // event handlers before we do. + + // If we are currently transitioning, the user would like to cancel + // it instead of toggling homescreen panels. + var inTransition = !!(openFrame || closeFrame); + + if (document.mozFullScreen) { + document.mozCancelFullScreen(); + } + + if (displayedApp !== homescreen || inTransition) { + if (displayedApp != ftuURL) { + setDisplayedApp(homescreen); + } else { + e.preventDefault(); + } + } else { + stopInlineActivity(true); + ensureHomescreen(true); + } + }); + + // Cancel dragstart event to workaround + // https://bugzilla.mozilla.org/show_bug.cgi?id=783076 + // which stops OOP home screen pannable with left mouse button on + // B2G/Desktop. + windows.addEventListener('dragstart', function(evt) { + evt.preventDefault(); + }, true); + + // With all important event handlers in place, we can now notify + // Gecko that we're ready for certain system services to send us + // messages (e.g. the radio). + // Note that shell.js starts listen for the mozContentEvent event at + // mozbrowserloadstart, which sometimes does not happen till window.onload. + window.addEventListener('load', function wm_loaded() { + window.removeEventListener('load', wm_loaded); + + var evt = new CustomEvent('mozContentEvent', + { bubbles: true, cancelable: false, + detail: { type: 'system-message-listener-ready' } }); + window.dispatchEvent(evt); + }); + + // This is code copied from + // http://dl.dropbox.com/u/8727858/physical-events/index.html + // It appears to workaround the Nexus S bug where we're not + // getting orientation data. See: + // https://bugzilla.mozilla.org/show_bug.cgi?id=753245 + // It seems it needs to be in both window_manager.js and bootstrap.js. + function dumbListener2(event) {} + window.addEventListener('devicemotion', dumbListener2); + + window.setTimeout(function() { + window.removeEventListener('devicemotion', dumbListener2); + }, 2000); + + // Return the object that holds the public API + return { + isFtuRunning: function() { + return isRunningFirstRunApp; + }, + launch: launch, + kill: kill, + reload: reload, + getDisplayedApp: getDisplayedApp, + setOrientationForApp: setOrientationForApp, + getAppFrame: getAppFrame, + getRunningApps: function() { + return runningApps; + }, + setDisplayedApp: setDisplayedApp, + getCurrentDisplayedApp: function() { + return runningApps[displayedApp]; + }, + hideCurrentApp: hideCurrentApp, + restoreCurrentApp: restoreCurrentApp, + retrieveHomescreen: retrieveHomescreen, + retrieveFTU: retrieveFTU + }; +}()); + diff --git a/apps/system/js/wrapper.js b/apps/system/js/wrapper.js new file mode 100644 index 0000000..160d70b --- /dev/null +++ b/apps/system/js/wrapper.js @@ -0,0 +1,198 @@ +/* -*- Mode: js; js-indent-level: 2; indent-tabs-mode: nil -*- */ +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + +'use strict'; + +var Launcher = (function() { + function log(str) { + dump(' -+- Launcher -+-: ' + str + '\n'); + } + + function currentAppFrame() { + return WindowManager.getAppFrame(WindowManager.getDisplayedApp()); + } + + + function currentAppIframe() { + return currentAppFrame().firstChild; + } + + var _ = navigator.mozL10n.get; + + var BUTTONBAR_TIMEOUT = 5000; + var BUTTONBAR_INITIAL_OPEN_TIMEOUT = 1500; + + var footer = document.querySelector('#wrapper-footer'); + window.addEventListener('appopen', function onAppOpen(e) { + if ('wrapper' in currentAppFrame().dataset) { + window.addEventListener('mozbrowserlocationchange', onLocationChange); + onLocationChange(); + onDisplayedApplicationChange(); + } + }); + + window.addEventListener('appwillclose', function onAppClose(e) { + if ('wrapper' in currentAppFrame().dataset) { + window.removeEventListener('mozbrowserlocationchange', onLocationChange); + clearTimeout(buttonBarTimeout); + footer.classList.add('closed'); + isButtonBarDisplayed = false; + } + }); + + window.addEventListener('keyboardchange', function onKeyboardChange(e) { + if ('wrapper' in currentAppFrame().dataset) { + if (footer.classList.contains('visible')) { + footer.classList.remove('visible'); + } + } + }); + + window.addEventListener('keyboardhide', function onKeyboardChange(e) { + if ('wrapper' in currentAppFrame().dataset) { + if (!footer.classList.contains('visible')) { + footer.classList.add('visible'); + } + } + }); + + var buttonBarTimeout; + + var isButtonBarDisplayed = false; + function toggleButtonBar(time) { + clearTimeout(buttonBarTimeout); + footer.classList.toggle('closed'); + isButtonBarDisplayed = !isButtonBarDisplayed; + if (isButtonBarDisplayed) { + buttonBarTimeout = setTimeout(toggleButtonBar, time || BUTTONBAR_TIMEOUT); + } + } + + function clearButtonBarTimeout() { + clearTimeout(buttonBarTimeout); + buttonBarTimeout = setTimeout(toggleButtonBar, BUTTONBAR_TIMEOUT); + } + + document.getElementById('handler'). + addEventListener('mousedown', function open() { toggleButtonBar() }); + + document.getElementById('close-button'). + addEventListener('mousedown', function close() { toggleButtonBar() }); + + var reload = document.getElementById('reload-button'); + reload.addEventListener('click', function doReload(evt) { + clearButtonBarTimeout(); + currentAppIframe().reload(true); + }); + + var back = document.getElementById('back-button'); + back.addEventListener('click', function goBack() { + clearButtonBarTimeout(); + currentAppIframe().goBack(); + }); + + var forward = document.getElementById('forward-button'); + forward.addEventListener('click', function goForward() { + clearButtonBarTimeout(); + currentAppIframe().goForward(); + }); + + function onLocationChange() { + currentAppIframe().getCanGoForward().onsuccess = function forwardSuccess(e) { + if (e.target.result === true) { + delete forward.dataset.disabled; + } else { + forward.dataset.disabled = true; + } + } + + currentAppIframe().getCanGoBack().onsuccess = function backSuccess(e) { + if (e.target.result === true) { + delete back.dataset.disabled; + } else { + back.dataset.disabled = true; + } + } + } + + window.addEventListener('mozbrowserlocationchange', onLocationChange); + + var bookmarkButton = document.getElementById('bookmark-button'); + function onDisplayedApplicationChange() { + toggleButtonBar(BUTTONBAR_INITIAL_OPEN_TIMEOUT); + + var dataset = currentAppIframe().dataset; + if (dataset.originURL || dataset.searchURL) { + delete bookmarkButton.dataset.disabled; + return; + } + + bookmarkButton.dataset.disabled = true; + } + + bookmarkButton.addEventListener('click', function doBookmark(evt) { + if (bookmarkButton.dataset.disabled) + return; + + clearButtonBarTimeout(); + var dataset = currentAppIframe().dataset; + + function selected(value) { + if (!value) + return; + + var name, url; + if (value === 'origin') { + name = dataset.originName; + url = dataset.originURL; + } + + if (value === 'search') { + name = dataset.searchName; + url = dataset.searchURL; + } + + var activity = new MozActivity({ + name: 'save-bookmark', + data: { + type: 'url', + url: url, + name: name, + icon: dataset.icon, + useAsyncPanZoom: dataset.useAsyncPanZoom, + iconable: false + } + }); + + activity.onsuccess = function onsuccess() { + if (value === 'origin') { + delete currentAppIframe().dataset.originURL; + } + + if (value === 'search') { + delete currentAppIframe().dataset.searchURL; + } + + if (!currentAppIframe().dataset.originURL && + !currentAppIframe().dataset.searchURL) { + bookmarkButton.dataset.disabled = true; + } + } + } + + var data = { + title: _('add-to-home-screen'), + options: [] + }; + + if (dataset.originURL) { + data.options.push({ id: 'origin', text: dataset.originName }); + } + + if (dataset.searchURL) { + data.options.push({ id: 'search', text: dataset.searchName }); + } + + ModalDialog.selectOne(data, selected); + }); +}()); |