/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { LocationHelper: "resource://gre/modules/LocationHelper.sys.mjs", clearTimeout: "resource://gre/modules/Timer.sys.mjs", setTimeout: "resource://gre/modules/Timer.sys.mjs", }); // GeolocationPositionError has no interface object, so we can't use that here. const POSITION_UNAVAILABLE = 2; XPCOMUtils.defineLazyPreferenceGetter( lazy, "gLoggingEnabled", "geo.provider.network.logging.enabled", false ); function LOG(aMsg) { if (lazy.gLoggingEnabled) { dump("*** WIFI GEO: " + aMsg + "\n"); } } function CachedRequest(loc, wifiList) { this.location = loc; let wifis = new Set(); if (wifiList) { for (let i = 0; i < wifiList.length; i++) { wifis.add(wifiList[i].macAddress); } } this.hasWifis = () => wifis.size > 0; // if 50% of the SSIDS match this.isWifiApproxEqual = function (wifiList) { if (!this.hasWifis()) { return false; } // if either list is a 50% subset of the other, they are equal let common = 0; for (let i = 0; i < wifiList.length; i++) { if (wifis.has(wifiList[i].macAddress)) { common++; } } let kPercentMatch = 0.5; return common >= Math.max(wifis.size, wifiList.length) * kPercentMatch; }; this.isGeoip = function () { return !this.hasWifis(); }; } /** @type {CachedRequest?} */ var gCachedRequest = null; var gDebugCacheReasoning = ""; // for logging the caching logic // This function serves two purposes: // 1) do we have a cached request // 2) is the cached request better than what newWifiList will obtain // If the cached request exists, and we know it to have greater accuracy // by the nature of its origin (wifi/geoip), use its cached location. // // If there is more source info than the cached request had, return false // In other cases, MLS is known to produce better/worse accuracy based on the // inputs, so base the decision on that. function isCachedRequestMoreAccurateThanServerRequest(newWifiList) { gDebugCacheReasoning = ""; let isNetworkRequestCacheEnabled = Services.prefs.getBoolPref( "geo.provider.network.debug.requestCache.enabled", true ); // Mochitest needs this pref to simulate request failure if (!isNetworkRequestCacheEnabled) { gCachedRequest = null; } if (!gCachedRequest || !isNetworkRequestCacheEnabled) { gDebugCacheReasoning = "No cached data"; return false; } if (!newWifiList) { gDebugCacheReasoning = "New req. is GeoIP."; return true; } let hasEqualWifis = false; if (newWifiList) { hasEqualWifis = gCachedRequest.isWifiApproxEqual(newWifiList); } gDebugCacheReasoning = `EqualWifis: ${hasEqualWifis}`; if (gCachedRequest.hasWifis() && hasEqualWifis) { gDebugCacheReasoning += ", Wifi only."; return true; } return false; } function NetworkGeoCoordsObject(lat, lon, acc) { this.latitude = lat; this.longitude = lon; this.accuracy = acc; // Neither GLS nor MLS return the following properties, so set them to NaN // here. nsGeoPositionCoords will convert NaNs to null for optional properties // of the JavaScript Coordinates object. this.altitude = NaN; this.altitudeAccuracy = NaN; this.heading = NaN; this.speed = NaN; } NetworkGeoCoordsObject.prototype = { QueryInterface: ChromeUtils.generateQI(["nsIDOMGeoPositionCoords"]), }; function NetworkGeoPositionObject(lat, lng, acc) { this.coords = new NetworkGeoCoordsObject(lat, lng, acc); this.address = null; this.timestamp = Date.now(); } NetworkGeoPositionObject.prototype = { QueryInterface: ChromeUtils.generateQI(["nsIDOMGeoPosition"]), }; export function NetworkGeolocationProvider() { /* The _wifiMonitorTimeout controls how long we wait on receiving an update from the Wifi subsystem. If this timer fires, we believe the Wifi scan has had a problem and we no longer can use Wifi to position the user this time around (we will continue to be hopeful that Wifi will recover). */ XPCOMUtils.defineLazyPreferenceGetter( this, "_wifiMonitorTimeout", "geo.provider.network.timeToWaitBeforeSending", 5000 ); XPCOMUtils.defineLazyPreferenceGetter( this, "_wifiScanningEnabled", "geo.provider.network.scan", true ); this.wifiService = null; this.timer = null; this.started = false; } NetworkGeolocationProvider.prototype = { classID: Components.ID("{77DA64D3-7458-4920-9491-86CC9914F904}"), name: "NetworkGeolocationProvider", QueryInterface: ChromeUtils.generateQI([ "nsIGeolocationProvider", "nsIWifiListener", "nsITimerCallback", "nsIObserver", "nsINamed", ]), listener: null, get isWifiScanningEnabled() { return Cc["@mozilla.org/wifi/monitor;1"] && this._wifiScanningEnabled; }, resetTimer() { if (this.timer) { this.timer.cancel(); this.timer = null; } // Wifi thread triggers NetworkGeolocationProvider to proceed. With no wifi, // do manual timeout. this.timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); this.timer.initWithCallback( this, this._wifiMonitorTimeout, this.timer.TYPE_REPEATING_SLACK ); }, startup() { LOG("startup called."); if (this.started) { return; } this.started = true; if (this.isWifiScanningEnabled) { if (this.wifiService) { this.wifiService.stopWatching(this); } this.wifiService = Cc["@mozilla.org/wifi/monitor;1"].getService( Ci.nsIWifiMonitor ); this.wifiService.startWatching(this, false); } this.resetTimer(); }, watch(c) { LOG("watch called"); this.listener = c; this.notify(); this.resetTimer(); }, shutdown() { LOG("shutdown called"); if (!this.started) { return; } // Without clearing this, we could end up using the cache almost indefinitely // TODO: add logic for cache lifespan, for now just be safe and clear it gCachedRequest = null; if (this.timer) { this.timer.cancel(); this.timer = null; } if (this.wifiService) { this.wifiService.stopWatching(this); this.wifiService = null; } this.listener = null; this.started = false; }, setHighAccuracy(enable) { // Mochitest wants to check this value if (Services.prefs.getBoolPref("geo.provider.testing", false)) { Services.obs.notifyObservers( null, "testing-geolocation-high-accuracy", enable ); } }, onChange(accessPoints) { // we got some wifi data, rearm the timer. this.resetTimer(); let wifiData = null; if (accessPoints) { wifiData = lazy.LocationHelper.formatWifiAccessPoints(accessPoints); } this.sendLocationRequest(wifiData); }, onError(code) { LOG("wifi error: " + code); this.sendLocationRequest(null); }, onStatus(err, statusMessage) { if (!this.listener) { return; } LOG("onStatus called." + statusMessage); if (statusMessage && this.listener.notifyStatus) { this.listener.notifyStatus(statusMessage); } if (err && this.listener.notifyError) { this.listener.notifyError(POSITION_UNAVAILABLE, statusMessage); } }, notify() { this.onStatus(false, "wifi-timeout"); this.sendLocationRequest(null); }, /** * After wifi data has been gathered, this method is invoked to perform the * request to network geolocation provider. * The result of each request is sent to all registered listener (@see watch) * by invoking its respective `update`, `notifyError` or `notifyStatus` * callbacks. * `update` is called upon a successful request with its response data; this will be a `NetworkGeoPositionObject` instance. * `notifyError` is called whenever the request gets an error from the local * network subsystem, the server or simply times out. * `notifyStatus` is called for each status change of the request that may be * of interest to the consumer of this class. Currently the following status * changes are reported: 'xhr-start', 'xhr-timeout', 'xhr-error' and * 'xhr-empty'. * * @param {Array} wifiData Optional set of publicly available wifi networks * in the following structure: * * [ * { macAddress: , signalStrength: }, * { macAddress: , signalStrength: } * ] * */ async sendLocationRequest(wifiData) { let data = { wifiAccessPoints: undefined }; if (wifiData && wifiData.length >= 2) { data.wifiAccessPoints = wifiData; } let useCached = isCachedRequestMoreAccurateThanServerRequest( data.wifiAccessPoints ); LOG("Use request cache:" + useCached + " reason:" + gDebugCacheReasoning); if (useCached) { gCachedRequest.location.timestamp = Date.now(); if (this.listener) { this.listener.update(gCachedRequest.location); } return; } // From here on, do a network geolocation request // let url = Services.urlFormatter.formatURLPref("geo.provider.network.url"); LOG("Sending request"); let result; try { result = await this.makeRequest(url, wifiData); LOG( `geo provider reported: ${result.location.lng}:${result.location.lat}` ); let newLocation = new NetworkGeoPositionObject( result.location.lat, result.location.lng, result.accuracy ); if (this.listener) { this.listener.update(newLocation); } gCachedRequest = new CachedRequest(newLocation, data.wifiAccessPoints); } catch (err) { LOG("Location request hit error: " + err.name); console.error(err); if (err.name == "AbortError") { this.onStatus(true, "xhr-timeout"); } else { this.onStatus(true, "xhr-error"); } } }, async makeRequest(url, wifiData) { this.onStatus(false, "xhr-start"); let fetchController = new AbortController(); let fetchOpts = { method: "POST", headers: { "Content-Type": "application/json; charset=UTF-8" }, credentials: "omit", signal: fetchController.signal, }; if (wifiData) { fetchOpts.body = JSON.stringify({ wifiAccessPoints: wifiData }); } let timeoutId = lazy.setTimeout( () => fetchController.abort(), Services.prefs.getIntPref("geo.provider.network.timeout") ); let req = await fetch(url, fetchOpts); lazy.clearTimeout(timeoutId); if (!req.ok) { throw new Error( `The geolocation provider returned a non-ok status ${req.status}`, { cause: await req.text() } ); } let result = req.json(); return result; }, };