diff --git a/frontend/app/android/capacitor.settings.gradle b/frontend/app/android/capacitor.settings.gradle index ed17b8b0..af9a5408 100644 --- a/frontend/app/android/capacitor.settings.gradle +++ b/frontend/app/android/capacitor.settings.gradle @@ -12,28 +12,28 @@ include ':capacitor-app' project(':capacitor-app').projectDir = new File('../../../node_modules/.pnpm/@capacitor+app@8.0.1_@capacitor+core@8.2.0/node_modules/@capacitor/app/android') include ':capacitor-browser' -project(':capacitor-browser').projectDir = new File('../../../node_modules/.pnpm/@capacitor+browser@8.0.2_@capacitor+core@8.2.0/node_modules/@capacitor/browser/android') +project(':capacitor-browser').projectDir = new File('../../../node_modules/.pnpm/@capacitor+browser@8.0.4_@capacitor+core@8.2.0/node_modules/@capacitor/browser/android') include ':capacitor-clipboard' project(':capacitor-clipboard').projectDir = new File('../../../node_modules/.pnpm/@capacitor+clipboard@8.0.1_@capacitor+core@8.2.0/node_modules/@capacitor/clipboard/android') include ':capacitor-device' -project(':capacitor-device').projectDir = new File('../../../node_modules/.pnpm/@capacitor+device@8.0.1_@capacitor+core@8.2.0/node_modules/@capacitor/device/android') +project(':capacitor-device').projectDir = new File('../../../node_modules/.pnpm/@capacitor+device@8.0.3_@capacitor+core@8.2.0/node_modules/@capacitor/device/android') include ':capacitor-dialog' project(':capacitor-dialog').projectDir = new File('../../../node_modules/.pnpm/@capacitor+dialog@8.0.1_@capacitor+core@8.2.0/node_modules/@capacitor/dialog/android') include ':capacitor-filesystem' -project(':capacitor-filesystem').projectDir = new File('../../../node_modules/.pnpm/@capacitor+filesystem@8.1.2_@capacitor+core@8.2.0/node_modules/@capacitor/filesystem/android') +project(':capacitor-filesystem').projectDir = new File('../../../node_modules/.pnpm/@capacitor+filesystem@8.1.3_@capacitor+core@8.2.0/node_modules/@capacitor/filesystem/android') include ':capacitor-geolocation' project(':capacitor-geolocation').projectDir = new File('../../../node_modules/.pnpm/@capacitor+geolocation@8.1.0_@capacitor+core@8.2.0/node_modules/@capacitor/geolocation/android') include ':capacitor-haptics' -project(':capacitor-haptics').projectDir = new File('../../../node_modules/.pnpm/@capacitor+haptics@8.0.1_@capacitor+core@8.2.0/node_modules/@capacitor/haptics/android') +project(':capacitor-haptics').projectDir = new File('../../../node_modules/.pnpm/@capacitor+haptics@8.0.2_@capacitor+core@8.2.0/node_modules/@capacitor/haptics/android') include ':capacitor-keyboard' -project(':capacitor-keyboard').projectDir = new File('../../../node_modules/.pnpm/@capacitor+keyboard@8.0.1_@capacitor+core@8.2.0/node_modules/@capacitor/keyboard/android') +project(':capacitor-keyboard').projectDir = new File('../../../node_modules/.pnpm/@capacitor+keyboard@8.0.5_@capacitor+core@8.2.0/node_modules/@capacitor/keyboard/android') include ':capacitor-local-notifications' project(':capacitor-local-notifications').projectDir = new File('../../../node_modules/.pnpm/@capacitor+local-notifications@8.0.2_@capacitor+core@8.2.0/node_modules/@capacitor/local-notifications/android') @@ -51,10 +51,10 @@ include ':capacitor-share' project(':capacitor-share').projectDir = new File('../../../node_modules/.pnpm/@capacitor+share@8.0.1_@capacitor+core@8.2.0/node_modules/@capacitor/share/android') include ':capacitor-splash-screen' -project(':capacitor-splash-screen').projectDir = new File('../../../node_modules/.pnpm/@capacitor+splash-screen@8.0.1_@capacitor+core@8.2.0/node_modules/@capacitor/splash-screen/android') +project(':capacitor-splash-screen').projectDir = new File('../../../node_modules/.pnpm/@capacitor+splash-screen@8.0.2_@capacitor+core@8.2.0/node_modules/@capacitor/splash-screen/android') include ':transistorsoft-capacitor-background-fetch' project(':transistorsoft-capacitor-background-fetch').projectDir = new File('../../../node_modules/.pnpm/@transistorsoft+capacitor-background-fetch@8.0.0_@capacitor+core@8.2.0/node_modules/@transistorsoft/capacitor-background-fetch/android') include ':capacitor-secure-storage-plugin' -project(':capacitor-secure-storage-plugin').projectDir = new File('../../../node_modules/.pnpm/capacitor-secure-storage-plugin@0.12.0_@capacitor+core@8.2.0/node_modules/capacitor-secure-storage-plugin/android') +project(':capacitor-secure-storage-plugin').projectDir = new File('../../../node_modules/.pnpm/capacitor-secure-storage-plugin@0.13.0_@capacitor+core@8.2.0/node_modules/capacitor-secure-storage-plugin/android') diff --git a/frontend/app/src/app/modules/data/list/food-data-list.component.ts b/frontend/app/src/app/modules/data/list/food-data-list.component.ts index 546d686b..285c6c99 100644 --- a/frontend/app/src/app/modules/data/list/food-data-list.component.ts +++ b/frontend/app/src/app/modules/data/list/food-data-list.component.ts @@ -14,8 +14,7 @@ */ import {Component, inject} from '@angular/core'; import {PositionService} from '../../map/position.service'; -import {Geolocation} from '@capacitor/geolocation'; -import {BehaviorSubject, catchError} from 'rxjs'; +import {BehaviorSubject, distinctUntilChanged, map} from 'rxjs'; import {pauseWhen} from '../../../util/rxjs/pause-when'; import {SCSearchFilter} from '@openstapps/core'; import {ContextMenuService} from '../../menu/context/context-menu.service'; @@ -68,43 +67,42 @@ export class FoodDataListComponent { type: 'boolean', }; + private positionService = inject(PositionService); + constructor() { - const positionService = inject(PositionService); + const positionService = this.positionService; const contextMenuService = inject(ContextMenuService); - positionService - .watchCurrentLocation({enableHighAccuracy: false, maximumAge: 1000}) + // Uses the app-wide shared location watch instead of starting its own + positionService.geoLocation .pipe( + map(({coords}) => ({latitude: coords.latitude, longitude: coords.longitude})), + // only re-sort (= new search) after moving roughly 100 m, not on every GPS update + distinctUntilChanged( + (a, b) => Math.abs(a.latitude - b.latitude) < 0.001 && Math.abs(a.longitude - b.longitude) < 0.0015, + ), pauseWhen(this.isNotInView$), takeUntilDestroyed(), - catchError(async _error => { - await Geolocation.checkPermissions(); - }), ) - .subscribe({ - next(position) { - if (!position) return; - positionService.position = position; - contextMenuService.sortQuery.next([ - { - type: 'distance', - order: 'asc', - arguments: { - field: 'geo', - position: [position.longitude, position.latitude], - }, + .subscribe(position => { + positionService.position = position; + contextMenuService.sortQuery.next([ + { + type: 'distance', + order: 'asc', + arguments: { + field: 'geo', + position: [position.longitude, position.latitude], }, - ]); - }, - async error() { - positionService.position = undefined; - await Geolocation.checkPermissions(); - }, + }, + ]); }); } ionViewWillEnter() { this.isNotInView$.next(false); + // the shared watch never asks by itself; on this page, location is the point, so ask here + void this.positionService.requestPermission(); } ionViewWillLeave() { diff --git a/frontend/app/src/app/modules/map/controls/geolocate-control.component.ts b/frontend/app/src/app/modules/map/controls/geolocate-control.component.ts index ac3f5acc..c4b6bada 100644 --- a/frontend/app/src/app/modules/map/controls/geolocate-control.component.ts +++ b/frontend/app/src/app/modules/map/controls/geolocate-control.component.ts @@ -13,16 +13,86 @@ import {IonFabButton} from '@ionic/angular/standalone'; import {MapService} from '@maplibre/ngx-maplibre-gl'; import {FitBoundsOptions, GeolocateControl, GeolocateControlOptions} from 'maplibre-gl'; import {Map as MapLibre} from 'maplibre-gl'; -import {BehaviorSubject} from 'rxjs'; +import {BehaviorSubject, Subscription} from 'rxjs'; import {IonIconDirective} from 'src/app/util/ion-icon/ion-icon.directive'; +import {PERMISSION_DENIED, PositionService} from '../position.service'; type WatchState = InstanceType['_watchState']; +type GeolocationApi = Pick; + +/** + * A `navigator.geolocation` look-alike that serves positions from the app's PositionService. + * MapLibre has no option for a custom position source and always calls `navigator.geolocation`, + * so this lets the map share the app's single location watch instead of starting its own. + */ +class PositionServiceGeolocation implements GeolocationApi { + private nextId = 1; + + private readonly watches = new Map(); + + constructor(private readonly positionService: PositionService) {} + + watchPosition(success: PositionCallback, error?: PositionErrorCallback | null): number { + const id = this.nextId++; + const watch = new Subscription(); + this.watches.set(id, watch); + void this.positionService.requestPermission().then(permission => { + if (watch.closed) return; + if (permission === PERMISSION_DENIED) { + error?.({code: 1, message: 'User denied Geolocation'} as GeolocationPositionError); + return; + } + // Capacitor's Position has the same shape as the browser's GeolocationPosition + watch.add( + // precise: while the user tracks their location on the map, the shared watch uses GPS + this.positionService.preciseLocation.subscribe(position => + success(position as unknown as GeolocationPosition), + ), + ); + }); + return id; + } + + clearWatch(id: number): void { + this.watches.get(id)?.unsubscribe(); + this.watches.delete(id); + } + + getCurrentPosition(success: PositionCallback, error?: PositionErrorCallback | null): void { + const id = this.watchPosition( + position => { + this.clearWatch(id); + success(position); + }, + positionError => { + this.clearWatch(id); + error?.(positionError); + }, + ); + } +} + +/** + * Runs `run` while `navigator.geolocation` is replaced by `geolocation`. + * MapLibre only uses it synchronously inside the wrapped calls, so no other code sees the swap. + */ +function withGeolocation(geolocation: GeolocationApi, run: () => T): T { + Object.defineProperty(globalThis.navigator, 'geolocation', {value: geolocation, configurable: true}); + try { + return run(); + } finally { + // removing the own property brings back the browser's original + delete (globalThis.navigator as {geolocation?: unknown}).geolocation; + } +} + class CustomGeolocateControl extends GeolocateControl { constructor( public _container: HTMLElement, watchState: BehaviorSubject, options: GeolocateControlOptions, + private readonly geolocation: GeolocationApi, ) { super(options); Object.defineProperty(this, '_watchState', { @@ -45,7 +115,27 @@ class CustomGeolocateControl extends GeolocateControl { return this._container; } - override onRemove() {} + // MapLibre uses navigator.geolocation only in these three methods: point it to the PositionService + + override trigger(): boolean { + return withGeolocation(this.geolocation, () => super.trigger()); + } + + override _clearWatch(): void { + withGeolocation(this.geolocation, () => super._clearWatch()); + } + + /** + * Run MapLibre's cleanup (stops the watch, removes the dot, resets its global watch counter), + * but don't let it remove our Angular-owned container + */ + override onRemove() { + if (!this._map) return; + const container = this._container; + this._container = document.createElement('div'); + withGeolocation(this.geolocation, () => super.onRemove()); + this._container = container; + } } @Component({ @@ -58,6 +148,8 @@ class CustomGeolocateControl extends GeolocateControl { export class GeolocateControlComponent implements AfterContentInit, OnDestroy { private mapService = inject(MapService); + private positionService = inject(PositionService); + @Input() position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; @Input() positionOptions?: PositionOptions; @@ -75,12 +167,17 @@ export class GeolocateControlComponent implements AfterContentInit, OnDestroy { control: CustomGeolocateControl; ngAfterContentInit() { - this.control = new CustomGeolocateControl(this.content.nativeElement, this.watchState, { - positionOptions: this.positionOptions, - fitBoundsOptions: this.fitBoundsOptions, - trackUserLocation: this.trackUserLocation, - showUserLocation: this.showUserLocation, - }); + this.control = new CustomGeolocateControl( + this.content.nativeElement, + this.watchState, + { + positionOptions: this.positionOptions, + fitBoundsOptions: this.fitBoundsOptions, + trackUserLocation: this.trackUserLocation, + showUserLocation: this.showUserLocation, + }, + new PositionServiceGeolocation(this.positionService), + ); this.mapService.mapCreated$.subscribe(() => { this.mapService.addControl(this.control, this.position); }); diff --git a/frontend/app/src/app/modules/map/position.service.ts b/frontend/app/src/app/modules/map/position.service.ts index 418145f2..a307a73a 100644 --- a/frontend/app/src/app/modules/map/position.service.ts +++ b/frontend/app/src/app/modules/map/position.service.ts @@ -14,8 +14,92 @@ */ import {Injectable} from '@angular/core'; import {Point} from 'geojson'; -import {Observable} from 'rxjs'; -import {Geolocation, Position} from '@capacitor/geolocation'; +import { + BehaviorSubject, + catchError, + defer, + distinctUntilChanged, + finalize, + first, + from, + map, + merge, + Observable, + of, + ReplaySubject, + retry, + share, + Subject, + switchMap, + timer, +} from 'rxjs'; +import {Geolocation, PermissionStatus, Position} from '@capacitor/geolocation'; +import {App} from '@capacitor/app'; +import {Capacitor, PermissionState} from '@capacitor/core'; + +/** + * Coarse (network / Wi-Fi) location: enough for distances in lists, fast indoors, saves battery + */ +const COARSE_OPTIONS: PositionOptions = {enableHighAccuracy: false, maximumAge: 30_000, timeout: 30_000}; + +/** + * Precise (GPS) location: for following the user on the map + */ +const PRECISE_OPTIONS: PositionOptions = {enableHighAccuracy: true, maximumAge: 0, timeout: 30_000}; + +/** + * Permission states as defined by Capacitor (`PermissionState` is a type only, it has no runtime constants) + */ +export const PERMISSION_GRANTED = 'granted' satisfies PermissionState; +export const PERMISSION_DENIED = 'denied' satisfies PermissionState; + +/** + * Location may be used. On Android, `location` is only granted for *precise* location; + * if the user chose "approximate", only `coarseLocation` is granted, which is enough for us + * (Android then just ignores enableHighAccuracy). + */ +function isGranted(status: PermissionStatus): boolean { + return status.location === PERMISSION_GRANTED || status.coarseLocation === PERMISSION_GRANTED; +} + +/** + * The user refused both precise and approximate location + */ +function isDenied(status: PermissionStatus): boolean { + return status.location === PERMISSION_DENIED && status.coarseLocation === PERMISSION_DENIED; +} + +/** + * Emits when the app comes back to the foreground (e.g. from the system settings) + */ +const appResumed$ = new Observable(subscriber => { + const listener = App.addListener('resume', () => subscriber.next()); + return () => void listener.then(handle => handle.remove()); +}); + +/** + * Wraps a Capacitor position watch in an Observable. + * Passes the real error on, and only clears the watch if it is still alive + * (the plugin removes a watch by itself after an error). + */ +function nativeWatch(options: PositionOptions): Observable { + return new Observable(subscriber => { + let active = true; + const watcherID = Geolocation.watchPosition(options, (position, error) => { + if (error) { + active = false; + subscriber.error(error); + } else if (position) { + subscriber.next(position); + } + }); + return () => { + const wasActive = active; + active = false; + void watcherID.then(id => (wasActive ? Geolocation.clearWatch({id}) : undefined)).catch(() => {}); + }; + }); +} export interface Coordinates { /** @@ -39,29 +123,87 @@ export interface MapPosition extends Coordinates { providedIn: 'root', }) export class PositionService { - geoLocation = new Observable(subscriber => { - const watcherID = Geolocation.checkPermissions().then(permissions => { - if (permissions.location === 'granted') { - return Geolocation.watchPosition({}, (position, error) => { - if (error) { - subscriber.error(position); - } else if (position) { - subscriber.next(position); - } - }); - } - return; - }); - return { - unsubscribe() { - watcherID.then(id => { - if (id) { - Geolocation.clearWatch({id}); - } - }); - }, - }; - }); + /** + * How many subscribers currently need precise (GPS) location, see preciseLocation + */ + private readonly preciseSubscribers$ = new BehaviorSubject(0); + + /** + * Emits after requestPermission() showed the permission prompt + */ + private readonly permissionRequested$ = new Subject(); + + /** + * When to (re-)check whether location permission has been granted. + * Web: every 2 s, browsers don't tell us when the user clicks "Allow". + * Device: permission only changes via our prompt or the system settings (and coming back + * from there resumes the app), so no polling is needed. + */ + private readonly permissionChecks$: Observable = Capacitor.isNativePlatform() + ? merge(of(true), this.permissionRequested$, appResumed$) + : timer(0, 2000); + + /** + * The device position, shared by everything that needs it (list distances, map, ...). + * + * - Only one native watch, however many subscribers (before: one per list item) + * - Waits until location permission is granted, also if that happens later (e.g. via the prompt) + * - New subscribers immediately get the last position + * - Keeps running for 30 s without subscribers, so a list re-render doesn't restart it + * - Retries after errors (e.g. timeouts indoors) instead of breaking all subscribers + * - Coarse by default; switches the same watch to GPS while someone uses preciseLocation + */ + readonly geoLocation: Observable = defer(() => + this.permissionChecks$.pipe( + switchMap(() => + from(Geolocation.checkPermissions()).pipe( + // no Permissions API (some browsers): just start, watchPosition prompts / fails by itself + catchError(() => + of({location: PERMISSION_GRANTED, coarseLocation: PERMISSION_GRANTED}), + ), + ), + ), + first(isGranted), + // restart the one watch with other options whenever the need for precision changes + switchMap(() => + this.preciseSubscribers$.pipe( + map(count => count > 0), + distinctUntilChanged(), + switchMap(precise => nativeWatch(precise ? PRECISE_OPTIONS : COARSE_OPTIONS)), + ), + ), + retry({delay: () => timer(10_000)}), + ), + ).pipe( + share({ + connector: () => new ReplaySubject(1), + resetOnRefCountZero: () => timer(30_000), + }), + ); + + /** + * Same positions as geoLocation, but while subscribed, the shared watch uses GPS. + * For actively following the user (map); falls back to coarse when the last one unsubscribes. + */ + readonly preciseLocation: Observable = defer(() => { + this.preciseSubscribers$.next(this.preciseSubscribers$.value + 1); + return this.geoLocation; + }).pipe(finalize(() => this.preciseSubscribers$.next(this.preciseSubscribers$.value - 1))); + + /** + * Shows the permission prompt if permission hasn't been decided yet + * @returns whether location may be used + */ + async requestPermission(): Promise { + const status = await Geolocation.checkPermissions().catch(() => {}); + if (status && isGranted(status)) return PERMISSION_GRANTED; + if (status && isDenied(status)) return PERMISSION_DENIED; + // getCurrentPosition shows the prompt on web, Android and iOS + await Geolocation.getCurrentPosition(COARSE_OPTIONS).catch(() => {}); + this.permissionRequested$.next(); + const after = await Geolocation.checkPermissions().catch(() => {}); + return after && isDenied(after) ? PERMISSION_DENIED : PERMISSION_GRANTED; + } /** * Current position @@ -108,29 +250,17 @@ export class PositionService { * @param options Options which define which data should be provided (e.g., how accurate or how old) */ watchCurrentLocation(options: PositionOptions = {}): Observable { - return new Observable(subscriber => { - const watcherID = Geolocation.watchPosition(options, (position, error) => { - if (error) { - subscriber.error(position); - } else { - this.position = { - // TODO use native compass heading instead - // waiting for https://github.com/ionic-team/capacitor-plugins/issues/1192 - heading: undefined, - latitude: position?.coords.latitude ?? 0, - longitude: position?.coords.longitude ?? 0, // TODO: handle null position - }; - - subscriber.next(this.position); - } - }); - return { - unsubscribe() { - watcherID.then(id => { - void Geolocation.clearWatch({id}); - }); - }, - }; - }); + return nativeWatch(options).pipe( + map(position => { + this.position = { + // TODO use native compass heading instead + // waiting for https://github.com/ionic-team/capacitor-plugins/issues/1192 + heading: undefined, + latitude: position.coords.latitude, + longitude: position.coords.longitude, + }; + return this.position; + }), + ); } }