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