1import FontAwesome from '@expo/vector-icons/build/FontAwesome';
2import MaterialIcons from '@expo/vector-icons/build/MaterialIcons';
3import AsyncStorage from '@react-native-async-storage/async-storage';
4import { useFocusEffect } from '@react-navigation/native';
5import { StackNavigationProp } from '@react-navigation/stack';
6import * as Location from 'expo-location';
7import * as TaskManager from 'expo-task-manager';
8import { EventEmitter, EventSubscription } from 'fbemitter';
9import * as React from 'react';
10import { Modal, Platform, StyleSheet, Text, View } from 'react-native';
11import MapView from 'react-native-maps';
12
13import Button from '../../components/Button';
14import Colors from '../../constants/Colors';
15import usePermissions from '../../utilities/usePermissions';
16
17const STORAGE_KEY = 'expo-home-locations';
18const LOCATION_UPDATES_TASK = 'location-updates';
19
20const locationEventsEmitter = new EventEmitter();
21
22const locationAccuracyStates: { [key in Location.Accuracy]: Location.Accuracy } = {
23  [Location.Accuracy.Lowest]: Location.Accuracy.Low,
24  [Location.Accuracy.Low]: Location.Accuracy.Balanced,
25  [Location.Accuracy.Balanced]: Location.Accuracy.High,
26  [Location.Accuracy.High]: Location.Accuracy.Highest,
27  [Location.Accuracy.Highest]: Location.Accuracy.BestForNavigation,
28  [Location.Accuracy.BestForNavigation]: Location.Accuracy.Lowest,
29};
30
31const locationActivityTypes: {
32  [key in Location.ActivityType]: Location.ActivityType | undefined;
33} = {
34  [Location.ActivityType.Other]: Location.ActivityType.AutomotiveNavigation,
35  [Location.ActivityType.AutomotiveNavigation]: Location.ActivityType.Fitness,
36  [Location.ActivityType.Fitness]: Location.ActivityType.OtherNavigation,
37  [Location.ActivityType.OtherNavigation]: Location.ActivityType.Airborne,
38  [Location.ActivityType.Airborne]: undefined,
39};
40
41interface Props {
42  navigation: StackNavigationProp<any>;
43}
44
45type Region = {
46  latitude: number;
47  longitude: number;
48  latitudeDelta: number;
49  longitudeDelta: number;
50};
51
52type State = Pick<Location.LocationTaskOptions, 'showsBackgroundLocationIndicator'> & {
53  activityType: Location.ActivityType | null;
54  accuracy: Location.Accuracy;
55  isTracking: boolean;
56  savedLocations: any[];
57  initialRegion: Region | null;
58};
59
60const initialState: State = {
61  isTracking: false,
62  savedLocations: [],
63  activityType: null,
64  accuracy: Location.Accuracy.High,
65  initialRegion: null,
66  showsBackgroundLocationIndicator: false,
67};
68
69function reducer(state: State, action: Partial<State>): State {
70  return {
71    ...state,
72    ...action,
73  };
74}
75
76export default function BackgroundLocationMapScreen(props: Props) {
77  const [permission] = usePermissions(Location.requestForegroundPermissionsAsync);
78
79  React.useEffect(() => {
80    (async () => {
81      if (!(await Location.isBackgroundLocationAvailableAsync())) {
82        alert('Background location is not available in this application.');
83        props.navigation.goBack();
84      }
85    })();
86  }, [props.navigation]);
87
88  if (!permission) {
89    return (
90      <Text style={styles.errorText}>
91        Location permissions are required in order to use this feature. You can manually enable them
92        at any time in the "Location Services" section of the Settings app.
93      </Text>
94    );
95  }
96
97  return <BackgroundLocationMapView />;
98}
99
100function BackgroundLocationMapView() {
101  const mapViewRef = React.useRef<MapView>(null);
102  const [state, dispatch] = React.useReducer(reducer, initialState);
103
104  const onFocus = React.useCallback(() => {
105    let subscription: EventSubscription | null = null;
106    let isMounted = true;
107    (async () => {
108      if ((await Location.getBackgroundPermissionsAsync()).status !== 'granted') {
109        console.log(
110          'Missing background location permissions. Make sure it is granted in the OS Settings.'
111        );
112        return;
113      }
114      const { coords } = await Location.getCurrentPositionAsync();
115      const isTracking = await Location.hasStartedLocationUpdatesAsync(LOCATION_UPDATES_TASK);
116      const task = (await TaskManager.getRegisteredTasksAsync()).find(
117        ({ taskName }) => taskName === LOCATION_UPDATES_TASK
118      );
119      const savedLocations = await getSavedLocations();
120
121      subscription = locationEventsEmitter.addListener('update', (savedLocations: any) => {
122        if (isMounted) dispatch({ savedLocations });
123      });
124
125      if (!isTracking) {
126        alert('Click `Start tracking` to start getting location updates.');
127      }
128
129      if (!isMounted) return;
130
131      dispatch({
132        isTracking,
133        accuracy: task?.options.accuracy ?? state.accuracy,
134        showsBackgroundLocationIndicator: task?.options.showsBackgroundLocationIndicator,
135        activityType: task?.options.activityType ?? null,
136        savedLocations,
137        initialRegion: {
138          latitude: coords.latitude,
139          longitude: coords.longitude,
140          latitudeDelta: 0.004,
141          longitudeDelta: 0.002,
142        },
143      });
144    })();
145
146    return () => {
147      isMounted = false;
148      if (subscription) {
149        subscription.remove();
150      }
151    };
152  }, [state.accuracy, state.isTracking]);
153
154  useFocusEffect(onFocus);
155
156  const startLocationUpdates = React.useCallback(
157    async (acc = state.accuracy) => {
158      if ((await Location.getBackgroundPermissionsAsync()).status !== 'granted') {
159        console.log(
160          'Missing background location permissions. Make sure it is granted in the OS Settings.'
161        );
162        return;
163      }
164      await Location.startLocationUpdatesAsync(LOCATION_UPDATES_TASK, {
165        accuracy: acc,
166        activityType: state.activityType ?? undefined,
167        pausesUpdatesAutomatically: state.activityType != null,
168        showsBackgroundLocationIndicator: state.showsBackgroundLocationIndicator,
169        deferredUpdatesInterval: 60 * 1000, // 1 minute
170        deferredUpdatesDistance: 100, // 100 meters
171        foregroundService: {
172          notificationTitle: 'expo-location-demo',
173          notificationBody: 'Background location is running...',
174          notificationColor: Colors.tintColor,
175        },
176      });
177
178      if (!state.isTracking) {
179        alert(
180          'Now you can send app to the background, go somewhere and come back here! You can even terminate the app and it will be woken up when the new significant location change comes out.'
181        );
182      }
183      dispatch({
184        isTracking: true,
185      });
186    },
187    [state.isTracking, state.accuracy, state.activityType, state.showsBackgroundLocationIndicator]
188  );
189
190  const stopLocationUpdates = React.useCallback(async () => {
191    await Location.stopLocationUpdatesAsync(LOCATION_UPDATES_TASK);
192    dispatch({
193      isTracking: false,
194    });
195  }, []);
196
197  const clearLocations = React.useCallback(async () => {
198    await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify([]));
199    dispatch({
200      savedLocations: [],
201    });
202  }, []);
203
204  const toggleTracking = React.useCallback(async () => {
205    await AsyncStorage.removeItem(STORAGE_KEY);
206
207    if (state.isTracking) {
208      await stopLocationUpdates();
209    } else {
210      await startLocationUpdates();
211    }
212    dispatch({
213      savedLocations: [],
214    });
215  }, [state.isTracking, startLocationUpdates, stopLocationUpdates]);
216
217  const onAccuracyChange = React.useCallback(() => {
218    const currentAccuracy = locationAccuracyStates[state.accuracy];
219
220    dispatch({
221      accuracy: currentAccuracy,
222    });
223
224    if (state.isTracking) {
225      // Restart background task with the new accuracy.
226      startLocationUpdates(currentAccuracy);
227    }
228  }, [state.accuracy, state.isTracking, startLocationUpdates]);
229
230  const toggleLocationIndicator = React.useCallback(() => {
231    dispatch({
232      showsBackgroundLocationIndicator: !state.showsBackgroundLocationIndicator,
233    });
234    if (state.isTracking) {
235      startLocationUpdates();
236    }
237  }, [state.showsBackgroundLocationIndicator, state.isTracking, startLocationUpdates]);
238
239  const toggleActivityType = React.useCallback(() => {
240    let nextActivityType: Location.ActivityType | null;
241    if (state.activityType) {
242      nextActivityType = locationActivityTypes[state.activityType] ?? null;
243    } else {
244      nextActivityType = Location.ActivityType.Other;
245    }
246    dispatch({
247      activityType: nextActivityType,
248    });
249
250    if (state.isTracking) {
251      // Restart background task with the new activity type
252      startLocationUpdates();
253    }
254  }, [state.activityType, state.isTracking, startLocationUpdates]);
255
256  const onCenterMap = React.useCallback(async () => {
257    const { coords } = await Location.getCurrentPositionAsync();
258    const mapView = mapViewRef.current;
259
260    if (mapView) {
261      mapView.animateToRegion({
262        latitude: coords.latitude,
263        longitude: coords.longitude,
264        latitudeDelta: 0.004,
265        longitudeDelta: 0.002,
266      });
267    }
268  }, []);
269
270  const renderPolyline = React.useCallback(() => {
271    if (state.savedLocations.length === 0) {
272      return null;
273    }
274    return (
275      // @ts-ignore
276      <MapView.Polyline
277        coordinates={state.savedLocations}
278        strokeWidth={3}
279        strokeColor={Colors.tintColor}
280      />
281    );
282  }, [state.savedLocations]);
283
284  return (
285    <View style={styles.screen}>
286      <PermissionsModal />
287      <MapView
288        ref={mapViewRef}
289        style={styles.mapView}
290        initialRegion={state.initialRegion ?? undefined}
291        showsUserLocation>
292        {renderPolyline()}
293      </MapView>
294      <View style={styles.buttons} pointerEvents="box-none">
295        <View style={styles.topButtons}>
296          <View style={styles.buttonsColumn}>
297            {Platform.OS === 'android' ? null : (
298              <Button style={styles.button} onPress={toggleLocationIndicator}>
299                <View style={styles.buttonContentWrapper}>
300                  <Text style={styles.text}>
301                    {state.showsBackgroundLocationIndicator ? 'Hide' : 'Show'}
302                  </Text>
303                  <Text style={styles.text}> background </Text>
304                  <FontAwesome name="location-arrow" size={20} color="white" />
305                  <Text style={styles.text}> indicator</Text>
306                </View>
307              </Button>
308            )}
309            {Platform.OS === 'android' ? null : (
310              <Button
311                style={styles.button}
312                onPress={toggleActivityType}
313                title={
314                  state.activityType
315                    ? `Activity type: ${Location.ActivityType[state.activityType]}`
316                    : 'No activity type'
317                }
318              />
319            )}
320            <Button
321              title={`Accuracy: ${Location.Accuracy[state.accuracy]}`}
322              style={styles.button}
323              onPress={onAccuracyChange}
324            />
325          </View>
326          <View style={styles.buttonsColumn}>
327            <Button style={styles.button} onPress={onCenterMap}>
328              <MaterialIcons name="my-location" size={20} color="white" />
329            </Button>
330          </View>
331        </View>
332
333        <View style={styles.bottomButtons}>
334          <Button title="Clear locations" style={styles.button} onPress={clearLocations} />
335          <Button
336            title={state.isTracking ? 'Stop tracking' : 'Start tracking'}
337            style={styles.button}
338            onPress={toggleTracking}
339          />
340        </View>
341      </View>
342    </View>
343  );
344}
345
346const PermissionsModal = () => {
347  const [showPermissionsModal, setShowPermissionsModal] = React.useState(true);
348  const [permission] = usePermissions(Location.getBackgroundPermissionsAsync);
349
350  return (
351    <Modal
352      animationType="slide"
353      transparent={false}
354      visible={!permission && showPermissionsModal}
355      onRequestClose={() => {
356        setShowPermissionsModal(!showPermissionsModal);
357      }}>
358      <View style={{ flex: 1, justifyContent: 'space-around', alignItems: 'center' }}>
359        <View style={{ flex: 2, justifyContent: 'center', alignItems: 'center' }}>
360          <Text style={styles.modalHeader}>Background location access</Text>
361
362          <Text style={styles.modalText}>
363            This app collects location data to enable updating the MapView in the background even
364            when the app is closed or not in use. Otherwise, your location on the map will only be
365            updated while the app is foregrounded.
366          </Text>
367          <Text style={styles.modalText}>
368            This data is not used for anything other than updating the position on the map, and this
369            data is never shared with anyone.
370          </Text>
371        </View>
372        <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
373          <Button
374            title="Request background location permission"
375            style={styles.button}
376            onPress={async () => {
377              // Need both background and foreground permissions
378              await Location.requestForegroundPermissionsAsync();
379              await Location.requestBackgroundPermissionsAsync();
380              setShowPermissionsModal(!showPermissionsModal);
381            }}
382          />
383          <Button
384            title="Continue without background location permission"
385            style={styles.button}
386            onPress={() => setShowPermissionsModal(!showPermissionsModal)}
387          />
388        </View>
389      </View>
390    </Modal>
391  );
392};
393
394BackgroundLocationMapScreen.navigationOptions = {
395  title: 'Background location',
396};
397
398async function getSavedLocations() {
399  try {
400    const item = await AsyncStorage.getItem(STORAGE_KEY);
401    return item ? JSON.parse(item) : [];
402  } catch {
403    return [];
404  }
405}
406
407TaskManager.defineTask(LOCATION_UPDATES_TASK, async ({ data: { locations } }: any) => {
408  if (locations && locations.length > 0) {
409    const savedLocations = await getSavedLocations();
410    const newLocations = locations.map(({ coords }: any) => ({
411      latitude: coords.latitude,
412      longitude: coords.longitude,
413    }));
414
415    console.log(`Received new locations at ${new Date()}:`, locations);
416
417    savedLocations.push(...newLocations);
418    await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(savedLocations));
419
420    locationEventsEmitter.emit('update', savedLocations);
421  }
422});
423
424const styles = StyleSheet.create({
425  screen: {
426    flex: 1,
427  },
428  mapView: {
429    flex: 1,
430  },
431  buttons: {
432    flex: 1,
433    flexDirection: 'column',
434    justifyContent: 'space-between',
435    padding: 10,
436    position: 'absolute',
437    top: 0,
438    right: 0,
439    bottom: 0,
440    left: 0,
441  },
442  topButtons: {
443    flexDirection: 'row',
444    justifyContent: 'space-between',
445  },
446  bottomButtons: {
447    flexDirection: 'column',
448    alignItems: 'flex-end',
449  },
450  buttonsColumn: {
451    flexDirection: 'column',
452    alignItems: 'flex-start',
453  },
454  button: {
455    paddingVertical: 5,
456    paddingHorizontal: 10,
457    marginVertical: 5,
458  },
459  buttonContentWrapper: {
460    flexDirection: 'row',
461  },
462  text: {
463    color: 'white',
464    fontWeight: '700',
465  },
466  errorText: {
467    fontSize: 15,
468    color: 'rgba(0,0,0,0.7)',
469    margin: 20,
470  },
471  modalHeader: { padding: 12, fontSize: 20, fontWeight: '800' },
472  modalText: { padding: 8, fontWeight: '600' },
473});
474