Bug 1839170 - Refactor Snap pulling, Add Firefox Snap Core22 and GNOME 42 SDK symbols...
[gecko.git] / dom / system / NetworkGeolocationProvider.sys.mjs
blob1bee69a282adc9af1f108a9bfd4f6227b25c38b7
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 const lazy = {};
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 });
15 // GeolocationPositionError has no interface object, so we can't use that here.
16 const POSITION_UNAVAILABLE = 2;
18 XPCOMUtils.defineLazyPreferenceGetter(
19   lazy,
20   "gLoggingEnabled",
21   "geo.provider.network.logging.enabled",
22   false
25 function LOG(aMsg) {
26   if (lazy.gLoggingEnabled) {
27     dump("*** WIFI GEO: " + aMsg + "\n");
28   }
31 function CachedRequest(loc, cellInfo, wifiList) {
32   this.location = loc;
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   }
41   // Use only these values for equality
42   // (the JSON will contain additional values in future)
43   function makeCellKey(cell) {
44     return (
45       "" +
46       cell.radio +
47       ":" +
48       cell.mobileCountryCode +
49       ":" +
50       cell.mobileNetworkCode +
51       ":" +
52       cell.locationAreaCode +
53       ":" +
54       cell.cellId
55     );
56   }
58   let cells = new Set();
59   if (cellInfo) {
60     for (let i = 0; i < cellInfo.length; i++) {
61       cells.add(makeCellKey(cellInfo[i]));
62     }
63   }
65   this.hasCells = () => cells.size > 0;
67   this.hasWifis = () => wifis.size > 0;
69   // if fields match
70   this.isCellEqual = function (cellInfo) {
71     if (!this.hasCells()) {
72       return false;
73     }
75     let len1 = cells.size;
76     let len2 = cellInfo.length;
78     if (len1 != len2) {
79       LOG("cells not equal len");
80       return false;
81     }
83     for (let i = 0; i < len2; i++) {
84       if (!cells.has(makeCellKey(cellInfo[i]))) {
85         return false;
86       }
87     }
88     return true;
89   };
91   // if 50% of the SSIDS match
92   this.isWifiApproxEqual = function (wifiList) {
93     if (!this.hasWifis()) {
94       return false;
95     }
97     // if either list is a 50% subset of the other, they are equal
98     let common = 0;
99     for (let i = 0; i < wifiList.length; i++) {
100       if (wifis.has(wifiList[i].macAddress)) {
101         common++;
102       }
103     }
104     let kPercentMatch = 0.5;
105     return common >= Math.max(wifis.size, wifiList.length) * kPercentMatch;
106   };
108   this.isGeoip = function () {
109     return !this.hasCells() && !this.hasWifis();
110   };
112   this.isCellAndWifi = function () {
113     return this.hasCells() && this.hasWifis();
114   };
116   this.isCellOnly = function () {
117     return this.hasCells() && !this.hasWifis();
118   };
120   this.isWifiOnly = function () {
121     return this.hasWifis() && !this.hasCells();
122   };
125 var gCachedRequest = null;
126 var gDebugCacheReasoning = ""; // for logging the caching logic
128 // This function serves two purposes:
129 // 1) do we have a cached request
130 // 2) is the cached request better than what newCell and newWifiList will obtain
131 // If the cached request exists, and we know it to have greater accuracy
132 // by the nature of its origin (wifi/cell/geoip), use its cached location.
134 // If there is more source info than the cached request had, return false
135 // In other cases, MLS is known to produce better/worse accuracy based on the
136 // inputs, so base the decision on that.
137 function isCachedRequestMoreAccurateThanServerRequest(newCell, newWifiList) {
138   gDebugCacheReasoning = "";
139   let isNetworkRequestCacheEnabled = true;
140   try {
141     // Mochitest needs this pref to simulate request failure
142     isNetworkRequestCacheEnabled = Services.prefs.getBoolPref(
143       "geo.provider.network.debug.requestCache.enabled"
144     );
145     if (!isNetworkRequestCacheEnabled) {
146       gCachedRequest = null;
147     }
148   } catch (e) {}
150   if (!gCachedRequest || !isNetworkRequestCacheEnabled) {
151     gDebugCacheReasoning = "No cached data";
152     return false;
153   }
155   if (!newCell && !newWifiList) {
156     gDebugCacheReasoning = "New req. is GeoIP.";
157     return true;
158   }
160   if (
161     newCell &&
162     newWifiList &&
163     (gCachedRequest.isCellOnly() || gCachedRequest.isWifiOnly())
164   ) {
165     gDebugCacheReasoning = "New req. is cell+wifi, cache only cell or wifi.";
166     return false;
167   }
169   if (newCell && gCachedRequest.isWifiOnly()) {
170     // In order to know if a cell-only request should trump a wifi-only request
171     // need to know if wifi is low accuracy. >5km would be VERY low accuracy,
172     // it is worth trying the cell
173     var isHighAccuracyWifi = gCachedRequest.location.coords.accuracy < 5000;
174     gDebugCacheReasoning =
175       "Req. is cell, cache is wifi, isHigh:" + isHighAccuracyWifi;
176     return isHighAccuracyWifi;
177   }
179   let hasEqualCells = false;
180   if (newCell) {
181     hasEqualCells = gCachedRequest.isCellEqual(newCell);
182   }
184   let hasEqualWifis = false;
185   if (newWifiList) {
186     hasEqualWifis = gCachedRequest.isWifiApproxEqual(newWifiList);
187   }
189   gDebugCacheReasoning =
190     "EqualCells:" + hasEqualCells + " EqualWifis:" + hasEqualWifis;
192   if (gCachedRequest.isCellOnly()) {
193     gDebugCacheReasoning += ", Cell only.";
194     if (hasEqualCells) {
195       return true;
196     }
197   } else if (gCachedRequest.isWifiOnly() && hasEqualWifis) {
198     gDebugCacheReasoning += ", Wifi only.";
199     return true;
200   } else if (gCachedRequest.isCellAndWifi()) {
201     gDebugCacheReasoning += ", Cache has Cell+Wifi.";
202     if (
203       (hasEqualCells && hasEqualWifis) ||
204       (!newWifiList && hasEqualCells) ||
205       (!newCell && hasEqualWifis)
206     ) {
207       return true;
208     }
209   }
211   return false;
214 function NetworkGeoCoordsObject(lat, lon, acc) {
215   this.latitude = lat;
216   this.longitude = lon;
217   this.accuracy = acc;
219   // Neither GLS nor MLS return the following properties, so set them to NaN
220   // here. nsGeoPositionCoords will convert NaNs to null for optional properties
221   // of the JavaScript Coordinates object.
222   this.altitude = NaN;
223   this.altitudeAccuracy = NaN;
224   this.heading = NaN;
225   this.speed = NaN;
228 NetworkGeoCoordsObject.prototype = {
229   QueryInterface: ChromeUtils.generateQI(["nsIDOMGeoPositionCoords"]),
232 function NetworkGeoPositionObject(lat, lng, acc) {
233   this.coords = new NetworkGeoCoordsObject(lat, lng, acc);
234   this.address = null;
235   this.timestamp = Date.now();
238 NetworkGeoPositionObject.prototype = {
239   QueryInterface: ChromeUtils.generateQI(["nsIDOMGeoPosition"]),
242 export function NetworkGeolocationProvider() {
243   /*
244     The _wifiMonitorTimeout controls how long we wait on receiving an update
245     from the Wifi subsystem.  If this timer fires, we believe the Wifi scan has
246     had a problem and we no longer can use Wifi to position the user this time
247     around (we will continue to be hopeful that Wifi will recover).
249     This timeout value is also used when Wifi scanning is disabled (see
250     isWifiScanningEnabled).  In this case, we use this timer to collect cell/ip
251     data and xhr it to the location server.
252   */
253   XPCOMUtils.defineLazyPreferenceGetter(
254     this,
255     "_wifiMonitorTimeout",
256     "geo.provider.network.timeToWaitBeforeSending",
257     5000
258   );
260   XPCOMUtils.defineLazyPreferenceGetter(
261     this,
262     "_wifiScanningEnabled",
263     "geo.provider.network.scan",
264     true
265   );
267   this.wifiService = null;
268   this.timer = null;
269   this.started = false;
272 NetworkGeolocationProvider.prototype = {
273   classID: Components.ID("{77DA64D3-7458-4920-9491-86CC9914F904}"),
274   name: "NetworkGeolocationProvider",
275   QueryInterface: ChromeUtils.generateQI([
276     "nsIGeolocationProvider",
277     "nsIWifiListener",
278     "nsITimerCallback",
279     "nsIObserver",
280     "nsINamed",
281   ]),
282   listener: null,
284   get isWifiScanningEnabled() {
285     return Cc["@mozilla.org/wifi/monitor;1"] && this._wifiScanningEnabled;
286   },
288   resetTimer() {
289     if (this.timer) {
290       this.timer.cancel();
291       this.timer = null;
292     }
293     // Wifi thread triggers NetworkGeolocationProvider to proceed. With no wifi,
294     // do manual timeout.
295     this.timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
296     this.timer.initWithCallback(
297       this,
298       this._wifiMonitorTimeout,
299       this.timer.TYPE_REPEATING_SLACK
300     );
301   },
303   startup() {
304     if (this.started) {
305       return;
306     }
308     this.started = true;
310     if (this.isWifiScanningEnabled) {
311       if (this.wifiService) {
312         this.wifiService.stopWatching(this);
313       }
314       this.wifiService = Cc["@mozilla.org/wifi/monitor;1"].getService(
315         Ci.nsIWifiMonitor
316       );
317       this.wifiService.startWatching(this, false);
318     }
320     this.resetTimer();
321     LOG("startup called.");
322   },
324   watch(c) {
325     this.listener = c;
326   },
328   shutdown() {
329     LOG("shutdown called");
330     if (!this.started) {
331       return;
332     }
334     // Without clearing this, we could end up using the cache almost indefinitely
335     // TODO: add logic for cache lifespan, for now just be safe and clear it
336     gCachedRequest = null;
338     if (this.timer) {
339       this.timer.cancel();
340       this.timer = null;
341     }
343     if (this.wifiService) {
344       this.wifiService.stopWatching(this);
345       this.wifiService = null;
346     }
348     this.listener = null;
349     this.started = false;
350   },
352   setHighAccuracy(enable) {
353     // Mochitest wants to check this value
354     if (Services.prefs.getBoolPref("geo.provider.testing")) {
355       Services.obs.notifyObservers(
356         null,
357         "testing-geolocation-high-accuracy",
358         enable
359       );
360     }
361   },
363   onChange(accessPoints) {
364     // we got some wifi data, rearm the timer.
365     this.resetTimer();
367     let wifiData = null;
368     if (accessPoints) {
369       wifiData = lazy.LocationHelper.formatWifiAccessPoints(accessPoints);
370     }
371     this.sendLocationRequest(wifiData);
372   },
374   onError(code) {
375     LOG("wifi error: " + code);
376     this.sendLocationRequest(null);
377   },
379   onStatus(err, statusMessage) {
380     if (!this.listener) {
381       return;
382     }
383     LOG("onStatus called." + statusMessage);
385     if (statusMessage && this.listener.notifyStatus) {
386       this.listener.notifyStatus(statusMessage);
387     }
389     if (err && this.listener.notifyError) {
390       this.listener.notifyError(POSITION_UNAVAILABLE, statusMessage);
391     }
392   },
394   notify(timer) {
395     this.onStatus(false, "wifi-timeout");
396     this.sendLocationRequest(null);
397   },
399   /**
400    * After wifi (and possible cell tower) data has been gathered, this method is
401    * invoked to perform the request to network geolocation provider.
402    * The result of each request is sent to all registered listener (@see watch)
403    * by invoking its respective `update`, `notifyError` or `notifyStatus`
404    * callbacks.
405    * `update` is called upon a successful request with its response data; this will be a `NetworkGeoPositionObject` instance.
406    * `notifyError` is called whenever the request gets an error from the local
407    * network subsystem, the server or simply times out.
408    * `notifyStatus` is called for each status change of the request that may be
409    * of interest to the consumer of this class. Currently the following status
410    * changes are reported: 'xhr-start', 'xhr-timeout', 'xhr-error' and
411    * 'xhr-empty'.
412    *
413    * @param  {Array} wifiData Optional set of publicly available wifi networks
414    *                          in the following structure:
415    *                          <code>
416    *                          [
417    *                            { macAddress: <mac1>, signalStrength: <signal1> },
418    *                            { macAddress: <mac2>, signalStrength: <signal2> }
419    *                          ]
420    *                          </code>
421    */
422   async sendLocationRequest(wifiData) {
423     let data = { cellTowers: undefined, wifiAccessPoints: undefined };
424     if (wifiData && wifiData.length >= 2) {
425       data.wifiAccessPoints = wifiData;
426     }
428     let useCached = isCachedRequestMoreAccurateThanServerRequest(
429       data.cellTowers,
430       data.wifiAccessPoints
431     );
433     LOG("Use request cache:" + useCached + " reason:" + gDebugCacheReasoning);
435     if (useCached) {
436       gCachedRequest.location.timestamp = Date.now();
437       if (this.listener) {
438         this.listener.update(gCachedRequest.location);
439       }
440       return;
441     }
443     // From here on, do a network geolocation request //
444     let url = Services.urlFormatter.formatURLPref("geo.provider.network.url");
445     LOG("Sending request");
447     let result;
448     try {
449       result = await this.makeRequest(url, wifiData);
450       LOG(
451         `geo provider reported: ${result.location.lng}:${result.location.lat}`
452       );
453       let newLocation = new NetworkGeoPositionObject(
454         result.location.lat,
455         result.location.lng,
456         result.accuracy
457       );
459       if (this.listener) {
460         this.listener.update(newLocation);
461       }
463       gCachedRequest = new CachedRequest(
464         newLocation,
465         data.cellTowers,
466         data.wifiAccessPoints
467       );
468     } catch (err) {
469       LOG("Location request hit error: " + err.name);
470       console.error(err);
471       if (err.name == "AbortError") {
472         this.onStatus(true, "xhr-timeout");
473       } else {
474         this.onStatus(true, "xhr-error");
475       }
476     }
477   },
479   async makeRequest(url, wifiData) {
480     this.onStatus(false, "xhr-start");
482     let fetchController = new AbortController();
483     let fetchOpts = {
484       method: "POST",
485       headers: { "Content-Type": "application/json; charset=UTF-8" },
486       credentials: "omit",
487       signal: fetchController.signal,
488     };
490     if (wifiData) {
491       fetchOpts.body = JSON.stringify({ wifiAccessPoints: wifiData });
492     }
494     let timeoutId = lazy.setTimeout(
495       () => fetchController.abort(),
496       Services.prefs.getIntPref("geo.provider.network.timeout")
497     );
499     let req = await fetch(url, fetchOpts);
500     lazy.clearTimeout(timeoutId);
501     let result = req.json();
502     return result;
503   },