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";
13 ChromeUtils.defineESModuleGetters(lazy, {
14 LocationHelper: "resource://gre/modules/LocationHelper.sys.mjs",
15 setTimeout: "resource://gre/modules/Timer.sys.mjs",
18 XPCOMUtils.defineLazyPreferenceGetter(
20 "wifiScanningEnabled",
21 "browser.region.network.scan",
25 XPCOMUtils.defineLazyPreferenceGetter(
28 "browser.region.timeout",
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(
38 "browser.region.retry-timeout",
42 XPCOMUtils.defineLazyPreferenceGetter(
49 XPCOMUtils.defineLazyPreferenceGetter(
52 "browser.region.update.enabled",
56 XPCOMUtils.defineLazyPreferenceGetter(
59 "browser.region.update.debounce",
63 XPCOMUtils.defineLazyPreferenceGetter(
66 "browser.region.update.updated",
70 XPCOMUtils.defineLazyPreferenceGetter(
72 "localGeocodingEnabled",
73 "browser.region.local-geocoding",
77 XPCOMUtils.defineLazyServiceGetter(
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",
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.
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.
118 class RegionDetector {
119 // The users home location.
121 // The most recent location the user was detected.
123 // The RemoteSettings client used to sync region files.
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.
130 // Let tests wait for init to complete.
132 // Topic for Observer events fired by Region.sys.mjs.
133 REGION_TOPIC = "browser-region-updated";
134 // Values for telemetry.
143 * Read currently stored region data and if needed trigger background
147 if (this._initPromise) {
148 return this._initPromise;
150 if (lazy.cacheBustEnabled && !inChildProcess) {
151 Services.tm.idleDispatchToMainThread(() => {
152 lazy.timerManager.registerTimer(
154 () => this._updateTimer(),
155 UPDATE_CHECK_INTERVAL
160 this._home = Services.prefs.getCharPref(REGION_PREF, null);
161 if (!this._home && !inChildProcess) {
162 promises.push(this._idleDispatch(() => this._fetchRegion()));
164 if (lazy.localGeocodingEnabled && !inChildProcess) {
165 promises.push(this._idleDispatch(() => this._setupRemoteSettings()));
167 return (this._initPromise = Promise.all(promises));
171 * Get the region we currently consider the users home.
174 * The users current home region.
181 * Get the last region we detected the user to be in.
184 * The users current region.
187 return this._current;
191 * Fetch the users current region.
194 * The country_code defining users current region.
196 async _fetchRegion() {
197 if (this._retryCount >= MAX_RETRIES) {
200 let startTime = Date.now();
201 let telemetryResult = this.TELEMETRY.SUCCESS;
205 result = await this._getRegion();
207 telemetryResult = this.TELEMETRY[err.message] || this.TELEMETRY.ERROR;
208 log.error("Failed to fetch region", err);
209 if (lazy.retryTimeout) {
211 lazy.setTimeout(() => {
212 Services.tm.idleDispatchToMainThread(this._fetchRegion.bind(this));
213 }, lazy.retryTimeout);
217 let took = Date.now() - startTime;
219 await this._storeRegion(result);
222 .getHistogramById("SEARCH_SERVICE_COUNTRY_FETCH_TIME_MS")
226 .getHistogramById("SEARCH_SERVICE_COUNTRY_FETCH_RESULT")
227 .add(telemetryResult);
233 * Validate then store the region and report telemetry.
236 * The region to store.
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);
249 if (region == "US" && !isTimezoneUS) {
250 log.info("storeRegion mismatch - US Region, non-US timezone");
252 .getHistogramById(`${prefix}_US_COUNTRY_MISMATCHED_TIMEZONE`)
255 if (region != "US" && isTimezoneUS) {
256 log.info("storeRegion mismatch - non-US Region, US timezone");
258 .getHistogramById(`${prefix}_US_TIMEZONE_MISMATCHED_COUNTRY`)
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;
266 let probeUSMismatched, probeNonUSMismatched;
267 switch (AppConstants.platform) {
269 probeUSMismatched = `${prefix}_US_COUNTRY_MISMATCHED_PLATFORM_OSX`;
270 probeNonUSMismatched = `${prefix}_NONUS_COUNTRY_MISMATCHED_PLATFORM_OSX`;
273 probeUSMismatched = `${prefix}_US_COUNTRY_MISMATCHED_PLATFORM_WIN`;
274 probeNonUSMismatched = `${prefix}_NONUS_COUNTRY_MISMATCHED_PLATFORM_WIN`;
279 Services.appinfo.OS +
280 " has system country code but no search service telemetry probes"
284 if (probeUSMismatched && probeNonUSMismatched) {
285 if (region == "US" || platformCC == "US") {
286 // one of the 2 said US, so record if they are the same.
288 .getHistogramById(probeUSMismatched)
289 .add(region != platformCC);
291 // non-US - record if they are the same
293 .getHistogramById(probeNonUSMismatched)
294 .add(region != platformCC);
301 * Save the update current region and check if the home region
302 * also needs an update.
304 * @param {string} region
305 * The region to store.
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`,
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.
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);
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`);
345 // Wrap a string as a nsISupports.
346 _createSupportsString(data) {
347 let string = Cc["@mozilla.org/supports-string;1"].createInstance(
355 * Save the updated home region and notify observers.
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.
363 _setHomeRegion(region, notify = true) {
364 if (region == this._home) {
367 log.info("Updating home region:", region);
369 Services.prefs.setCharPref("browser.search.region", region);
371 Services.obs.notifyObservers(
372 this._createSupportsString(region),
379 * Make the request to fetch the region from the configured service.
382 log.info("_getRegion called");
384 headers: { "Content-Type": "application/json" },
387 if (lazy.wifiScanningEnabled) {
388 let wifiData = await this._fetchWifiData();
390 let postData = JSON.stringify({ wifiAccessPoints: wifiData });
391 log.info("Sending wifi details: ", wifiData);
392 fetchOpts.method = "POST";
393 fetchOpts.body = postData;
396 let url = Services.urlFormatter.formatURLPref("browser.region.network.url");
397 log.info("_getRegion url is: ", url);
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;
409 log.error("Error fetching region", err);
410 let errCode = err.message in this.TELEMETRY ? err.message : "NO_RESULT";
411 throw new Error(errCode);
416 * Setup the RemoteSetting client + sync listener and ensure
417 * the map files are downloaded.
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);
430 * Called when RemoteSettings syncs new data, clean up any
431 * stale attachments and download any new ones.
433 * @param {Object} syncData
434 * Object describing the data that has just been synced.
436 async _onRegionFilesSync({ data: { deleted } }) {
437 log.info("_onRegionFilesSync");
438 const toDelete = deleted.filter(d => d.attachment);
439 // Remove local files of deleted records
441 toDelete.map(entry => this._rsClient.attachments.deleteDownloaded(entry))
443 await this._ensureRegionFilesDownloaded();
447 * Download the RemoteSetting record attachments, when they are
448 * successfully downloaded set a flag so we can start using them
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");
459 await Promise.all(records.map(r => this._rsClient.attachments.download(r)));
460 log.info("_ensureRegionFilesDownloaded complete");
461 this._regionFilesReady = true;
465 * Fetch an attachment from RemoteSettings.
468 * The id of the record to fetch the attachment from.
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);
478 * Get a map of the world with region definitions.
480 async _getPlainMap() {
481 return this._fetchAttachment("world");
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.
488 async _getBufferedMap() {
489 return this._fetchAttachment("world-buffered");
493 * Gets the users current location using the same reverse IP
494 * request that is used for GeoLocation requests.
496 * @returns {Object} location
497 * Object representing the user location, with a location key
498 * that contains the lat / lng coordinates.
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);
511 * Return the users current region using
512 * request that is used for GeoLocation requests.
515 * A 2 character string representing a region.
517 async _getRegionLocally() {
518 let { location } = await this._getLocation();
519 return this._geoCode(location);
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
527 * @param {Object} location
528 * A location object containing lat + lng coordinates.
531 * A 2 character string representing a region.
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;
540 if (polygons.length) {
541 log.info("Found in ", polygons.length, "overlapping exact regions");
542 return this._findFurthest(location, polygons);
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;
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)
563 return this._findClosest(location, unBufferedRegions);
569 * Find all the polygons that contain a single point, return
570 * an array of those polygons along with the region that
573 * @param {Object} point
574 * A lat + lng coordinate.
575 * @param {Object} map
576 * Geojson object that defined seperate regions with a list
580 * An array of polygons that contain the point, along with the
581 * region they define.
583 _getPolygonsContainingPoint(point, map) {
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);
591 } else if (feature.geometry.type === "MultiPolygon") {
592 for (const innerCoords of coords) {
593 if (this._polygonInPoint(point, innerCoords[0])) {
594 polygons.push(feature);
603 * Find the largest distance between a point and any of the points that
604 * make up an array of regions.
606 * @param {Object} location
607 * A lat + lng coordinate.
608 * @param {Array} regions
609 * An array of GeoJSON region definitions.
612 * A 2 character string representing a region.
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 };
626 * Find the smallest distance between a point and any of the points that
627 * make up an array of regions.
629 * @param {Object} location
630 * A lat + lng coordinate.
631 * @param {Array} regions
632 * An array of GeoJSON region definitions.
635 * A 2 character string representing a region.
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 };
649 * Utility function to loop over all the coordinate points in an
650 * array of polygons and call a function on them.
652 * @param {Array} regions
653 * An array of GeoJSON region definitions.
654 * @param {Function} fun
655 * Function to call on individual coordinates.
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 });
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 });
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.
681 * @param {Object} location
682 * A lat + lng coordinate.
683 * @param {Object} polygon
684 * Array of coordinates that define the boundaries of a polygon.
687 * Whether the point is within the polygon.
689 _polygonInPoint({ lng, lat }, poly) {
691 // For each edge of the polygon.
692 for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
697 // Does a ray cast from the point intersect with this polygon edge.
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.
710 * Find the distance between 2 points.
713 * A lat + lng coordinate.
715 * A lat + lng coordinate.
718 * The distance between the 2 points.
720 _distanceBetween(p1, p2) {
721 return Math.hypot(p2.lng - p1.lng, p2.lat - p1.lat);
725 * A wrapper around fetch that implements a timeout, will throw
726 * a TIMEOUT error if the request is not completed in time.
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.
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)]);
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
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.
752 async _timeout(timeout, controller) {
753 await new Promise(resolve => lazy.setTimeout(resolve, timeout));
755 // Yield so it is the TIMEOUT that is returned and not
756 // the result of the abort().
757 lazy.setTimeout(() => controller.abort(), 0);
759 throw new Error("TIMEOUT");
762 async _fetchWifiData() {
763 log.info("fetchWifiData called");
764 this.wifiService = Cc["@mozilla.org/wifi/monitor;1"].getService(
767 this.wifiService.startWatching(this, false);
769 return new Promise(resolve => {
770 this._wifiDataPromise = resolve;
775 * If the user is using geolocation then we will see frequent updates
776 * debounce those so we aren't processing them constantly.
779 * Whether we should continue the update check.
781 _needsUpdateCheck() {
782 let sinceUpdate = Math.round(Date.now() / 1000) - lazy.lastUpdated;
783 let needsUpdate = sinceUpdate >= lazy.updateDebounce;
785 log.info(`Ignoring update check, last seen ${sinceUpdate} seconds ago`);
791 * Dispatch a promise returning function to the main thread and
792 * resolve when it is completed.
795 return new Promise(resolve => {
796 Services.tm.idleDispatchToMainThread(fun().then(resolve));
801 * timerManager will call this periodically to update the region
802 * in case the user never users geolocation.
804 async _updateTimer() {
805 if (this._needsUpdateCheck()) {
806 await this._fetchRegion();
811 * Called when we see geolocation updates.
812 * in case the user never users geolocation.
814 * @param {Object} location
815 * A location object containing lat + lng coordinates.
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);
823 this._setCurrentRegion(region);
828 onChange(accessPoints) {
829 log.info("onChange called");
830 if (!accessPoints || !this._wifiDataPromise) {
834 if (this.wifiService) {
835 this.wifiService.stopWatching(this);
836 this.wifiService = null;
839 if (this._wifiDataPromise) {
840 let data = lazy.LocationHelper.formatWifiAccessPoints(accessPoints);
841 this._wifiDataPromise(data);
842 this._wifiDataPromise = null;
846 observe(aSubject, aTopic) {
847 log.info(`Observed ${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;
855 lat: coords.latitude,
856 lng: coords.longitude,
862 // For tests to create blank new instances.
864 return new RegionDetector();
868 export let Region = new RegionDetector();
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;