From 27150200f01a17d7329664d74a41cc4ca110db61 Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Fri, 11 Jan 2019 20:15:07 +0100 Subject: [PATCH] [home][ios] add diagnostics screen with background location demos (#3194) * [home] add diagnostics screen * [ios] fix home not having experienceUrl in constants * [home] make background location diagnostic screen look better * [ios] fix notification events not being send to home experience * [home] add diagnostic screen for geofencing * [home] subtle changes in background location diagnostic screen * [home] Only show diagnostics screen on iOS for now * [home] Verify permissions and handle the case where they are rejected --- home/navigation/Navigation.js | 58 ++++- home/screens/BackgroundLocationScreen.js | 301 +++++++++++++++++++++++ home/screens/DiagnosticsScreen.js | 93 +++++++ home/screens/GeofencingScreen.js | 282 +++++++++++++++++++++ home/screens/ProfileScreen.js | 2 +- ios/Client/EXHomeAppManager.m | 1 + ios/Exponent/Kernel/Core/EXKernel.m | 6 + 7 files changed, 730 insertions(+), 13 deletions(-) create mode 100644 home/screens/BackgroundLocationScreen.js create mode 100644 home/screens/DiagnosticsScreen.js create mode 100644 home/screens/GeofencingScreen.js diff --git a/home/navigation/Navigation.js b/home/navigation/Navigation.js index c9af50f4118297..fdb3d242339d6e 100644 --- a/home/navigation/Navigation.js +++ b/home/navigation/Navigation.js @@ -13,6 +13,9 @@ import { Entypo, Ionicons } from '@expo/vector-icons'; import { Constants } from 'expo'; import ProjectsScreen from '../screens/ProjectsScreen'; +import DiagnosticsScreen from '../screens/DiagnosticsScreen'; +import BackgroundLocationScreen from '../screens/BackgroundLocationScreen'; +import GeofencingScreen from '../screens/GeofencingScreen'; import ExploreScreen from '../screens/ExploreScreen'; import ProfileScreen from '../screens/ProfileScreen'; import SearchScreen from '../screens/SearchScreen'; @@ -119,18 +122,49 @@ const ProfileStack = createStackNavigator( } ); -const TabRoutes = - Platform.OS === 'android' || !Constants.isDevice - ? { - ProjectsStack, - ExploreStack, - ProfileStack, - } - : { - ProjectsStack, - ProfileStack, - }; +const DiagnosticsStack = createStackNavigator( + { + Diagnostics: DiagnosticsScreen, + BackgroundLocation: BackgroundLocationScreen, + Geofencing: GeofencingScreen, + }, + { + initialRouteName: 'Diagnostics', + defaultNavigationOptions, + navigationOptions: { + tabBarIcon: ({ focused }) => renderIcon(Ionicons, 'ios-git-branch', 26, focused), + tabBarLabel: 'Diagnostics', + }, + cardStyle: { + backgroundColor: Colors.greyBackground, + }, + } +); +let TabRoutes; + +if (Platform.OS === 'android') { + TabRoutes = { + ProjectsStack, + ExploreStack, + ProfileStack, + }; +} else { + if (Constants.isDevice) { + TabRoutes = { + ProjectsStack, + DiagnosticsStack, + ProfileStack, + }; + } else { + TabRoutes = { + ProjectsStack, + ExploreStack, + DiagnosticsStack, + ProfileStack, + }; + } +} const TabNavigator = Platform.OS === 'ios' ? createBottomTabNavigator(TabRoutes, { @@ -181,6 +215,6 @@ function renderIcon(IconComponent: any, iconName: string, iconSize: number, isSe const styles = StyleSheet.create({ icon: { - marginBottom: Platform.OS === 'ios' ? -2 : 0, + marginBottom: Platform.OS === 'ios' ? -3 : 0, }, }); diff --git a/home/screens/BackgroundLocationScreen.js b/home/screens/BackgroundLocationScreen.js new file mode 100644 index 00000000000000..5a29f584d5df95 --- /dev/null +++ b/home/screens/BackgroundLocationScreen.js @@ -0,0 +1,301 @@ +import React from 'react'; +import { EventEmitter } from 'fbemitter'; +import { NavigationEvents } from 'react-navigation'; +import { AppState, AsyncStorage, Platform, StyleSheet, Text, View } from 'react-native'; +import { Location, MapView, Permissions, TaskManager } from 'expo'; +import { FontAwesome, MaterialIcons } from '@expo/vector-icons'; + +import Button from '../components/PrimaryButton'; +import Colors from '../constants/Colors'; + +const STORAGE_KEY = 'expo-home-locations'; +const LOCATION_UPDATES_TASK = 'location-updates'; + +const locationEventsEmitter = new EventEmitter(); + +export default class BackgroundLocationScreen extends React.Component { + static navigationOptions = { + title: 'Background location', + }; + + mapViewRef = React.createRef(); + + state = { + accuracy: Location.Accuracy.High, + isTracking: false, + showsBackgroundLocationIndicator: false, + savedLocations: [], + initialRegion: null, + error: null, + }; + + didFocus = async () => { + let { status } = await Permissions.askAsync(Permissions.LOCATION); + + if (status !== 'granted') { + AppState.addEventListener('change', this.handleAppStateChange); + this.setState({ + error: + 'Location permissions are required in order to use this feature. You can manually enable them at any time in the "Location Services" section of the Settings app.', + }); + return; + } else { + this.setState({ error: null }); + } + + const { coords } = await Location.getCurrentPositionAsync(); + const isTracking = await Location.hasStartedLocationUpdatesAsync(LOCATION_UPDATES_TASK); + const task = (await TaskManager.getRegisteredTasksAsync()).find( + ({ taskName }) => taskName === LOCATION_UPDATES_TASK + ); + const savedLocations = await getSavedLocations(); + const accuracy = (task && task.options.accuracy) || this.state.accuracy; + + this.eventSubscription = locationEventsEmitter.addListener('update', locations => { + this.setState({ savedLocations: locations }); + }); + + if (!isTracking) { + alert('Click `Start tracking` to start getting location updates.'); + } + + this.setState({ + accuracy, + isTracking, + savedLocations, + initialRegion: { + latitude: coords.latitude, + longitude: coords.longitude, + latitudeDelta: 0.004, + longitudeDelta: 0.002, + }, + }); + }; + + handleAppStateChange = (nextAppState) => { + if (nextAppState !== 'active') { + return; + } + + if (this.state.initialRegion) { + AppState.removeEventListener('change', this.handleAppStateChange); + return; + } + + this.didFocus(); + }; + + + componentWillUnmount() { + if (this.eventSubscription) { + this.eventSubscription.remove(); + } + + AppState.removeEventListener('change', this.handleAppStateChange); + } + + async startLocationUpdates(accuracy = this.state.accuracy) { + await Location.startLocationUpdatesAsync(LOCATION_UPDATES_TASK, { + accuracy, + showsBackgroundLocationIndicator: this.state.showsBackgroundLocationIndicator, + }); + + if (!this.state.isTracking) { + alert( + '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.' + ); + } + this.setState({ isTracking: true }); + } + + async stopLocationUpdates() { + await Location.stopLocationUpdatesAsync(LOCATION_UPDATES_TASK); + this.setState({ isTracking: false }); + } + + clearLocations = async () => { + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify([])); + this.setState({ savedLocations: [] }); + }; + + toggleTracking = async () => { + await AsyncStorage.removeItem(STORAGE_KEY); + + if (this.state.isTracking) { + await this.stopLocationUpdates(); + } else { + await this.startLocationUpdates(); + } + this.setState({ savedLocations: [] }); + }; + + onAccuracyChange = () => { + const next = Location.Accuracy[this.state.accuracy + 1]; + const accuracy = next ? Location.Accuracy[next] : Location.Accuracy.Lowest; + + this.setState({ accuracy }); + + if (this.state.isTracking) { + // Restart background task with the new accuracy. + this.startLocationUpdates(accuracy); + } + }; + + toggleLocationIndicator = async () => { + const showsBackgroundLocationIndicator = !this.state.showsBackgroundLocationIndicator; + + this.setState({ showsBackgroundLocationIndicator }, async () => { + if (this.state.isTracking) { + await this.startLocationUpdates(); + } + }); + }; + + onCenterMap = async () => { + const { coords } = await Location.getCurrentPositionAsync(); + const mapView = this.mapViewRef.current; + + if (mapView) { + mapView.animateToRegion({ + latitude: coords.latitude, + longitude: coords.longitude, + latitudeDelta: 0.004, + longitudeDelta: 0.002, + }); + } + }; + + renderPolyline() { + const { savedLocations } = this.state; + + if (savedLocations.length === 0) { + return null; + } + return ( + + ); + } + + render() { + if (this.state.error) { + return {this.state.error}; + } + + if (!this.state.initialRegion) { + return ; + } + + return ( + + + {this.renderPolyline()} + + + + + {Platform.OS === 'android' ? null : ( + + )} + + + + + + + + + + + + + + ); + } +} + +async function getSavedLocations() { + try { + const item = await AsyncStorage.getItem(STORAGE_KEY); + return item ? JSON.parse(item) : []; + } catch (e) { + return []; + } +} + +TaskManager.defineTask(LOCATION_UPDATES_TASK, async ({ data: { locations } }) => { + if (locations && locations.length > 0) { + const savedLocations = await getSavedLocations(); + const newLocations = locations.map(({ coords }) => ({ + latitude: coords.latitude, + longitude: coords.longitude, + })); + + savedLocations.push(...newLocations); + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(savedLocations)); + + locationEventsEmitter.emit('update', savedLocations); + } +}); + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + mapView: { + flex: 1, + }, + buttons: { + flex: 1, + flexDirection: 'column', + justifyContent: 'space-between', + padding: 10, + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + }, + topButtons: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + bottomButtons: { + flexDirection: 'column', + alignItems: 'flex-end', + }, + buttonsColumn: { + flexDirection: 'column', + alignItems: 'flex-start', + }, + button: { + paddingVertical: 5, + paddingHorizontal: 10, + marginVertical: 5, + }, + errorText: { + fontSize: 15, + color: 'rgba(0,0,0,0.7)', + margin: 20, + }, +}); diff --git a/home/screens/DiagnosticsScreen.js b/home/screens/DiagnosticsScreen.js new file mode 100644 index 00000000000000..c2c86233b456a6 --- /dev/null +++ b/home/screens/DiagnosticsScreen.js @@ -0,0 +1,93 @@ +import React from 'react'; +import { Animated, StyleSheet, Text, View } from 'react-native'; +import { ScrollView } from 'react-navigation'; +import { BaseButton, State } from 'react-native-gesture-handler'; +import Colors from '../constants/Colors'; + +class ShadowButton extends React.Component { + state = { + scale: new Animated.Value(1), + }; + + _handleGestureStateChange = active => { + if (active) { + Animated.spring(this.state.scale, { toValue: 0.95 }).start(); + } else { + Animated.spring(this.state.scale, { toValue: 1 }).start(); + } + }; + + render() { + return ( + + + {this.props.children} + + + ); + } +} + +export default class DiagnosticsScren extends React.Component { + static navigationOptions = { + title: 'Diagnostics', + }; + + render() { + return ( + + + this.props.navigation.navigate('BackgroundLocation')}> + Background location + + On iOS it's possible to track your location when an app is backgrounded or even + closed. This diagnostic allows you to see what options are available, see the output, + and test the functionality on your device. None of the location data will leave your + device. + + + this.props.navigation.navigate('Geofencing')}> + Geofencing + + You can fire actions when your device enters specific geographical regions represented + by a longitude, latitude, and a radius. This diagnostic lets you experiment with + Geofencing using regions that you specify and shows you the data that is made + available. None of the data will leave your device. + + + + + ); + } +} + +const styles = StyleSheet.create({ + titleText: { + fontSize: 18, + fontWeight: '500', + marginBottom: 6, + }, + bodyText: { + fontSize: 14, + lineHeight: 20, + color: 'rgba(0,0,0,0.6)', + }, +}); diff --git a/home/screens/GeofencingScreen.js b/home/screens/GeofencingScreen.js new file mode 100644 index 00000000000000..97f3b08174cc41 --- /dev/null +++ b/home/screens/GeofencingScreen.js @@ -0,0 +1,282 @@ +import React from 'react'; +import { NavigationEvents } from 'react-navigation'; +import { AppState, StyleSheet, Text, View } from 'react-native'; +import { Location, MapView, Permissions, Notifications, TaskManager } from 'expo'; +import { MaterialIcons } from '@expo/vector-icons'; + +import Button from '../components/PrimaryButton'; + +const GEOFENCING_TASK = 'geofencing'; +const REGION_RADIUSES = [30, 50, 75, 100, 150, 200]; + +export default class GeofencingScreen extends React.Component { + static navigationOptions = { + title: 'Geofencing', + }; + + mapViewRef = React.createRef(); + + state = { + isGeofencing: false, + newRegionRadius: REGION_RADIUSES[1], + geofencingRegions: [], + initialRegion: null, + error: null, + }; + + didFocus = async () => { + let { status } = await Permissions.askAsync(Permissions.LOCATION); + + if (status !== 'granted') { + AppState.addEventListener('change', this.handleAppStateChange); + this.setState({ + error: + 'Location permissions are required in order to use this feature. You can manually enable them at any time in the "Location Services" section of the Settings app.', + }); + return; + } else { + this.setState({ error: null }); + } + + const { coords } = await Location.getCurrentPositionAsync(); + const isGeofencing = await Location.hasStartedGeofencingAsync(GEOFENCING_TASK); + const geofencingRegions = await getSavedRegions(); + + if (!isGeofencing) { + alert( + 'Tap on the map to select a region with chosen radius and then press `Start geofencing` to start getting geofencing notifications.' + ); + } + + this.setState({ + isGeofencing, + geofencingRegions, + initialRegion: { + latitude: coords.latitude, + longitude: coords.longitude, + latitudeDelta: 0.004, + longitudeDelta: 0.002, + }, + }); + }; + + handleAppStateChange = (nextAppState) => { + if (nextAppState !== 'active') { + return; + } + + if (this.state.initialRegion) { + AppState.removeEventListener('change', this.handleAppStateChange); + return; + } + + this.didFocus(); + }; + + canToggleGeofencing() { + return this.state.isGeofencing || this.state.geofencingRegions.length > 0; + } + + toggleGeofencing = async () => { + if (!this.canToggleGeofencing()) { + return; + } + + if (this.state.isGeofencing) { + await Location.stopGeofencingAsync(GEOFENCING_TASK); + this.setState({ geofencingRegions: [] }); + } else { + await Location.startGeofencingAsync(GEOFENCING_TASK, this.state.geofencingRegions); + alert( + 'You will be receiving notifications when the device enters or exits from selected regions.' + ); + } + this.setState({ isGeofencing: !this.state.isGeofencing }); + }; + + shiftRegionRadius = () => { + const index = REGION_RADIUSES.indexOf(this.state.newRegionRadius) + 1; + const radius = index < REGION_RADIUSES.length ? REGION_RADIUSES[index] : REGION_RADIUSES[0]; + + this.setState({ newRegionRadius: radius }); + }; + + centerMap = async () => { + const { coords } = await Location.getCurrentPositionAsync(); + const mapView = this.mapViewRef.current; + + if (mapView) { + mapView.animateToRegion({ + latitude: coords.latitude, + longitude: coords.longitude, + latitudeDelta: 0.004, + longitudeDelta: 0.002, + }); + } + }; + + onMapPress = async ({ nativeEvent: { coordinate } }) => { + const geofencingRegions = [...this.state.geofencingRegions]; + + geofencingRegions.push({ + identifier: `${coordinate.latitude},${coordinate.longitude}`, + latitude: coordinate.latitude, + longitude: coordinate.longitude, + radius: this.state.newRegionRadius, + }); + this.setState({ geofencingRegions }); + + if (await Location.hasStartedGeofencingAsync(GEOFENCING_TASK)) { + // update existing geofencing task + await Location.startGeofencingAsync(GEOFENCING_TASK, geofencingRegions); + } + }; + + renderRegions() { + const { geofencingRegions } = this.state; + + return geofencingRegions.map(region => { + return ( + + ); + }); + } + + getGeofencingButtonContent() { + const canToggle = this.canToggleGeofencing(); + + if (canToggle) { + return this.state.isGeofencing ? 'Stop geofencing' : 'Start geofencing'; + } + return 'Select at least one region on the map'; + } + + render() { + if (this.state.error) { + return {this.state.error}; + } + + if (!this.state.initialRegion) { + return ; + } + + const canToggle = this.canToggleGeofencing(); + + return ( + + + {this.renderRegions()} + + + + + + + + + + + + + + + + + + ); + } +} + +async function getSavedRegions() { + const tasks = await TaskManager.getRegisteredTasksAsync(); + const task = tasks.find(({ taskName }) => taskName === GEOFENCING_TASK); + return task ? task.options.regions : []; +} + +TaskManager.defineTask(GEOFENCING_TASK, async ({ data: { region } }) => { + const stateString = Location.GeofencingRegionState[region.state].toLowerCase(); + const body = `You're ${stateString} a region with latitude: ${region.latitude}, longitude: ${ + region.longitude + } and radius: ${region.radius}m`; + + await Notifications.presentLocalNotificationAsync({ + title: 'Expo Geofencing', + body, + data: { + ...region, + notificationBody: body, + notificationType: GEOFENCING_TASK, + }, + }); +}); + +Notifications.addListener(({ data, remote }) => { + if (!remote && data.notificationType === GEOFENCING_TASK) { + alert(data.notificationBody); + } +}); + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + mapView: { + flex: 1, + }, + buttons: { + flex: 1, + flexDirection: 'column', + justifyContent: 'space-between', + padding: 10, + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + }, + topButtons: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + bottomButtons: { + flexDirection: 'column', + alignItems: 'flex-end', + }, + buttonsColumn: { + flexDirection: 'column', + alignItems: 'flex-start', + }, + button: { + paddingVertical: 5, + paddingHorizontal: 10, + marginVertical: 5, + }, + disabledButton: { + backgroundColor: 'gray', + opacity: 0.8, + }, + errorText: { + fontSize: 15, + color: 'rgba(0,0,0,0.7)', + margin: 20 + }, +}); diff --git a/home/screens/ProfileScreen.js b/home/screens/ProfileScreen.js index 93f7ee67f11c84..7491fafb14472f 100644 --- a/home/screens/ProfileScreen.js +++ b/home/screens/ProfileScreen.js @@ -177,7 +177,7 @@ class UserSettingsButtonIOS extends React.Component { render() { return ( - Options + Options ); } diff --git a/ios/Client/EXHomeAppManager.m b/ios/Client/EXHomeAppManager.m index 69a5021c337a88..627e8a58b70436 100644 --- a/ios/Client/EXHomeAppManager.m +++ b/ios/Client/EXHomeAppManager.m @@ -82,6 +82,7 @@ - (NSArray *)extraModulesForBridge:(RCTBridge *)bridge @"installationId": [EXKernel deviceInstallUUID], @"expoRuntimeVersion": [EXBuildConstants sharedInstance].expoRuntimeVersion, @"linkingUri": @"exp://", + @"experienceUrl": [@"exp://" stringByAppendingString:self.appRecord.appLoader.manifest[@"hostUri"]], @"manifest": self.appRecord.appLoader.manifest, @"appOwnership": @"expo", }, diff --git a/ios/Exponent/Kernel/Core/EXKernel.m b/ios/Exponent/Kernel/Core/EXKernel.m index 8667c39186bc8e..a4c920b910b3e0 100644 --- a/ios/Exponent/Kernel/Core/EXKernel.m +++ b/ios/Exponent/Kernel/Core/EXKernel.m @@ -167,6 +167,12 @@ - (id)nativeModuleForAppManager:(EXReactAppManager *)appManager named:(NSString - (BOOL)sendNotification:(EXPendingNotification *)notification { EXKernelAppRecord *destinationApp = [_appRegistry newestRecordWithExperienceId:notification.experienceId]; + + // This allows home app record to receive notification events as well. + if (!destinationApp && [_appRegistry.homeAppRecord.experienceId isEqualToString:notification.experienceId]) { + destinationApp = _appRegistry.homeAppRecord; + } + if (destinationApp) { // send the body to the already-open experience [self _dispatchJSEvent:@"Exponent.notification" body:notification.properties toApp:destinationApp];