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