Bug 1892041 - Part 1: Update test262 features. r=spidermonkey-reviewers,dminor
[gecko.git] / toolkit / modules / Region.sys.mjs
blob559273461fd3d97baf38757d5bfad352d48592e7
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
7 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
9 import { RemoteSettings } from "resource://services-settings/remote-settings.sys.mjs";
11 const lazy = {};
13 ChromeUtils.defineESModuleGetters(lazy, {
14   LocationHelper: "resource://gre/modules/LocationHelper.sys.mjs",
15   setTimeout: "resource://gre/modules/Timer.sys.mjs",
16 });
18 XPCOMUtils.defineLazyPreferenceGetter(
19   lazy,
20   "wifiScanningEnabled",
21   "browser.region.network.scan",
22   true
25 XPCOMUtils.defineLazyPreferenceGetter(
26   lazy,
27   "networkTimeout",
28   "browser.region.timeout",
29   5000
32 // Retry the region lookup every hour on failure, a failure
33 // is likely to be a service failure so this gives the
34 // service some time to restore. Setting to 0 disabled retries.
35 XPCOMUtils.defineLazyPreferenceGetter(
36   lazy,
37   "retryTimeout",
38   "browser.region.retry-timeout",
39   60 * 60 * 1000
42 XPCOMUtils.defineLazyPreferenceGetter(
43   lazy,
44   "loggingEnabled",
45   "browser.region.log",
46   false
49 XPCOMUtils.defineLazyPreferenceGetter(
50   lazy,
51   "cacheBustEnabled",
52   "browser.region.update.enabled",
53   false
56 XPCOMUtils.defineLazyPreferenceGetter(
57   lazy,
58   "updateDebounce",
59   "browser.region.update.debounce",
60   60 * 60 * 24
63 XPCOMUtils.defineLazyPreferenceGetter(
64   lazy,
65   "lastUpdated",
66   "browser.region.update.updated",
67   0
70 XPCOMUtils.defineLazyPreferenceGetter(
71   lazy,
72   "localGeocodingEnabled",
73   "browser.region.local-geocoding",
74   false
77 XPCOMUtils.defineLazyServiceGetter(
78   lazy,
79   "timerManager",
80   "@mozilla.org/updates/timer-manager;1",
81   "nsIUpdateTimerManager"
84 const log = console.createInstance({
85   prefix: "Region.sys.mjs",
86   maxLogLevel: lazy.loggingEnabled ? "All" : "Warn",
87 });
89 const REGION_PREF = "browser.search.region";
90 const COLLECTION_ID = "regions";
91 const GEOLOCATION_TOPIC = "geolocation-position-events";
93 // Prefix for all the region updating related preferences.
94 const UPDATE_PREFIX = "browser.region.update";
96 // The amount of time (in seconds) we need to be in a new
97 // location before we update the home region.
98 // Currently set to 2 weeks.
99 const UPDATE_INTERVAL = 60 * 60 * 24 * 14;
101 const MAX_RETRIES = 3;
103 // If the user never uses geolocation, schedule a periodic
104 // update to check the current location (in seconds).
105 const UPDATE_CHECK_NAME = "region-update-timer";
106 const UPDATE_CHECK_INTERVAL = 60 * 60 * 24 * 7;
108 // Let child processes read the current home value
109 // but dont trigger redundant updates in them.
110 let inChildProcess =
111   Services.appinfo.processType != Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT;
114  * This module keeps track of the users current region (country).
115  * so the SearchService and other consumers can apply region
116  * specific customisations.
117  */
118 class RegionDetector {
119   // The users home location.
120   _home = null;
121   // The most recent location the user was detected.
122   _current = null;
123   // The RemoteSettings client used to sync region files.
124   _rsClient = null;
125   // Keep track of the wifi data across listener events.
126   _wifiDataPromise = null;
127   // Keep track of how many times we have tried to fetch
128   // the users region during failure.
129   _retryCount = 0;
130   // Let tests wait for init to complete.
131   _initPromise = null;
132   // Topic for Observer events fired by Region.sys.mjs.
133   REGION_TOPIC = "browser-region-updated";
134   // Values for telemetry.
135   TELEMETRY = {
136     SUCCESS: 0,
137     NO_RESULT: 1,
138     TIMEOUT: 2,
139     ERROR: 3,
140   };
142   /**
143    * Read currently stored region data and if needed trigger background
144    * region detection.
145    */
146   async init() {
147     if (this._initPromise) {
148       return this._initPromise;
149     }
150     if (lazy.cacheBustEnabled && !inChildProcess) {
151       Services.tm.idleDispatchToMainThread(() => {
152         lazy.timerManager.registerTimer(
153           UPDATE_CHECK_NAME,
154           () => this._updateTimer(),
155           UPDATE_CHECK_INTERVAL
156         );
157       });
158     }
159     let promises = [];
160     this._home = Services.prefs.getCharPref(REGION_PREF, null);
161     if (!this._home && !inChildProcess) {
162       promises.push(this._idleDispatch(() => this._fetchRegion()));
163     }
164     if (lazy.localGeocodingEnabled && !inChildProcess) {
165       promises.push(this._idleDispatch(() => this._setupRemoteSettings()));
166     }
167     return (this._initPromise = Promise.all(promises));
168   }
170   /**
171    * Get the region we currently consider the users home.
172    *
173    * @returns {string}
174    *   The users current home region.
175    */
176   get home() {
177     return this._home;
178   }
180   /**
181    * Get the last region we detected the user to be in.
182    *
183    * @returns {string}
184    *   The users current region.
185    */
186   get current() {
187     return this._current;
188   }
190   /**
191    * Fetch the users current region.
192    *
193    * @returns {string}
194    *   The country_code defining users current region.
195    */
196   async _fetchRegion() {
197     if (this._retryCount >= MAX_RETRIES) {
198       return null;
199     }
200     let startTime = Date.now();
201     let telemetryResult = this.TELEMETRY.SUCCESS;
202     let result = null;
204     try {
205       result = await this._getRegion();
206     } catch (err) {
207       telemetryResult = this.TELEMETRY[err.message] || this.TELEMETRY.ERROR;
208       log.error("Failed to fetch region", err);
209       if (lazy.retryTimeout) {
210         this._retryCount++;
211         lazy.setTimeout(() => {
212           Services.tm.idleDispatchToMainThread(this._fetchRegion.bind(this));
213         }, lazy.retryTimeout);
214       }
215     }
217     let took = Date.now() - startTime;
218     if (result) {
219       await this._storeRegion(result);
220     }
221     Services.telemetry
222       .getHistogramById("SEARCH_SERVICE_COUNTRY_FETCH_TIME_MS")
223       .add(took);
225     Services.telemetry
226       .getHistogramById("SEARCH_SERVICE_COUNTRY_FETCH_RESULT")
227       .add(telemetryResult);
229     return result;
230   }
232   /**
233    * Validate then store the region and report telemetry.
234    *
235    * @param region
236    *   The region to store.
237    */
238   async _storeRegion(region) {
239     let prefix = "SEARCH_SERVICE";
240     let isTimezoneUS = isUSTimezone();
241     // If it's a US region, but not a US timezone, we don't store
242     // the value. This works because no region defaults to
243     // ZZ (unknown) in nsURLFormatter
244     if (region != "US" || isTimezoneUS) {
245       this._setCurrentRegion(region, true);
246     }
248     // and telemetry...
249     if (region == "US" && !isTimezoneUS) {
250       log.info("storeRegion mismatch - US Region, non-US timezone");
251       Services.telemetry
252         .getHistogramById(`${prefix}_US_COUNTRY_MISMATCHED_TIMEZONE`)
253         .add(1);
254     }
255     if (region != "US" && isTimezoneUS) {
256       log.info("storeRegion mismatch - non-US Region, US timezone");
257       Services.telemetry
258         .getHistogramById(`${prefix}_US_TIMEZONE_MISMATCHED_COUNTRY`)
259         .add(1);
260     }
261     // telemetry to compare our geoip response with
262     // platform-specific country data.
263     // On Mac and Windows, we can get a country code via sysinfo
264     let platformCC = await Services.sysinfo.countryCode;
265     if (platformCC) {
266       let probeUSMismatched, probeNonUSMismatched;
267       switch (AppConstants.platform) {
268         case "macosx":
269           probeUSMismatched = `${prefix}_US_COUNTRY_MISMATCHED_PLATFORM_OSX`;
270           probeNonUSMismatched = `${prefix}_NONUS_COUNTRY_MISMATCHED_PLATFORM_OSX`;
271           break;
272         case "win":
273           probeUSMismatched = `${prefix}_US_COUNTRY_MISMATCHED_PLATFORM_WIN`;
274           probeNonUSMismatched = `${prefix}_NONUS_COUNTRY_MISMATCHED_PLATFORM_WIN`;
275           break;
276         default:
277           log.error(
278             "Platform " +
279               Services.appinfo.OS +
280               " has system country code but no search service telemetry probes"
281           );
282           break;
283       }
284       if (probeUSMismatched && probeNonUSMismatched) {
285         if (region == "US" || platformCC == "US") {
286           // one of the 2 said US, so record if they are the same.
287           Services.telemetry
288             .getHistogramById(probeUSMismatched)
289             .add(region != platformCC);
290         } else {
291           // non-US - record if they are the same
292           Services.telemetry
293             .getHistogramById(probeNonUSMismatched)
294             .add(region != platformCC);
295         }
296       }
297     }
298   }
300   /**
301    * Save the update current region and check if the home region
302    * also needs an update.
303    *
304    * @param {string} region
305    *   The region to store.
306    */
307   _setCurrentRegion(region = "") {
308     log.info("Setting current region:", region);
309     this._current = region;
311     let now = Math.round(Date.now() / 1000);
312     let prefs = Services.prefs;
313     prefs.setIntPref(`${UPDATE_PREFIX}.updated`, now);
315     // Interval is in seconds.
316     let interval = prefs.getIntPref(
317       `${UPDATE_PREFIX}.interval`,
318       UPDATE_INTERVAL
319     );
320     let seenRegion = prefs.getCharPref(`${UPDATE_PREFIX}.region`, null);
321     let firstSeen = prefs.getIntPref(`${UPDATE_PREFIX}.first-seen`, 0);
323     // If we don't have a value for .home we can set it immediately.
324     if (!this._home) {
325       this._setHomeRegion(region);
326     } else if (region != this._home && region != seenRegion) {
327       // If we are in a different region than what is currently
328       // considered home, then keep track of when we first
329       // seen the new location.
330       prefs.setCharPref(`${UPDATE_PREFIX}.region`, region);
331       prefs.setIntPref(`${UPDATE_PREFIX}.first-seen`, now);
332     } else if (region != this._home && region == seenRegion) {
333       // If we have been in the new region for longer than
334       // a specified time period, then set that as the new home.
335       if (now >= firstSeen + interval) {
336         this._setHomeRegion(region);
337       }
338     } else {
339       // If we are at home again, stop tracking the seen region.
340       prefs.clearUserPref(`${UPDATE_PREFIX}.region`);
341       prefs.clearUserPref(`${UPDATE_PREFIX}.first-seen`);
342     }
343   }
345   // Wrap a string as a nsISupports.
346   _createSupportsString(data) {
347     let string = Cc["@mozilla.org/supports-string;1"].createInstance(
348       Ci.nsISupportsString
349     );
350     string.data = data;
351     return string;
352   }
354   /**
355    * Save the updated home region and notify observers.
356    *
357    * @param {string} region
358    *   The region to store.
359    * @param {boolean} [notify]
360    *   Tests can disable the notification for convenience as it
361    *   may trigger an engines reload.
362    */
363   _setHomeRegion(region, notify = true) {
364     if (region == this._home) {
365       return;
366     }
367     log.info("Updating home region:", region);
368     this._home = region;
369     Services.prefs.setCharPref("browser.search.region", region);
370     if (notify) {
371       Services.obs.notifyObservers(
372         this._createSupportsString(region),
373         this.REGION_TOPIC
374       );
375     }
376   }
378   /**
379    * Make the request to fetch the region from the configured service.
380    */
381   async _getRegion() {
382     log.info("_getRegion called");
383     let fetchOpts = {
384       headers: { "Content-Type": "application/json" },
385       credentials: "omit",
386     };
387     if (lazy.wifiScanningEnabled) {
388       let wifiData = await this._fetchWifiData();
389       if (wifiData) {
390         let postData = JSON.stringify({ wifiAccessPoints: wifiData });
391         log.info("Sending wifi details: ", wifiData);
392         fetchOpts.method = "POST";
393         fetchOpts.body = postData;
394       }
395     }
396     let url = Services.urlFormatter.formatURLPref("browser.region.network.url");
397     log.info("_getRegion url is: ", url);
399     if (!url) {
400       return null;
401     }
403     try {
404       let req = await this._fetchTimeout(url, fetchOpts, lazy.networkTimeout);
405       let res = await req.json();
406       log.info("_getRegion returning ", res.country_code);
407       return res.country_code;
408     } catch (err) {
409       log.error("Error fetching region", err);
410       let errCode = err.message in this.TELEMETRY ? err.message : "NO_RESULT";
411       throw new Error(errCode);
412     }
413   }
415   /**
416    * Setup the RemoteSetting client + sync listener and ensure
417    * the map files are downloaded.
418    */
419   async _setupRemoteSettings() {
420     log.info("_setupRemoteSettings");
421     this._rsClient = RemoteSettings(COLLECTION_ID);
422     this._rsClient.on("sync", this._onRegionFilesSync.bind(this));
423     await this._ensureRegionFilesDownloaded();
424     // Start listening to geolocation events only after
425     // we know the maps are downloded.
426     Services.obs.addObserver(this, GEOLOCATION_TOPIC);
427   }
429   /**
430    * Called when RemoteSettings syncs new data, clean up any
431    * stale attachments and download any new ones.
432    *
433    * @param {Object} syncData
434    *   Object describing the data that has just been synced.
435    */
436   async _onRegionFilesSync({ data: { deleted } }) {
437     log.info("_onRegionFilesSync");
438     const toDelete = deleted.filter(d => d.attachment);
439     // Remove local files of deleted records
440     await Promise.all(
441       toDelete.map(entry => this._rsClient.attachments.deleteDownloaded(entry))
442     );
443     await this._ensureRegionFilesDownloaded();
444   }
446   /**
447    * Download the RemoteSetting record attachments, when they are
448    * successfully downloaded set a flag so we can start using them
449    * for geocoding.
450    */
451   async _ensureRegionFilesDownloaded() {
452     log.info("_ensureRegionFilesDownloaded");
453     let records = (await this._rsClient.get()).filter(d => d.attachment);
454     log.info("_ensureRegionFilesDownloaded", records);
455     if (!records.length) {
456       log.info("_ensureRegionFilesDownloaded: Nothing to download");
457       return;
458     }
459     await Promise.all(records.map(r => this._rsClient.attachments.download(r)));
460     log.info("_ensureRegionFilesDownloaded complete");
461     this._regionFilesReady = true;
462   }
464   /**
465    * Fetch an attachment from RemoteSettings.
466    *
467    * @param {String} id
468    *   The id of the record to fetch the attachment from.
469    */
470   async _fetchAttachment(id) {
471     let record = (await this._rsClient.get({ filters: { id } })).pop();
472     let { buffer } = await this._rsClient.attachments.download(record);
473     let text = new TextDecoder("utf-8").decode(buffer);
474     return JSON.parse(text);
475   }
477   /**
478    * Get a map of the world with region definitions.
479    */
480   async _getPlainMap() {
481     return this._fetchAttachment("world");
482   }
484   /**
485    * Get a map with the regions expanded by a few km to help
486    * fallback lookups when a location is not within a region.
487    */
488   async _getBufferedMap() {
489     return this._fetchAttachment("world-buffered");
490   }
492   /**
493    * Gets the users current location using the same reverse IP
494    * request that is used for GeoLocation requests.
495    *
496    * @returns {Object} location
497    *   Object representing the user location, with a location key
498    *   that contains the lat / lng coordinates.
499    */
500   async _getLocation() {
501     log.info("_getLocation called");
502     let fetchOpts = { headers: { "Content-Type": "application/json" } };
503     let url = Services.urlFormatter.formatURLPref("geo.provider.network.url");
504     let req = await this._fetchTimeout(url, fetchOpts, lazy.networkTimeout);
505     let result = await req.json();
506     log.info("_getLocation returning", result);
507     return result;
508   }
510   /**
511    * Return the users current region using
512    * request that is used for GeoLocation requests.
513    *
514    * @returns {String}
515    *   A 2 character string representing a region.
516    */
517   async _getRegionLocally() {
518     let { location } = await this._getLocation();
519     return this._geoCode(location);
520   }
522   /**
523    * Take a location and return the region code for that location
524    * by looking up the coordinates in geojson map files.
525    * Inspired by https://github.com/mozilla/ichnaea/blob/874e8284f0dfa1868e79aae64e14707eed660efe/ichnaea/geocode.py#L114
526    *
527    * @param {Object} location
528    *   A location object containing lat + lng coordinates.
529    *
530    * @returns {String}
531    *   A 2 character string representing a region.
532    */
533   async _geoCode(location) {
534     let plainMap = await this._getPlainMap();
535     let polygons = this._getPolygonsContainingPoint(location, plainMap);
536     if (polygons.length == 1) {
537       log.info("Found in single exact region");
538       return polygons[0].properties.alpha2;
539     }
540     if (polygons.length) {
541       log.info("Found in ", polygons.length, "overlapping exact regions");
542       return this._findFurthest(location, polygons);
543     }
545     // We haven't found a match in the exact map, use the buffered map
546     // to see if the point is close to a region.
547     let bufferedMap = await this._getBufferedMap();
548     polygons = this._getPolygonsContainingPoint(location, bufferedMap);
550     if (polygons.length === 1) {
551       log.info("Found in single buffered region");
552       return polygons[0].properties.alpha2;
553     }
555     // Matched more than one region, which one of those regions
556     // is it closest to without the buffer.
557     if (polygons.length) {
558       log.info("Found in ", polygons.length, "overlapping buffered regions");
559       let regions = polygons.map(polygon => polygon.properties.alpha2);
560       let unBufferedRegions = plainMap.features.filter(feature =>
561         regions.includes(feature.properties.alpha2)
562       );
563       return this._findClosest(location, unBufferedRegions);
564     }
565     return null;
566   }
568   /**
569    * Find all the polygons that contain a single point, return
570    * an array of those polygons along with the region that
571    * they define
572    *
573    * @param {Object} point
574    *   A lat + lng coordinate.
575    * @param {Object} map
576    *   Geojson object that defined seperate regions with a list
577    *   of polygons.
578    *
579    * @returns {Array}
580    *   An array of polygons that contain the point, along with the
581    *   region they define.
582    */
583   _getPolygonsContainingPoint(point, map) {
584     let polygons = [];
585     for (const feature of map.features) {
586       let coords = feature.geometry.coordinates;
587       if (feature.geometry.type === "Polygon") {
588         if (this._polygonInPoint(point, coords[0])) {
589           polygons.push(feature);
590         }
591       } else if (feature.geometry.type === "MultiPolygon") {
592         for (const innerCoords of coords) {
593           if (this._polygonInPoint(point, innerCoords[0])) {
594             polygons.push(feature);
595           }
596         }
597       }
598     }
599     return polygons;
600   }
602   /**
603    * Find the largest distance between a point and any of the points that
604    * make up an array of regions.
605    *
606    * @param {Object} location
607    *   A lat + lng coordinate.
608    * @param {Array} regions
609    *   An array of GeoJSON region definitions.
610    *
611    * @returns {String}
612    *   A 2 character string representing a region.
613    */
614   _findFurthest(location, regions) {
615     let max = { distance: 0, region: null };
616     this._traverse(regions, ({ lat, lng, region }) => {
617       let distance = this._distanceBetween(location, { lng, lat });
618       if (distance > max.distance) {
619         max = { distance, region };
620       }
621     });
622     return max.region;
623   }
625   /**
626    * Find the smallest distance between a point and any of the points that
627    * make up an array of regions.
628    *
629    * @param {Object} location
630    *   A lat + lng coordinate.
631    * @param {Array} regions
632    *   An array of GeoJSON region definitions.
633    *
634    * @returns {String}
635    *   A 2 character string representing a region.
636    */
637   _findClosest(location, regions) {
638     let min = { distance: Infinity, region: null };
639     this._traverse(regions, ({ lat, lng, region }) => {
640       let distance = this._distanceBetween(location, { lng, lat });
641       if (distance < min.distance) {
642         min = { distance, region };
643       }
644     });
645     return min.region;
646   }
648   /**
649    * Utility function to loop over all the coordinate points in an
650    * array of polygons and call a function on them.
651    *
652    * @param {Array} regions
653    *   An array of GeoJSON region definitions.
654    * @param {Function} fun
655    *   Function to call on individual coordinates.
656    */
657   _traverse(regions, fun) {
658     for (const region of regions) {
659       if (region.geometry.type === "Polygon") {
660         for (const [lng, lat] of region.geometry.coordinates[0]) {
661           fun({ lat, lng, region: region.properties.alpha2 });
662         }
663       } else if (region.geometry.type === "MultiPolygon") {
664         for (const innerCoords of region.geometry.coordinates) {
665           for (const [lng, lat] of innerCoords[0]) {
666             fun({ lat, lng, region: region.properties.alpha2 });
667           }
668         }
669       }
670     }
671   }
673   /**
674    * Check whether a point is contained within a polygon using the
675    * point in polygon algorithm:
676    * https://en.wikipedia.org/wiki/Point_in_polygon
677    * This casts a ray from the point and counts how many times
678    * that ray intersects with the polygons borders, if it is
679    * an odd number of times the point is inside the polygon.
680    *
681    * @param {Object} location
682    *   A lat + lng coordinate.
683    * @param {Object} polygon
684    *   Array of coordinates that define the boundaries of a polygon.
685    *
686    * @returns {boolean}
687    *   Whether the point is within the polygon.
688    */
689   _polygonInPoint({ lng, lat }, poly) {
690     let inside = false;
691     // For each edge of the polygon.
692     for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
693       let xi = poly[i][0];
694       let yi = poly[i][1];
695       let xj = poly[j][0];
696       let yj = poly[j][1];
697       // Does a ray cast from the point intersect with this polygon edge.
698       let intersect =
699         yi > lat != yj > lat && lng < ((xj - xi) * (lat - yi)) / (yj - yi) + xi;
700       // If so toggle result, an odd number of intersections
701       // means the point is inside.
702       if (intersect) {
703         inside = !inside;
704       }
705     }
706     return inside;
707   }
709   /**
710    * Find the distance between 2 points.
711    *
712    * @param {Object} p1
713    *   A lat + lng coordinate.
714    * @param {Object} p2
715    *   A lat + lng coordinate.
716    *
717    * @returns {int}
718    *   The distance between the 2 points.
719    */
720   _distanceBetween(p1, p2) {
721     return Math.hypot(p2.lng - p1.lng, p2.lat - p1.lat);
722   }
724   /**
725    * A wrapper around fetch that implements a timeout, will throw
726    * a TIMEOUT error if the request is not completed in time.
727    *
728    * @param {String} url
729    *   The time url to fetch.
730    * @param {Object} opts
731    *   The options object passed to the call to fetch.
732    * @param {int} timeout
733    *   The time in ms to wait for the request to complete.
734    */
735   async _fetchTimeout(url, opts, timeout) {
736     let controller = new AbortController();
737     opts.signal = controller.signal;
738     return Promise.race([fetch(url, opts), this._timeout(timeout, controller)]);
739   }
741   /**
742    * Implement the timeout for network requests. This will be run for
743    * all network requests, but the error will only be returned if it
744    * completes first.
745    *
746    * @param {int} timeout
747    *   The time in ms to wait for the request to complete.
748    * @param {Object} controller
749    *   The AbortController passed to the fetch request that
750    *   allows us to abort the request.
751    */
752   async _timeout(timeout, controller) {
753     await new Promise(resolve => lazy.setTimeout(resolve, timeout));
754     if (controller) {
755       // Yield so it is the TIMEOUT that is returned and not
756       // the result of the abort().
757       lazy.setTimeout(() => controller.abort(), 0);
758     }
759     throw new Error("TIMEOUT");
760   }
762   async _fetchWifiData() {
763     log.info("fetchWifiData called");
764     this.wifiService = Cc["@mozilla.org/wifi/monitor;1"].getService(
765       Ci.nsIWifiMonitor
766     );
767     this.wifiService.startWatching(this, false);
769     return new Promise(resolve => {
770       this._wifiDataPromise = resolve;
771     });
772   }
774   /**
775    * If the user is using geolocation then we will see frequent updates
776    * debounce those so we aren't processing them constantly.
777    *
778    * @returns {bool}
779    *   Whether we should continue the update check.
780    */
781   _needsUpdateCheck() {
782     let sinceUpdate = Math.round(Date.now() / 1000) - lazy.lastUpdated;
783     let needsUpdate = sinceUpdate >= lazy.updateDebounce;
784     if (!needsUpdate) {
785       log.info(`Ignoring update check, last seen ${sinceUpdate} seconds ago`);
786     }
787     return needsUpdate;
788   }
790   /**
791    * Dispatch a promise returning function to the main thread and
792    * resolve when it is completed.
793    */
794   _idleDispatch(fun) {
795     return new Promise(resolve => {
796       Services.tm.idleDispatchToMainThread(fun().then(resolve));
797     });
798   }
800   /**
801    * timerManager will call this periodically to update the region
802    * in case the user never users geolocation.
803    */
804   async _updateTimer() {
805     if (this._needsUpdateCheck()) {
806       await this._fetchRegion();
807     }
808   }
810   /**
811    * Called when we see geolocation updates.
812    * in case the user never users geolocation.
813    *
814    * @param {Object} location
815    *   A location object containing lat + lng coordinates.
816    *
817    */
818   async _seenLocation(location) {
819     log.info(`Got location update: ${location.lat}:${location.lng}`);
820     if (this._needsUpdateCheck()) {
821       let region = await this._geoCode(location);
822       if (region) {
823         this._setCurrentRegion(region);
824       }
825     }
826   }
828   onChange(accessPoints) {
829     log.info("onChange called");
830     if (!accessPoints || !this._wifiDataPromise) {
831       return;
832     }
834     if (this.wifiService) {
835       this.wifiService.stopWatching(this);
836       this.wifiService = null;
837     }
839     if (this._wifiDataPromise) {
840       let data = lazy.LocationHelper.formatWifiAccessPoints(accessPoints);
841       this._wifiDataPromise(data);
842       this._wifiDataPromise = null;
843     }
844   }
846   observe(aSubject, aTopic) {
847     log.info(`Observed ${aTopic}`);
848     switch (aTopic) {
849       case GEOLOCATION_TOPIC:
850         // aSubject from GeoLocation.cpp will be a GeoPosition
851         // DOM Object, but from tests we will receive a
852         // wrappedJSObject so handle both here.
853         let coords = aSubject.coords || aSubject.wrappedJSObject.coords;
854         this._seenLocation({
855           lat: coords.latitude,
856           lng: coords.longitude,
857         });
858         break;
859     }
860   }
862   // For tests to create blank new instances.
863   newInstance() {
864     return new RegionDetector();
865   }
868 export let Region = new RegionDetector();
869 Region.init();
871 // A method that tries to determine if this user is in a US geography.
872 function isUSTimezone() {
873   // Timezone assumptions! We assume that if the system clock's timezone is
874   // between Newfoundland and Hawaii, that the user is in North America.
876   // This includes all of South America as well, but we have relatively few
877   // en-US users there, so that's OK.
879   // 150 minutes = 2.5 hours (UTC-2.5), which is
880   // Newfoundland Daylight Time (http://www.timeanddate.com/time/zones/ndt)
882   // 600 minutes = 10 hours (UTC-10), which is
883   // Hawaii-Aleutian Standard Time (http://www.timeanddate.com/time/zones/hast)
885   let UTCOffset = new Date().getTimezoneOffset();
886   return UTCOffset >= 150 && UTCOffset <= 600;