diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-21 11:44:51 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-21 11:44:51 +0000 |
commit | 9e3c08db40b8916968b9f30096c7be3f00ce9647 (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /toolkit/modules/Region.sys.mjs | |
parent | Initial commit. (diff) | |
download | thunderbird-9e3c08db40b8916968b9f30096c7be3f00ce9647.tar.xz thunderbird-9e3c08db40b8916968b9f30096c7be3f00ce9647.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/modules/Region.sys.mjs')
-rw-r--r-- | toolkit/modules/Region.sys.mjs | 887 |
1 files changed, 887 insertions, 0 deletions
diff --git a/toolkit/modules/Region.sys.mjs b/toolkit/modules/Region.sys.mjs new file mode 100644 index 0000000000..a07e73f378 --- /dev/null +++ b/toolkit/modules/Region.sys.mjs @@ -0,0 +1,887 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +import { RemoteSettings } from "resource://services-settings/remote-settings.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + LocationHelper: "resource://gre/modules/LocationHelper.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "wifiScanningEnabled", + "browser.region.network.scan", + true +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "networkTimeout", + "browser.region.timeout", + 5000 +); + +// Retry the region lookup every hour on failure, a failure +// is likely to be a service failure so this gives the +// service some time to restore. Setting to 0 disabled retries. +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "retryTimeout", + "browser.region.retry-timeout", + 60 * 60 * 1000 +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "loggingEnabled", + "browser.region.log", + false +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "cacheBustEnabled", + "browser.region.update.enabled", + false +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "updateDebounce", + "browser.region.update.debounce", + 60 * 60 * 24 +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "lastUpdated", + "browser.region.update.updated", + 0 +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "localGeocodingEnabled", + "browser.region.local-geocoding", + false +); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "timerManager", + "@mozilla.org/updates/timer-manager;1", + "nsIUpdateTimerManager" +); + +const log = console.createInstance({ + prefix: "Region.sys.mjs", + maxLogLevel: lazy.loggingEnabled ? "All" : "Warn", +}); + +const REGION_PREF = "browser.search.region"; +const COLLECTION_ID = "regions"; +const GEOLOCATION_TOPIC = "geolocation-position-events"; + +// Prefix for all the region updating related preferences. +const UPDATE_PREFIX = "browser.region.update"; + +// The amount of time (in seconds) we need to be in a new +// location before we update the home region. +// Currently set to 2 weeks. +const UPDATE_INTERVAL = 60 * 60 * 24 * 14; + +const MAX_RETRIES = 3; + +// If the user never uses geolocation, schedule a periodic +// update to check the current location (in seconds). +const UPDATE_CHECK_NAME = "region-update-timer"; +const UPDATE_CHECK_INTERVAL = 60 * 60 * 24 * 7; + +// Let child processes read the current home value +// but dont trigger redundant updates in them. +let inChildProcess = + Services.appinfo.processType != Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT; + +/** + * This module keeps track of the users current region (country). + * so the SearchService and other consumers can apply region + * specific customisations. + */ +class RegionDetector { + // The users home location. + _home = null; + // The most recent location the user was detected. + _current = null; + // The RemoteSettings client used to sync region files. + _rsClient = null; + // Keep track of the wifi data across listener events. + _wifiDataPromise = null; + // Keep track of how many times we have tried to fetch + // the users region during failure. + _retryCount = 0; + // Let tests wait for init to complete. + _initPromise = null; + // Topic for Observer events fired by Region.sys.mjs. + REGION_TOPIC = "browser-region-updated"; + // Values for telemetry. + TELEMETRY = { + SUCCESS: 0, + NO_RESULT: 1, + TIMEOUT: 2, + ERROR: 3, + }; + + /** + * Read currently stored region data and if needed trigger background + * region detection. + */ + async init() { + if (this._initPromise) { + return this._initPromise; + } + if (lazy.cacheBustEnabled && !inChildProcess) { + Services.tm.idleDispatchToMainThread(() => { + lazy.timerManager.registerTimer( + UPDATE_CHECK_NAME, + () => this._updateTimer(), + UPDATE_CHECK_INTERVAL + ); + }); + } + let promises = []; + this._home = Services.prefs.getCharPref(REGION_PREF, null); + if (!this._home && !inChildProcess) { + promises.push(this._idleDispatch(() => this._fetchRegion())); + } + if (lazy.localGeocodingEnabled && !inChildProcess) { + promises.push(this._idleDispatch(() => this._setupRemoteSettings())); + } + return (this._initPromise = Promise.all(promises)); + } + + /** + * Get the region we currently consider the users home. + * + * @returns {string} + * The users current home region. + */ + get home() { + return this._home; + } + + /** + * Get the last region we detected the user to be in. + * + * @returns {string} + * The users current region. + */ + get current() { + return this._current; + } + + /** + * Fetch the users current region. + * + * @returns {string} + * The country_code defining users current region. + */ + async _fetchRegion() { + if (this._retryCount >= MAX_RETRIES) { + return null; + } + let startTime = Date.now(); + let telemetryResult = this.TELEMETRY.SUCCESS; + let result = null; + + try { + result = await this._getRegion(); + } catch (err) { + telemetryResult = this.TELEMETRY[err.message] || this.TELEMETRY.ERROR; + log.error("Failed to fetch region", err); + if (lazy.retryTimeout) { + this._retryCount++; + lazy.setTimeout(() => { + Services.tm.idleDispatchToMainThread(this._fetchRegion.bind(this)); + }, lazy.retryTimeout); + } + } + + let took = Date.now() - startTime; + if (result) { + await this._storeRegion(result); + } + Services.telemetry + .getHistogramById("SEARCH_SERVICE_COUNTRY_FETCH_TIME_MS") + .add(took); + + Services.telemetry + .getHistogramById("SEARCH_SERVICE_COUNTRY_FETCH_RESULT") + .add(telemetryResult); + + return result; + } + + /** + * Validate then store the region and report telemetry. + * + * @param region + * The region to store. + */ + async _storeRegion(region) { + let prefix = "SEARCH_SERVICE"; + let isTimezoneUS = isUSTimezone(); + // If it's a US region, but not a US timezone, we don't store + // the value. This works because no region defaults to + // ZZ (unknown) in nsURLFormatter + if (region != "US" || isTimezoneUS) { + this._setCurrentRegion(region, true); + } + + // and telemetry... + if (region == "US" && !isTimezoneUS) { + log.info("storeRegion mismatch - US Region, non-US timezone"); + Services.telemetry + .getHistogramById(`${prefix}_US_COUNTRY_MISMATCHED_TIMEZONE`) + .add(1); + } + if (region != "US" && isTimezoneUS) { + log.info("storeRegion mismatch - non-US Region, US timezone"); + Services.telemetry + .getHistogramById(`${prefix}_US_TIMEZONE_MISMATCHED_COUNTRY`) + .add(1); + } + // telemetry to compare our geoip response with + // platform-specific country data. + // On Mac and Windows, we can get a country code via sysinfo + let platformCC = await Services.sysinfo.countryCode; + if (platformCC) { + let probeUSMismatched, probeNonUSMismatched; + switch (AppConstants.platform) { + case "macosx": + probeUSMismatched = `${prefix}_US_COUNTRY_MISMATCHED_PLATFORM_OSX`; + probeNonUSMismatched = `${prefix}_NONUS_COUNTRY_MISMATCHED_PLATFORM_OSX`; + break; + case "win": + probeUSMismatched = `${prefix}_US_COUNTRY_MISMATCHED_PLATFORM_WIN`; + probeNonUSMismatched = `${prefix}_NONUS_COUNTRY_MISMATCHED_PLATFORM_WIN`; + break; + default: + log.error( + "Platform " + + Services.appinfo.OS + + " has system country code but no search service telemetry probes" + ); + break; + } + if (probeUSMismatched && probeNonUSMismatched) { + if (region == "US" || platformCC == "US") { + // one of the 2 said US, so record if they are the same. + Services.telemetry + .getHistogramById(probeUSMismatched) + .add(region != platformCC); + } else { + // non-US - record if they are the same + Services.telemetry + .getHistogramById(probeNonUSMismatched) + .add(region != platformCC); + } + } + } + } + + /** + * Save the update current region and check if the home region + * also needs an update. + * + * @param {string} region + * The region to store. + */ + _setCurrentRegion(region = "") { + log.info("Setting current region:", region); + this._current = region; + + let now = Math.round(Date.now() / 1000); + let prefs = Services.prefs; + prefs.setIntPref(`${UPDATE_PREFIX}.updated`, now); + + // Interval is in seconds. + let interval = prefs.getIntPref( + `${UPDATE_PREFIX}.interval`, + UPDATE_INTERVAL + ); + let seenRegion = prefs.getCharPref(`${UPDATE_PREFIX}.region`, null); + let firstSeen = prefs.getIntPref(`${UPDATE_PREFIX}.first-seen`, 0); + + // If we don't have a value for .home we can set it immediately. + if (!this._home) { + this._setHomeRegion(region); + } else if (region != this._home && region != seenRegion) { + // If we are in a different region than what is currently + // considered home, then keep track of when we first + // seen the new location. + prefs.setCharPref(`${UPDATE_PREFIX}.region`, region); + prefs.setIntPref(`${UPDATE_PREFIX}.first-seen`, now); + } else if (region != this._home && region == seenRegion) { + // If we have been in the new region for longer than + // a specified time period, then set that as the new home. + if (now >= firstSeen + interval) { + this._setHomeRegion(region); + } + } else { + // If we are at home again, stop tracking the seen region. + prefs.clearUserPref(`${UPDATE_PREFIX}.region`); + prefs.clearUserPref(`${UPDATE_PREFIX}.first-seen`); + } + } + + // Wrap a string as a nsISupports. + _createSupportsString(data) { + let string = Cc["@mozilla.org/supports-string;1"].createInstance( + Ci.nsISupportsString + ); + string.data = data; + return string; + } + + /** + * Save the updated home region and notify observers. + * + * @param {string} region + * The region to store. + * @param {boolean} [notify] + * Tests can disable the notification for convenience as it + * may trigger an engines reload. + */ + _setHomeRegion(region, notify = true) { + if (region == this._home) { + return; + } + log.info("Updating home region:", region); + this._home = region; + Services.prefs.setCharPref("browser.search.region", region); + if (notify) { + Services.obs.notifyObservers( + this._createSupportsString(region), + this.REGION_TOPIC + ); + } + } + + /** + * Make the request to fetch the region from the configured service. + */ + async _getRegion() { + log.info("_getRegion called"); + let fetchOpts = { + headers: { "Content-Type": "application/json" }, + credentials: "omit", + }; + if (lazy.wifiScanningEnabled) { + let wifiData = await this._fetchWifiData(); + if (wifiData) { + let postData = JSON.stringify({ wifiAccessPoints: wifiData }); + log.info("Sending wifi details: ", wifiData); + fetchOpts.method = "POST"; + fetchOpts.body = postData; + } + } + let url = Services.urlFormatter.formatURLPref("browser.region.network.url"); + log.info("_getRegion url is: ", url); + + if (!url) { + return null; + } + + try { + let req = await this._fetchTimeout(url, fetchOpts, lazy.networkTimeout); + let res = await req.json(); + log.info("_getRegion returning ", res.country_code); + return res.country_code; + } catch (err) { + log.error("Error fetching region", err); + let errCode = err.message in this.TELEMETRY ? err.message : "NO_RESULT"; + throw new Error(errCode); + } + } + + /** + * Setup the RemoteSetting client + sync listener and ensure + * the map files are downloaded. + */ + async _setupRemoteSettings() { + log.info("_setupRemoteSettings"); + this._rsClient = RemoteSettings(COLLECTION_ID); + this._rsClient.on("sync", this._onRegionFilesSync.bind(this)); + await this._ensureRegionFilesDownloaded(); + // Start listening to geolocation events only after + // we know the maps are downloded. + Services.obs.addObserver(this, GEOLOCATION_TOPIC); + } + + /** + * Called when RemoteSettings syncs new data, clean up any + * stale attachments and download any new ones. + * + * @param {Object} syncData + * Object describing the data that has just been synced. + */ + async _onRegionFilesSync({ data: { deleted } }) { + log.info("_onRegionFilesSync"); + const toDelete = deleted.filter(d => d.attachment); + // Remove local files of deleted records + await Promise.all( + toDelete.map(entry => this._rsClient.attachments.deleteDownloaded(entry)) + ); + await this._ensureRegionFilesDownloaded(); + } + + /** + * Download the RemoteSetting record attachments, when they are + * successfully downloaded set a flag so we can start using them + * for geocoding. + */ + async _ensureRegionFilesDownloaded() { + log.info("_ensureRegionFilesDownloaded"); + let records = (await this._rsClient.get()).filter(d => d.attachment); + log.info("_ensureRegionFilesDownloaded", records); + if (!records.length) { + log.info("_ensureRegionFilesDownloaded: Nothing to download"); + return; + } + await Promise.all(records.map(r => this._rsClient.attachments.download(r))); + log.info("_ensureRegionFilesDownloaded complete"); + this._regionFilesReady = true; + } + + /** + * Fetch an attachment from RemoteSettings. + * + * @param {String} id + * The id of the record to fetch the attachment from. + */ + async _fetchAttachment(id) { + let record = (await this._rsClient.get({ filters: { id } })).pop(); + let { buffer } = await this._rsClient.attachments.download(record); + let text = new TextDecoder("utf-8").decode(buffer); + return JSON.parse(text); + } + + /** + * Get a map of the world with region definitions. + */ + async _getPlainMap() { + return this._fetchAttachment("world"); + } + + /** + * Get a map with the regions expanded by a few km to help + * fallback lookups when a location is not within a region. + */ + async _getBufferedMap() { + return this._fetchAttachment("world-buffered"); + } + + /** + * Gets the users current location using the same reverse IP + * request that is used for GeoLocation requests. + * + * @returns {Object} location + * Object representing the user location, with a location key + * that contains the lat / lng coordinates. + */ + async _getLocation() { + log.info("_getLocation called"); + let fetchOpts = { headers: { "Content-Type": "application/json" } }; + let url = Services.urlFormatter.formatURLPref("geo.provider.network.url"); + let req = await this._fetchTimeout(url, fetchOpts, lazy.networkTimeout); + let result = await req.json(); + log.info("_getLocation returning", result); + return result; + } + + /** + * Return the users current region using + * request that is used for GeoLocation requests. + * + * @returns {String} + * A 2 character string representing a region. + */ + async _getRegionLocally() { + let { location } = await this._getLocation(); + return this._geoCode(location); + } + + /** + * Take a location and return the region code for that location + * by looking up the coordinates in geojson map files. + * Inspired by https://github.com/mozilla/ichnaea/blob/874e8284f0dfa1868e79aae64e14707eed660efe/ichnaea/geocode.py#L114 + * + * @param {Object} location + * A location object containing lat + lng coordinates. + * + * @returns {String} + * A 2 character string representing a region. + */ + async _geoCode(location) { + let plainMap = await this._getPlainMap(); + let polygons = this._getPolygonsContainingPoint(location, plainMap); + if (polygons.length == 1) { + log.info("Found in single exact region"); + return polygons[0].properties.alpha2; + } + if (polygons.length) { + log.info("Found in ", polygons.length, "overlapping exact regions"); + return this._findFurthest(location, polygons); + } + + // We haven't found a match in the exact map, use the buffered map + // to see if the point is close to a region. + let bufferedMap = await this._getBufferedMap(); + polygons = this._getPolygonsContainingPoint(location, bufferedMap); + + if (polygons.length === 1) { + log.info("Found in single buffered region"); + return polygons[0].properties.alpha2; + } + + // Matched more than one region, which one of those regions + // is it closest to without the buffer. + if (polygons.length) { + log.info("Found in ", polygons.length, "overlapping buffered regions"); + let regions = polygons.map(polygon => polygon.properties.alpha2); + let unBufferedRegions = plainMap.features.filter(feature => + regions.includes(feature.properties.alpha2) + ); + return this._findClosest(location, unBufferedRegions); + } + return null; + } + + /** + * Find all the polygons that contain a single point, return + * an array of those polygons along with the region that + * they define + * + * @param {Object} point + * A lat + lng coordinate. + * @param {Object} map + * Geojson object that defined seperate regions with a list + * of polygons. + * + * @returns {Array} + * An array of polygons that contain the point, along with the + * region they define. + */ + _getPolygonsContainingPoint(point, map) { + let polygons = []; + for (const feature of map.features) { + let coords = feature.geometry.coordinates; + if (feature.geometry.type === "Polygon") { + if (this._polygonInPoint(point, coords[0])) { + polygons.push(feature); + } + } else if (feature.geometry.type === "MultiPolygon") { + for (const innerCoords of coords) { + if (this._polygonInPoint(point, innerCoords[0])) { + polygons.push(feature); + } + } + } + } + return polygons; + } + + /** + * Find the largest distance between a point and any of the points that + * make up an array of regions. + * + * @param {Object} location + * A lat + lng coordinate. + * @param {Array} regions + * An array of GeoJSON region definitions. + * + * @returns {String} + * A 2 character string representing a region. + */ + _findFurthest(location, regions) { + let max = { distance: 0, region: null }; + this._traverse(regions, ({ lat, lng, region }) => { + let distance = this._distanceBetween(location, { lng, lat }); + if (distance > max.distance) { + max = { distance, region }; + } + }); + return max.region; + } + + /** + * Find the smallest distance between a point and any of the points that + * make up an array of regions. + * + * @param {Object} location + * A lat + lng coordinate. + * @param {Array} regions + * An array of GeoJSON region definitions. + * + * @returns {String} + * A 2 character string representing a region. + */ + _findClosest(location, regions) { + let min = { distance: Infinity, region: null }; + this._traverse(regions, ({ lat, lng, region }) => { + let distance = this._distanceBetween(location, { lng, lat }); + if (distance < min.distance) { + min = { distance, region }; + } + }); + return min.region; + } + + /** + * Utility function to loop over all the coordinate points in an + * array of polygons and call a function on them. + * + * @param {Array} regions + * An array of GeoJSON region definitions. + * @param {Function} fun + * Function to call on individual coordinates. + */ + _traverse(regions, fun) { + for (const region of regions) { + if (region.geometry.type === "Polygon") { + for (const [lng, lat] of region.geometry.coordinates[0]) { + fun({ lat, lng, region: region.properties.alpha2 }); + } + } else if (region.geometry.type === "MultiPolygon") { + for (const innerCoords of region.geometry.coordinates) { + for (const [lng, lat] of innerCoords[0]) { + fun({ lat, lng, region: region.properties.alpha2 }); + } + } + } + } + } + + /** + * Check whether a point is contained within a polygon using the + * point in polygon algorithm: + * https://en.wikipedia.org/wiki/Point_in_polygon + * This casts a ray from the point and counts how many times + * that ray intersects with the polygons borders, if it is + * an odd number of times the point is inside the polygon. + * + * @param {Object} location + * A lat + lng coordinate. + * @param {Object} polygon + * Array of coordinates that define the boundaries of a polygon. + * + * @returns {boolean} + * Whether the point is within the polygon. + */ + _polygonInPoint({ lng, lat }, poly) { + let inside = false; + // For each edge of the polygon. + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + let xi = poly[i][0]; + let yi = poly[i][1]; + let xj = poly[j][0]; + let yj = poly[j][1]; + // Does a ray cast from the point intersect with this polygon edge. + let intersect = + yi > lat != yj > lat && lng < ((xj - xi) * (lat - yi)) / (yj - yi) + xi; + // If so toggle result, an odd number of intersections + // means the point is inside. + if (intersect) { + inside = !inside; + } + } + return inside; + } + + /** + * Find the distance between 2 points. + * + * @param {Object} p1 + * A lat + lng coordinate. + * @param {Object} p2 + * A lat + lng coordinate. + * + * @returns {int} + * The distance between the 2 points. + */ + _distanceBetween(p1, p2) { + return Math.hypot(p2.lng - p1.lng, p2.lat - p1.lat); + } + + /** + * A wrapper around fetch that implements a timeout, will throw + * a TIMEOUT error if the request is not completed in time. + * + * @param {String} url + * The time url to fetch. + * @param {Object} opts + * The options object passed to the call to fetch. + * @param {int} timeout + * The time in ms to wait for the request to complete. + */ + async _fetchTimeout(url, opts, timeout) { + let controller = new AbortController(); + opts.signal = controller.signal; + return Promise.race([fetch(url, opts), this._timeout(timeout, controller)]); + } + + /** + * Implement the timeout for network requests. This will be run for + * all network requests, but the error will only be returned if it + * completes first. + * + * @param {int} timeout + * The time in ms to wait for the request to complete. + * @param {Object} controller + * The AbortController passed to the fetch request that + * allows us to abort the request. + */ + async _timeout(timeout, controller) { + await new Promise(resolve => lazy.setTimeout(resolve, timeout)); + if (controller) { + // Yield so it is the TIMEOUT that is returned and not + // the result of the abort(). + lazy.setTimeout(() => controller.abort(), 0); + } + throw new Error("TIMEOUT"); + } + + async _fetchWifiData() { + log.info("fetchWifiData called"); + this.wifiService = Cc["@mozilla.org/wifi/monitor;1"].getService( + Ci.nsIWifiMonitor + ); + this.wifiService.startWatching(this, false); + + return new Promise(resolve => { + this._wifiDataPromise = resolve; + }); + } + + /** + * If the user is using geolocation then we will see frequent updates + * debounce those so we aren't processing them constantly. + * + * @returns {bool} + * Whether we should continue the update check. + */ + _needsUpdateCheck() { + let sinceUpdate = Math.round(Date.now() / 1000) - lazy.lastUpdated; + let needsUpdate = sinceUpdate >= lazy.updateDebounce; + if (!needsUpdate) { + log.info(`Ignoring update check, last seen ${sinceUpdate} seconds ago`); + } + return needsUpdate; + } + + /** + * Dispatch a promise returning function to the main thread and + * resolve when it is completed. + */ + _idleDispatch(fun) { + return new Promise(resolve => { + Services.tm.idleDispatchToMainThread(fun().then(resolve)); + }); + } + + /** + * timerManager will call this periodically to update the region + * in case the user never users geolocation. + */ + async _updateTimer() { + if (this._needsUpdateCheck()) { + await this._fetchRegion(); + } + } + + /** + * Called when we see geolocation updates. + * in case the user never users geolocation. + * + * @param {Object} location + * A location object containing lat + lng coordinates. + * + */ + async _seenLocation(location) { + log.info(`Got location update: ${location.lat}:${location.lng}`); + if (this._needsUpdateCheck()) { + let region = await this._geoCode(location); + if (region) { + this._setCurrentRegion(region); + } + } + } + + onChange(accessPoints) { + log.info("onChange called"); + if (!accessPoints || !this._wifiDataPromise) { + return; + } + + if (this.wifiService) { + this.wifiService.stopWatching(this); + this.wifiService = null; + } + + if (this._wifiDataPromise) { + let data = lazy.LocationHelper.formatWifiAccessPoints(accessPoints); + this._wifiDataPromise(data); + this._wifiDataPromise = null; + } + } + + observe(aSubject, aTopic, aData) { + log.info(`Observed ${aTopic}`); + switch (aTopic) { + case GEOLOCATION_TOPIC: + // aSubject from GeoLocation.cpp will be a GeoPosition + // DOM Object, but from tests we will receive a + // wrappedJSObject so handle both here. + let coords = aSubject.coords || aSubject.wrappedJSObject.coords; + this._seenLocation({ + lat: coords.latitude, + lng: coords.longitude, + }); + break; + } + } + + // For tests to create blank new instances. + newInstance() { + return new RegionDetector(); + } +} + +export let Region = new RegionDetector(); +Region.init(); + +// A method that tries to determine if this user is in a US geography. +function isUSTimezone() { + // Timezone assumptions! We assume that if the system clock's timezone is + // between Newfoundland and Hawaii, that the user is in North America. + + // This includes all of South America as well, but we have relatively few + // en-US users there, so that's OK. + + // 150 minutes = 2.5 hours (UTC-2.5), which is + // Newfoundland Daylight Time (http://www.timeanddate.com/time/zones/ndt) + + // 600 minutes = 10 hours (UTC-10), which is + // Hawaii-Aleutian Standard Time (http://www.timeanddate.com/time/zones/hast) + + let UTCOffset = new Date().getTimezoneOffset(); + return UTCOffset >= 150 && UTCOffset <= 600; +} |