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          // tslint:disable-next-line max-line-length
181          '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.'
182        );
183      }
184      dispatch({
185        isTracking: true,
186      });
187    },
188    [state.isTracking, state.accuracy, state.activityType, state.showsBackgroundLocationIndicator]
189  );
190
191  const stopLocationUpdates = React.useCallback(async () => {
192    await Location.stopLocationUpdatesAsync(LOCATION_UPDATES_TASK);
193    dispatch({
194      isTracking: false,
195    });
196  }, []);
197
198  const clearLocations = React.useCallback(async () => {
199    await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify([]));
200    dispatch({
201      savedLocations: [],
202    });
203  }, []);
204
205  const toggleTracking = React.useCallback(async () => {
206    await AsyncStorage.removeItem(STORAGE_KEY);
207
208    if (state.isTracking) {
209      await stopLocationUpdates();
210    } else {
211      await startLocationUpdates();
212    }
213    dispatch({
214      savedLocations: [],
215    });
216  }, [state.isTracking, startLocationUpdates, stopLocationUpdates]);
217
218  const onAccuracyChange = React.useCallback(() => {
219    const currentAccuracy = locationAccuracyStates[state.accuracy];
220
221    dispatch({
222      accuracy: currentAccuracy,
223    });
224
225    if (state.isTracking) {
226      // Restart background task with the new accuracy.
227      startLocationUpdates(currentAccuracy);
228    }
229  }, [state.accuracy, state.isTracking, startLocationUpdates]);
230
231  const toggleLocationIndicator = React.useCallback(() => {
232    dispatch({
233      showsBackgroundLocationIndicator: !state.showsBackgroundLocationIndicator,
234    });
235    if (state.isTracking) {
236      startLocationUpdates();
237    }
238  }, [state.showsBackgroundLocationIndicator, state.isTracking, startLocationUpdates]);
239
240  const toggleActivityType = React.useCallback(() => {
241    let nextActivityType: Location.ActivityType | null;
242    if (state.activityType) {
243      nextActivityType = locationActivityTypes[state.activityType] ?? null;
244    } else {
245      nextActivityType = Location.ActivityType.Other;
246    }
247    dispatch({
248      activityType: nextActivityType,
249    });
250
251    if (state.isTracking) {
252      // Restart background task with the new activity type
253      startLocationUpdates();
254    }
255  }, [state.activityType, state.isTracking, startLocationUpdates]);
256
257  const onCenterMap = React.useCallback(async () => {
258    const { coords } = await Location.getCurrentPositionAsync();
259    const mapView = mapViewRef.current;
260
261    if (mapView) {
262      mapView.animateToRegion({
263        latitude: coords.latitude,
264        longitude: coords.longitude,
265        latitudeDelta: 0.004,
266        longitudeDelta: 0.002,
267      });
268    }
269  }, []);
270
271  const renderPolyline = React.useCallback(() => {
272    if (state.savedLocations.length === 0) {
273      return null;
274    }
275    return (
276      // @ts-ignore
277      <MapView.Polyline
278        coordinates={state.savedLocations}
279        strokeWidth={3}
280        strokeColor={Colors.tintColor}
281      />
282    );
283  }, [state.savedLocations]);
284
285  return (
286    <View style={styles.screen}>
287      <PermissionsModal />
288      <MapView
289        ref={mapViewRef}
290        style={styles.mapView}
291        initialRegion={state.initialRegion ?? undefined}
292        showsUserLocation>
293        {renderPolyline()}
294      </MapView>
295      <View style={styles.buttons} pointerEvents="box-none">
296        <View style={styles.topButtons}>
297          <View style={styles.buttonsColumn}>
298            {Platform.OS === 'android' ? null : (
299              <Button style={styles.button} onPress={toggleLocationIndicator}>
300                <View style={styles.buttonContentWrapper}>
301                  <Text style={styles.text}>
302                    {state.showsBackgroundLocationIndicator ? 'Hide' : 'Show'}
303                  </Text>
304                  <Text style={styles.text}> background </Text>
305                  <FontAwesome name="location-arrow" size={20} color="white" />
306                  <Text style={styles.text}> indicator</Text>
307                </View>
308              </Button>
309            )}
310            {Platform.OS === 'android' ? null : (
311              <Button
312                style={styles.button}
313                onPress={toggleActivityType}
314                title={
315                  state.activityType
316                    ? `Activity type: ${Location.ActivityType[state.activityType]}`
317                    : 'No activity type'
318                }
319              />
320            )}
321            <Button
322              title={`Accuracy: ${Location.Accuracy[state.accuracy]}`}
323              style={styles.button}
324              onPress={onAccuracyChange}
325            />
326          </View>
327          <View style={styles.buttonsColumn}>
328            <Button style={styles.button} onPress={onCenterMap}>
329              <MaterialIcons name="my-location" size={20} color="white" />
330            </Button>
331          </View>
332        </View>
333
334        <View style={styles.bottomButtons}>
335          <Button title="Clear locations" style={styles.button} onPress={clearLocations} />
336          <Button
337            title={state.isTracking ? 'Stop tracking' : 'Start tracking'}
338            style={styles.button}
339            onPress={toggleTracking}
340          />
341        </View>
342      </View>
343    </View>
344  );
345}
346
347const PermissionsModal = () => {
348  const [showPermissionsModal, setShowPermissionsModal] = React.useState(true);
349  const [permission] = usePermissions(Location.getBackgroundPermissionsAsync);
350
351  return (
352    <Modal
353      animationType="slide"
354      transparent={false}
355      visible={!permission && showPermissionsModal}
356      onRequestClose={() => {
357        setShowPermissionsModal(!showPermissionsModal);
358      }}>
359      <View style={{ flex: 1, justifyContent: 'space-around', alignItems: 'center' }}>
360        <View style={{ flex: 2, justifyContent: 'center', alignItems: 'center' }}>
361          <Text style={styles.modalHeader}>Background location access</Text>
362
363          <Text style={styles.modalText}>
364            This app collects location data to enable updating the MapView in the background even
365            when the app is closed or not in use. Otherwise, your location on the map will only be
366            updated while the app is foregrounded.
367          </Text>
368          <Text style={styles.modalText}>
369            This data is not used for anything other than updating the position on the map, and this
370            data is never shared with anyone.
371          </Text>
372        </View>
373        <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
374          <Button
375            title="Request background location permission"
376            style={styles.button}
377            onPress={async () => {
378              // Need both background and foreground permissions
379              await Location.requestForegroundPermissionsAsync();
380              await Location.requestBackgroundPermissionsAsync();
381              setShowPermissionsModal(!showPermissionsModal);
382            }}
383          />
384          <Button
385            title="Continue without background location permission"
386            style={styles.button}
387            onPress={() => setShowPermissionsModal(!showPermissionsModal)}
388          />
389        </View>
390      </View>
391    </Modal>
392  );
393};
394
395BackgroundLocationMapScreen.navigationOptions = {
396  title: 'Background location',
397};
398
399async function getSavedLocations() {
400  try {
401    const item = await AsyncStorage.getItem(STORAGE_KEY);
402    return item ? JSON.parse(item) : [];
403  } catch {
404    return [];
405  }
406}
407
408TaskManager.defineTask(LOCATION_UPDATES_TASK, async ({ data: { locations } }: any) => {
409  if (locations && locations.length > 0) {
410    const savedLocations = await getSavedLocations();
411    const newLocations = locations.map(({ coords }: any) => ({
412      latitude: coords.latitude,
413      longitude: coords.longitude,
414    }));
415
416    // tslint:disable-next-line no-console
417    console.log(`Received new locations at ${new Date()}:`, locations);
418
419    savedLocations.push(...newLocations);
420    await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(savedLocations));
421
422    locationEventsEmitter.emit('update', savedLocations);
423  }
424});
425
426const styles = StyleSheet.create({
427  screen: {
428    flex: 1,
429  },
430  mapView: {
431    flex: 1,
432  },
433  buttons: {
434    flex: 1,
435    flexDirection: 'column',
436    justifyContent: 'space-between',
437    padding: 10,
438    position: 'absolute',
439    top: 0,
440    right: 0,
441    bottom: 0,
442    left: 0,
443  },
444  topButtons: {
445    flexDirection: 'row',
446    justifyContent: 'space-between',
447  },
448  bottomButtons: {
449    flexDirection: 'column',
450    alignItems: 'flex-end',
451  },
452  buttonsColumn: {
453    flexDirection: 'column',
454    alignItems: 'flex-start',
455  },
456  button: {
457    paddingVertical: 5,
458    paddingHorizontal: 10,
459    marginVertical: 5,
460  },
461  buttonContentWrapper: {
462    flexDirection: 'row',
463  },
464  text: {
465    color: 'white',
466    fontWeight: '700',
467  },
468  errorText: {
469    fontSize: 15,
470    color: 'rgba(0,0,0,0.7)',
471    margin: 20,
472  },
473  modalHeader: { padding: 12, fontSize: 20, fontWeight: '800' },
474  modalText: { padding: 8, fontWeight: '600' },
475});
476