NetworkGeolocationProvider.sys.mjs (11222B)
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 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 6 7 const lazy = {}; 8 9 ChromeUtils.defineESModuleGetters(lazy, { 10 LocationHelper: "resource://gre/modules/LocationHelper.sys.mjs", 11 clearTimeout: "resource://gre/modules/Timer.sys.mjs", 12 setTimeout: "resource://gre/modules/Timer.sys.mjs", 13 }); 14 15 // GeolocationPositionError has no interface object, so we can't use that here. 16 const POSITION_UNAVAILABLE = 2; 17 18 XPCOMUtils.defineLazyPreferenceGetter( 19 lazy, 20 "gLoggingEnabled", 21 "geo.provider.network.logging.enabled", 22 false 23 ); 24 25 function LOG(aMsg) { 26 if (lazy.gLoggingEnabled) { 27 dump("*** WIFI GEO: " + aMsg + "\n"); 28 } 29 } 30 31 function CachedRequest(loc, wifiList) { 32 this.location = loc; 33 34 let wifis = new Set(); 35 if (wifiList) { 36 for (let i = 0; i < wifiList.length; i++) { 37 wifis.add(wifiList[i].macAddress); 38 } 39 } 40 41 this.hasWifis = () => wifis.size > 0; 42 43 // if 50% of the SSIDS match 44 this.isWifiApproxEqual = function (wifiList) { 45 if (!this.hasWifis()) { 46 return false; 47 } 48 49 // if either list is a 50% subset of the other, they are equal 50 let common = 0; 51 for (let i = 0; i < wifiList.length; i++) { 52 if (wifis.has(wifiList[i].macAddress)) { 53 common++; 54 } 55 } 56 let kPercentMatch = 0.5; 57 return common >= Math.max(wifis.size, wifiList.length) * kPercentMatch; 58 }; 59 60 this.isGeoip = function () { 61 return !this.hasWifis(); 62 }; 63 } 64 65 /** @type {CachedRequest?} */ 66 var gCachedRequest = null; 67 var gDebugCacheReasoning = ""; // for logging the caching logic 68 69 // This function serves two purposes: 70 // 1) do we have a cached request 71 // 2) is the cached request better than what newWifiList will obtain 72 // If the cached request exists, and we know it to have greater accuracy 73 // by the nature of its origin (wifi/geoip), use its cached location. 74 // 75 // If there is more source info than the cached request had, return false 76 // In other cases, MLS is known to produce better/worse accuracy based on the 77 // inputs, so base the decision on that. 78 function isCachedRequestMoreAccurateThanServerRequest(newWifiList) { 79 gDebugCacheReasoning = ""; 80 let isNetworkRequestCacheEnabled = Services.prefs.getBoolPref( 81 "geo.provider.network.debug.requestCache.enabled", 82 true 83 ); 84 // Mochitest needs this pref to simulate request failure 85 if (!isNetworkRequestCacheEnabled) { 86 gCachedRequest = null; 87 } 88 89 if (!gCachedRequest || !isNetworkRequestCacheEnabled) { 90 gDebugCacheReasoning = "No cached data"; 91 return false; 92 } 93 94 if (!newWifiList) { 95 gDebugCacheReasoning = "New req. is GeoIP."; 96 return true; 97 } 98 99 let hasEqualWifis = false; 100 if (newWifiList) { 101 hasEqualWifis = gCachedRequest.isWifiApproxEqual(newWifiList); 102 } 103 104 gDebugCacheReasoning = `EqualWifis: ${hasEqualWifis}`; 105 106 if (gCachedRequest.hasWifis() && hasEqualWifis) { 107 gDebugCacheReasoning += ", Wifi only."; 108 return true; 109 } 110 111 return false; 112 } 113 114 function NetworkGeoCoordsObject(lat, lon, acc) { 115 this.latitude = lat; 116 this.longitude = lon; 117 this.accuracy = acc; 118 119 // Neither GLS nor MLS return the following properties, so set them to NaN 120 // here. nsGeoPositionCoords will convert NaNs to null for optional properties 121 // of the JavaScript Coordinates object. 122 this.altitude = NaN; 123 this.altitudeAccuracy = NaN; 124 this.heading = NaN; 125 this.speed = NaN; 126 } 127 128 NetworkGeoCoordsObject.prototype = { 129 QueryInterface: ChromeUtils.generateQI(["nsIDOMGeoPositionCoords"]), 130 }; 131 132 function NetworkGeoPositionObject(lat, lng, acc) { 133 this.coords = new NetworkGeoCoordsObject(lat, lng, acc); 134 this.address = null; 135 this.timestamp = Date.now(); 136 } 137 138 NetworkGeoPositionObject.prototype = { 139 QueryInterface: ChromeUtils.generateQI(["nsIDOMGeoPosition"]), 140 }; 141 142 export function NetworkGeolocationProvider() { 143 /* 144 The _wifiMonitorTimeout controls how long we wait on receiving an update 145 from the Wifi subsystem. If this timer fires, we believe the Wifi scan has 146 had a problem and we no longer can use Wifi to position the user this time 147 around (we will continue to be hopeful that Wifi will recover). 148 */ 149 XPCOMUtils.defineLazyPreferenceGetter( 150 this, 151 "_wifiMonitorTimeout", 152 "geo.provider.network.timeToWaitBeforeSending", 153 5000 154 ); 155 156 XPCOMUtils.defineLazyPreferenceGetter( 157 this, 158 "_wifiScanningEnabled", 159 "geo.provider.network.scan", 160 true 161 ); 162 163 this.wifiService = null; 164 this.timer = null; 165 this.started = false; 166 } 167 168 NetworkGeolocationProvider.prototype = { 169 classID: Components.ID("{77DA64D3-7458-4920-9491-86CC9914F904}"), 170 name: "NetworkGeolocationProvider", 171 QueryInterface: ChromeUtils.generateQI([ 172 "nsIGeolocationProvider", 173 "nsIWifiListener", 174 "nsITimerCallback", 175 "nsIObserver", 176 "nsINamed", 177 ]), 178 listener: null, 179 180 get isWifiScanningEnabled() { 181 return Cc["@mozilla.org/wifi/monitor;1"] && this._wifiScanningEnabled; 182 }, 183 184 resetTimer() { 185 if (this.timer) { 186 this.timer.cancel(); 187 this.timer = null; 188 } 189 // Wifi thread triggers NetworkGeolocationProvider to proceed. With no wifi, 190 // do manual timeout. 191 this.timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); 192 this.timer.initWithCallback( 193 this, 194 this._wifiMonitorTimeout, 195 this.timer.TYPE_REPEATING_SLACK 196 ); 197 }, 198 199 startup() { 200 LOG("startup called."); 201 if (this.started) { 202 return; 203 } 204 205 this.started = true; 206 207 if (this.isWifiScanningEnabled) { 208 if (this.wifiService) { 209 this.wifiService.stopWatching(this); 210 } 211 this.wifiService = Cc["@mozilla.org/wifi/monitor;1"].getService( 212 Ci.nsIWifiMonitor 213 ); 214 this.wifiService.startWatching(this, false); 215 } 216 217 this.resetTimer(); 218 }, 219 220 watch(c) { 221 LOG("watch called"); 222 this.listener = c; 223 this.notify(); 224 this.resetTimer(); 225 }, 226 227 shutdown() { 228 LOG("shutdown called"); 229 if (!this.started) { 230 return; 231 } 232 233 // Without clearing this, we could end up using the cache almost indefinitely 234 // TODO: add logic for cache lifespan, for now just be safe and clear it 235 gCachedRequest = null; 236 237 if (this.timer) { 238 this.timer.cancel(); 239 this.timer = null; 240 } 241 242 if (this.wifiService) { 243 this.wifiService.stopWatching(this); 244 this.wifiService = null; 245 } 246 247 this.listener = null; 248 this.started = false; 249 }, 250 251 setHighAccuracy(enable) { 252 // Mochitest wants to check this value 253 if (Services.prefs.getBoolPref("geo.provider.testing", false)) { 254 Services.obs.notifyObservers( 255 null, 256 "testing-geolocation-high-accuracy", 257 enable 258 ); 259 } 260 }, 261 262 onChange(accessPoints) { 263 // we got some wifi data, rearm the timer. 264 this.resetTimer(); 265 266 let wifiData = null; 267 if (accessPoints) { 268 wifiData = lazy.LocationHelper.formatWifiAccessPoints(accessPoints); 269 } 270 this.sendLocationRequest(wifiData); 271 }, 272 273 onError(code) { 274 LOG("wifi error: " + code); 275 this.sendLocationRequest(null); 276 }, 277 278 onStatus(err, statusMessage) { 279 if (!this.listener) { 280 return; 281 } 282 LOG("onStatus called." + statusMessage); 283 284 if (statusMessage && this.listener.notifyStatus) { 285 this.listener.notifyStatus(statusMessage); 286 } 287 288 if (err && this.listener.notifyError) { 289 this.listener.notifyError(POSITION_UNAVAILABLE, statusMessage); 290 } 291 }, 292 293 notify() { 294 this.onStatus(false, "wifi-timeout"); 295 this.sendLocationRequest(null); 296 }, 297 298 /** 299 * After wifi data has been gathered, this method is invoked to perform the 300 * request to network geolocation provider. 301 * The result of each request is sent to all registered listener (@see watch) 302 * by invoking its respective `update`, `notifyError` or `notifyStatus` 303 * callbacks. 304 * `update` is called upon a successful request with its response data; this will be a `NetworkGeoPositionObject` instance. 305 * `notifyError` is called whenever the request gets an error from the local 306 * network subsystem, the server or simply times out. 307 * `notifyStatus` is called for each status change of the request that may be 308 * of interest to the consumer of this class. Currently the following status 309 * changes are reported: 'xhr-start', 'xhr-timeout', 'xhr-error' and 310 * 'xhr-empty'. 311 * 312 * @param {Array} wifiData Optional set of publicly available wifi networks 313 * in the following structure: 314 * <code> 315 * [ 316 * { macAddress: <mac1>, signalStrength: <signal1> }, 317 * { macAddress: <mac2>, signalStrength: <signal2> } 318 * ] 319 * </code> 320 */ 321 async sendLocationRequest(wifiData) { 322 let data = { wifiAccessPoints: undefined }; 323 if (wifiData && wifiData.length >= 2) { 324 data.wifiAccessPoints = wifiData; 325 } 326 327 let useCached = isCachedRequestMoreAccurateThanServerRequest( 328 data.wifiAccessPoints 329 ); 330 331 LOG("Use request cache:" + useCached + " reason:" + gDebugCacheReasoning); 332 333 if (useCached) { 334 gCachedRequest.location.timestamp = Date.now(); 335 if (this.listener) { 336 this.listener.update(gCachedRequest.location); 337 } 338 return; 339 } 340 341 // From here on, do a network geolocation request // 342 let url = Services.urlFormatter.formatURLPref("geo.provider.network.url"); 343 LOG("Sending request"); 344 345 let result; 346 try { 347 result = await this.makeRequest(url, wifiData); 348 LOG( 349 `geo provider reported: ${result.location.lng}:${result.location.lat}` 350 ); 351 let newLocation = new NetworkGeoPositionObject( 352 result.location.lat, 353 result.location.lng, 354 result.accuracy 355 ); 356 357 if (this.listener) { 358 this.listener.update(newLocation); 359 } 360 361 gCachedRequest = new CachedRequest(newLocation, data.wifiAccessPoints); 362 } catch (err) { 363 LOG("Location request hit error: " + err.name); 364 console.error(err); 365 if (err.name == "AbortError") { 366 this.onStatus(true, "xhr-timeout"); 367 } else { 368 this.onStatus(true, "xhr-error"); 369 } 370 } 371 }, 372 373 async makeRequest(url, wifiData) { 374 this.onStatus(false, "xhr-start"); 375 376 let fetchController = new AbortController(); 377 let fetchOpts = { 378 method: "POST", 379 headers: { "Content-Type": "application/json; charset=UTF-8" }, 380 credentials: "omit", 381 signal: fetchController.signal, 382 }; 383 384 if (wifiData) { 385 fetchOpts.body = JSON.stringify({ wifiAccessPoints: wifiData }); 386 } 387 388 let timeoutId = lazy.setTimeout( 389 () => fetchController.abort(), 390 Services.prefs.getIntPref("geo.provider.network.timeout") 391 ); 392 393 let req = await fetch(url, fetchOpts); 394 lazy.clearTimeout(timeoutId); 395 396 if (!req.ok) { 397 throw new Error( 398 `The geolocation provider returned a non-ok status ${req.status}`, 399 { cause: await req.text() } 400 ); 401 } 402 403 let result = req.json(); 404 return result; 405 }, 406 };