import { Store, UIEventSource } from "../UIEventSource" import { LocalStorageSource } from "../Web/LocalStorageSource" import { QueryParameters } from "../Web/QueryParameters" import { Translation } from "../../UI/i18n/Translation" import Translations from "../../UI/i18n/Translations" import { AndroidPolyfill } from "../Web/AndroidPolyfill" export type GeolocationPermissionState = "prompt" | "requested" | "granted" | "denied" export interface GeoLocationPointProperties extends GeolocationCoordinates { id: "gps" "user:location": "yes" date: string } /** * An abstract representation of the current state of the geolocation, keeping track of permissions and if a location is known. */ export class GeoLocationState { /** * What do we know about the current state of having access to the GPS? * If 'prompt', then we just started and didn't request access yet * 'requested' means the user tapped the 'locate me' button at least once * 'granted' means that it is granted * 'denied' means that we don't have access */ public readonly permission: UIEventSource = new UIEventSource( "prompt", ) /** * If an error occurs with a code indicating "gps unavailable", this will be set to "false". * This is about the physical availability of the GPS-signal; this might e.g. become false if the user is in a tunnel */ private readonly _gpsAvailable: UIEventSource = new UIEventSource(true) public readonly gpsAvailable: Store = this._gpsAvailable /** * Important to determine e.g. if we move automatically on fix or not */ public readonly requestMoment: UIEventSource = new UIEventSource(undefined) /** * If true: the map will center (and re-center) to the current GPS-location */ public readonly allowMoving: UIEventSource = new UIEventSource(true) /** * The latest GeoLocationCoordinates, as given by the WebAPI */ public readonly currentGPSLocation: UIEventSource = new UIEventSource(undefined) /** * A small flag on localstorage. If the user previously granted the geolocation, it will be set. * On firefox, the permissions api is broken (probably fingerprint resistiance) and "granted + don't ask again" doesn't stick between sessions. * * Instead, we set this flag. If this flag is set upon loading the page, we start geolocating immediately. * If the user denies the geolocation this time, we unset this flag * @private */ private readonly _previousLocationGrant: UIEventSource = LocalStorageSource.getParsed("geolocation-permissions", false) /** * Used to detect a permission retraction */ private readonly _grantedThisSession: UIEventSource = new UIEventSource(false) /** * A human explanation of the current gps state, to be shown on the home screen or as tooltip */ public readonly gpsStateExplanation: Store constructor() { const self = this this.permission.addCallbackAndRunD(async (state) => { if (state === "granted") { self._previousLocationGrant.setData(true) self._grantedThisSession.setData(true) } if (state === "prompt" && self._grantedThisSession.data) { // This is _really_ weird: we had a grant earlier, but it's 'prompt' now? // This means that the rights have been revoked again! self._previousLocationGrant.setData(false) self.permission.setData("denied") self.currentGPSLocation.setData(undefined) console.warn("Detected a downgrade in permissions!") } if (state === "denied") { self._previousLocationGrant.setData(false) } }) console.log("Previous location grant:", this._previousLocationGrant.data) if (this._previousLocationGrant.data) { // A previous visit successfully granted permission. Chance is high that we are allowed to use it again! // We set the flag to false again. If the user only wanted to share their location once, we are not gonna keep bothering them this._previousLocationGrant.setData(false) console.log("Requesting access to GPS as this was previously granted") const latLonGivenViaUrl = QueryParameters.wasInitialized("lat") || QueryParameters.wasInitialized("lon") if (!latLonGivenViaUrl) { this.requestMoment.setData(new Date()) } this.requestPermission() } this.gpsStateExplanation = this.gpsAvailable.map( (available) => { if (this.currentGPSLocation.data !== undefined) { if (!this.allowMoving.data) { return Translations.t.general.visualFeedback.islocked } return Translations.t.general.labels.jumpToLocation } if (!available) { return Translations.t.general.labels.locationNotAvailable } if (this.permission.data === "denied") { return Translations.t.general.geopermissionDenied } if (this.permission.data === "prompt") { return Translations.t.general.labels.jumpToLocation } if (this.permission.data === "requested") { return Translations.t.general.waitingForGeopermission } return Translations.t.general.waitingForLocation }, [this.allowMoving, this.permission, this.currentGPSLocation], ) } /** * Requests the user to allow access to their position. * When granted, will be written to the 'geolocationState'. * This class will start watching */ public requestPermission() { this.requestPermissionAsync() } public static isSafari(): boolean { return navigator.permissions === undefined && navigator.geolocation !== undefined } /** * Requests the user to allow access to their position. * When granted, will be written to the 'geolocationState'. * This class will start watching */ public async requestPermissionAsync() { if (typeof navigator === "undefined") { // Not compatible with this browser this.permission.setData("denied") return } if (this.permission.data !== "prompt" && this.permission.data !== "requested") { // If the user denies the first prompt, revokes the deny and then tries again, we have to run the flow as well // Hence that we continue the flow if it is "requested" return } if(AndroidPolyfill.inAndroid.data){ this.permission.setData("requested") this.permission.addCallbackAndRunD(p => { if(p === "granted"){ this.startWatching() return true } }) AndroidPolyfill.requestGeoPermission().then(state => { const granted = state.value === "true" this.permission.set(granted ? "granted" : "denied") }) return } if (GeoLocationState.isSafari()) { /* This is probably safari Safari does not support the 'permissions'-API for geolocation, so we just start watching right away */ this.permission.setData("requested") this.startWatching() return } this.permission.setData("requested") try { const status = await navigator?.permissions?.query({ name: "geolocation" }) console.log("Got geolocation state", status.state) if (status.state === "granted" || status.state === "denied") { this.permission.setData(status.state) this.startWatching() return } status.addEventListener("change", () => { this.permission.setData(status.state) }) // The code above might have reset it to 'prompt', but we _did_ request permission! this.permission.setData("requested") // We _must_ call 'startWatching', as that is the actual trigger for the popup... this.startWatching() } catch (e) { console.error("Could not get permission:", e) } } /** * Installs the listener for updates * @private */ private async startWatching() { if(AndroidPolyfill.inAndroid.data){ AndroidPolyfill.watchLocation( this.currentGPSLocation, location => { console.log(JSON.stringify(location)) }) } navigator.geolocation.watchPosition( (position: GeolocationPosition) => { this._gpsAvailable.set(true) this.currentGPSLocation.setData(position.coords) this._previousLocationGrant.setData(true) }, (e: GeolocationPositionError) => { if (e.code === 2 || e.code === 3) { this._gpsAvailable.set(false) return } this._gpsAvailable.set(true) // We go back to the default assumption that the location is physically available if (e.code === 1) { this.permission.set("denied") this._grantedThisSession.setData(false) return } console.warn("Could not get location with navigator.geolocation due to", e) }, { enableHighAccuracy: true, } ) } }