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