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