From 4e6d360a7b58ec1632eb7e191fc7a2907edf301c Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 3 Dec 2024 22:54:04 +0000 Subject: [PATCH] feat: update draft observation location --- .../contexts/DraftObservationContext.tsx | 138 +++++++++++++++++- .../usePersistedDraftObservationNew.ts | 60 +++++--- 2 files changed, 173 insertions(+), 25 deletions(-) diff --git a/src/frontend/contexts/DraftObservationContext.tsx b/src/frontend/contexts/DraftObservationContext.tsx index 5629a425e..5e4c6ad4d 100644 --- a/src/frontend/contexts/DraftObservationContext.tsx +++ b/src/frontend/contexts/DraftObservationContext.tsx @@ -1,18 +1,28 @@ +import * as Location from 'expo-location'; +import {AppState, type AppStateStatus} from 'react-native'; +import CheapRuler from 'cheap-ruler'; import React, { PropsWithChildren, createContext, useContext, useState, } from 'react'; - -import {createDraftObservationStore} from '../hooks/persistedState/usePersistedDraftObservationNew'; +import { + convertPosition, + createDraftObservationStore, + type DraftState, +} from '../hooks/persistedState/usePersistedDraftObservationNew'; const DraftObservationContext = createContext | null>(null); export function DraftObservationProvider({children}: PropsWithChildren<{}>) { - const [value] = useState(() => createDraftObservationStore()); + const [value] = useState(() => { + const store = createDraftObservationStore(); + createDraftObservationLocationUpdator(store); + return store; + }); return ( @@ -30,3 +40,125 @@ export function useDraftObservationContext() { return result; } + +const LOCATION_OPTIONS: Location.LocationOptions = { + accuracy: Location.Accuracy.Highest, + timeInterval: 1000, +}; +/** We don't update the position of an observation with the location from the + * device location provider if the location is older than this threshold */ +const STALE_LOCATION_THRESHOLD_MS = 1000; +/** Over this threshold we consider the user to have moved away from the + * location of the observation, and we stop refining GPS position. We use the + * accuracy of the first location as the default, and use this as a fallback if + * we do not have an accuracy value. */ +const MOVED_AWAY_THRESHOLD_METERS = 100; +/** The factor of accuracy that a new location must be to consider the user to + * have moved away. E.g. if the accuracy of the initial position is 10m and the + * factor is 1.5, then we consider the user to have moved away if a location + * update is 15m away. */ +const ACCURACY_MOVED_AWAY_FACTOR = 1.5; + +export async function createDraftObservationLocationUpdator({ + store, + actions: {updatePosition}, +}: ReturnType) { + let {granted: locationPermissionGranted} = + await Location.getForegroundPermissionsAsync(); + let locationSubscriptionPromise: Promise | null = + null; + let appState: AppStateStatus = AppState.currentState; + let isNewlyCreatedDraftInStore = isNewlyCreatedDraft(store.getState()); + + AppState.addEventListener('change', async nextAppState => { + appState = nextAppState; + if (appState === 'active' && !locationPermissionGranted) { + locationPermissionGranted = ( + await Location.getForegroundPermissionsAsync() + ).granted; + } + watchPositionIfNeeded(); + }); + + store.subscribe(storeState => { + isNewlyCreatedDraftInStore = isNewlyCreatedDraft(storeState); + watchPositionIfNeeded(); + }); + + async function watchPositionIfNeeded() { + const shouldBeWatchingPosition = + appState === 'active' && + isNewlyCreatedDraftInStore && + locationPermissionGranted; + + if (shouldBeWatchingPosition && !locationSubscriptionPromise) { + locationSubscriptionPromise = Location.watchPositionAsync( + LOCATION_OPTIONS, + onPositionUpdate, + ); + } else if (!shouldBeWatchingPosition && locationSubscriptionPromise) { + // Avoid a race condition by nulling the state before awaiting the promise + const locationSubscriptionPromiseCopy = locationSubscriptionPromise; + locationSubscriptionPromise = null; + const subscription = await locationSubscriptionPromiseCopy; + subscription.remove(); + } + } + + function onPositionUpdate(location: Location.LocationObject) { + const {value: currentDraft, initialPosition} = store.getState(); + if (!currentDraft) return; + + const isManualLocation = !!currentDraft.metadata?.manualLocation; + if (isManualLocation) return; + + const isStale = + Date.now() - location.timestamp > STALE_LOCATION_THRESHOLD_MS; + if (isStale) return; + + store.setState(prev => { + if (prev.initialPosition || !prev.value) return prev; + return {...prev, initialPosition: convertPosition(location)}; + }); + + const initialAccuracy = initialPosition?.coords.accuracy; + const movedAwayThreshold = initialAccuracy + ? initialAccuracy * ACCURACY_MOVED_AWAY_FACTOR + : MOVED_AWAY_THRESHOLD_METERS; + const hasMovedAway = + initialPosition && + distanceBetweenCoords(initialPosition.coords, location.coords) > + movedAwayThreshold; + if (hasMovedAway) return; + + const currentDraftCoords = currentDraft.metadata?.position?.coords; + + const isMoreAccurate = + !currentDraftCoords || + !location.coords.accuracy || + !currentDraftCoords.accuracy || + location.coords.accuracy < currentDraftCoords.accuracy; + + if (!isMoreAccurate) return; + + updatePosition({ + manualLocation: false, + position: location, + // TODO: Also add positionProvider. Probably needs access to the locationProvider store. + }); + } +} + +function isNewlyCreatedDraft(storeState: DraftState) { + const isObservationInStore = !!storeState.value; + const isNewlyCreatedObservation = !storeState.id; + return isObservationInStore && isNewlyCreatedObservation; +} + +function distanceBetweenCoords( + a: {latitude: number; longitude: number}, + b: {latitude: number; longitude: number}, +) { + const ruler = new CheapRuler(a.latitude); + return ruler.distance([a.longitude, a.latitude], [b.longitude, b.latitude]); +} diff --git a/src/frontend/hooks/persistedState/usePersistedDraftObservationNew.ts b/src/frontend/hooks/persistedState/usePersistedDraftObservationNew.ts index ed728f4f3..e0c162b1f 100644 --- a/src/frontend/hooks/persistedState/usePersistedDraftObservationNew.ts +++ b/src/frontend/hooks/persistedState/usePersistedDraftObservationNew.ts @@ -69,6 +69,7 @@ type DraftStateEmpty = { value: null; id: null; unsavedAttachments: null; + initialPosition: null; }; type ObservationWithPreset = Exclude & { @@ -83,6 +84,9 @@ export type DraftStatePopulated = { value: ObservationValueWithPreset; id: {docId: string; versionId: string} | null; unsavedAttachments: Map; + /** Initial (first) position of an observation. Not currently persisted, but + * used for checking if the user moves away from the original location */ + initialPosition: Position | null; }; export type DraftState = DraftStateEmpty | DraftStatePopulated; @@ -93,6 +97,15 @@ const THUMBNAIL_COMPRESSION = 0.3; const PREVIEW_SIZE = 1200; const PREVIEW_COMPRESSION = 0.3; +function createEmptyStoreState(): DraftStateEmpty { + return { + value: null, + id: null, + unsavedAttachments: null, + initialPosition: null, + }; +} + function createEmptyObservationValue(): ObservationValueWithPreset { return { schemaName: 'observation', @@ -126,11 +139,7 @@ export function createDraftObservationStore() { let nextAttachmentId = 0; const store = createPersistedStore( - () => ({ - value: null, - id: null, - unsavedAttachments: null, - }), + () => createEmptyStoreState(), '@MapeoDraft', ); @@ -298,26 +307,30 @@ export function createDraftObservationStore() { } function clearDraft() { - store.setState({ - value: null, - id: null, - unsavedAttachments: null, - }); + store.setState(createEmptyStoreState(), true); } function createDraft(observation?: ObservationWithPreset) { if (observation) { - store.setState({ - value: valueOf(observation), - id: {docId: observation.docId, versionId: observation.versionId}, - unsavedAttachments: new Map(), - }); + store.setState( + { + value: valueOf(observation), + id: {docId: observation.docId, versionId: observation.versionId}, + unsavedAttachments: new Map(), + initialPosition: null, + }, + true, + ); } else { - store.setState({ - value: createEmptyObservationValue(), - id: null, - unsavedAttachments: new Map(), - }); + store.setState( + { + value: createEmptyObservationValue(), + id: null, + unsavedAttachments: new Map(), + initialPosition: null, + }, + true, + ); } } @@ -326,7 +339,8 @@ export function createDraftObservationStore() { | { manualLocation: false; position: LocationObject; - positionProvider: LocationProviderStatus; + // TODO: Optional for now until we integrate this into the draft observation location updater + positionProvider?: LocationProviderStatus; } | { manualLocation: true; @@ -461,7 +475,9 @@ function getPhotoRotation(acc?: AccelerometerMeasurement) { return rotation; } -function convertPosition(location: LocationObject | ManualPosition): Position { +export function convertPosition( + location: LocationObject | ManualPosition, +): Position { const {coords} = location; return { coords: {