diff --git a/README.md b/README.md index 8421dfb..97311d1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# react-native-geocoder +# react-native-geocoder [![CircleCI](https://circleci.com/gh/devfd/react-native-geocoder/tree/master.svg?style=shield)](https://circleci.com/gh/devfd/react-native-geocoder/tree/master) @@ -114,13 +114,17 @@ both iOS and Android will return the following object: ```js { position: {lat, lng}, + region: { + center: {lat, lng}, + radius: Number, + } | null, formattedAddress: String, // the full address feature: String | null, // ex Yosemite Park, Eiffel Tower streetNumber: String | null, streetName: String | null, postalCode: String | null, locality: String | null, // city name - country: String, + country: String, countryCode: String adminArea: String | null subAdminArea: String | null, @@ -135,5 +139,4 @@ iOS does not allow sending multiple geocoding requests simultaneously, unless yo ### Android geocoding may not work on older android devices (4.1) and will not work if Google play services are not available. - - +`region` will always be `null` on Android since it doesn't support the feature. In this case, `Geocoder.geocodePositionFallback` and `Geocoder.geocodeAddressFallback` may be used to get the `region` value. diff --git a/android/src/main/java/com/devfd/RNGeocoder/RNGeocoderModule.java b/android/src/main/java/com/devfd/RNGeocoder/RNGeocoderModule.java index 642ebfb..37741b7 100644 --- a/android/src/main/java/com/devfd/RNGeocoder/RNGeocoderModule.java +++ b/android/src/main/java/com/devfd/RNGeocoder/RNGeocoderModule.java @@ -1,8 +1,10 @@ package com.devfd.RNGeocoder; +import android.content.Context; import android.location.Address; import android.location.Geocoder; +import com.facebook.react.bridge.Callback; import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReactContextBaseJavaModule; @@ -15,14 +17,20 @@ import java.io.IOException; import java.util.List; +import java.util.Locale; public class RNGeocoderModule extends ReactContextBaseJavaModule { + private Context context; + private Locale locale; private Geocoder geocoder; public RNGeocoderModule(ReactApplicationContext reactContext) { super(reactContext); - geocoder = new Geocoder(reactContext.getApplicationContext()); + context = reactContext.getApplicationContext(); + + locale = context.getResources().getConfiguration().locale; + geocoder = new Geocoder(context, locale); } @Override @@ -30,6 +38,20 @@ public String getName() { return "RNGeocoder"; } + @ReactMethod + public void setLanguage(String language, Callback callback) { + Locale target = new Locale(language); + if (!context.getResources().getConfiguration().locale.equals(target) && !locale.equals(target)) { + locale = target; + geocoder = new Geocoder(context, locale); + + callback.invoke(language); + return; + } + + callback.invoke((String) null); + } + @ReactMethod public void geocodeAddress(String addressName, Promise promise) { if (!geocoder.isPresent()) { @@ -39,7 +61,12 @@ public void geocodeAddress(String addressName, Promise promise) { try { List
addresses = geocoder.getFromLocationName(addressName, 20); - promise.resolve(transform(addresses)); + if(addresses != null && addresses.size() > 0) { + promise.resolve(transform(addresses)); + } else { + promise.reject("NOT_AVAILABLE", "Geocoder returned an empty list"); + } + } catch (IOException e) { @@ -74,6 +101,9 @@ WritableArray transform(List
addresses) { position.putDouble("lng", address.getLongitude()); result.putMap("position", position); + // There is no region field in the `Address` object. + result.putString("region", null); + final String feature_name = address.getFeatureName(); if (feature_name != null && !feature_name.equals(address.getSubThoroughfare()) && !feature_name.equals(address.getThoroughfare()) && diff --git a/ios/RNGeocoder/RNGeocoder.h b/ios/RNGeocoder/RNGeocoder.h index 8abb862..98ce8fa 100644 --- a/ios/RNGeocoder/RNGeocoder.h +++ b/ios/RNGeocoder/RNGeocoder.h @@ -1,5 +1,5 @@ -#import "RCTBridgeModule.h" -#import "RCTConvert.h" +#import +#import #import diff --git a/ios/RNGeocoder/RNGeocoder.m b/ios/RNGeocoder/RNGeocoder.m index 5ad122b..796ac4a 100644 --- a/ios/RNGeocoder/RNGeocoder.m +++ b/ios/RNGeocoder/RNGeocoder.m @@ -2,7 +2,7 @@ #import -#import "RCTConvert.h" +#import @implementation RCTConvert (CoreLocation) @@ -22,6 +22,17 @@ @implementation RNGeocoder RCT_EXPORT_MODULE(); +RCT_EXPORT_METHOD(setLanguage:(NSString *)language + callback:(RCTResponseSenderBlock)callback) +{ + NSString *deviceLanguage = [[NSLocale preferredLanguages] objectAtIndex:0]; + if ([deviceLanguage isEqualToString:language]) { + return callback(@[[NSNull null]]); + } + + callback(@[language]); +} + RCT_EXPORT_METHOD(geocodePosition:(CLLocation *)location resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) @@ -81,6 +92,7 @@ - (NSArray *)placemarksToDictionary:(NSArray *)placemarks { for (int i = 0; i < placemarks.count; i++) { CLPlacemark* placemark = [placemarks objectAtIndex:i]; + CLCircularRegion* region = placemark.region; NSString* name = [NSNull null]; @@ -100,6 +112,13 @@ - (NSArray *)placemarksToDictionary:(NSArray *)placemarks { @"lat": [NSNumber numberWithDouble:placemark.location.coordinate.latitude], @"lng": [NSNumber numberWithDouble:placemark.location.coordinate.longitude], }, + @"region": placemark.region ? @{ + @"center": @{ + @"lat": [NSNumber numberWithDouble:region.center.latitude], + @"lng": [NSNumber numberWithDouble:region.center.longitude], + }, + @"radius": [NSNumber numberWithDouble:region.radius], + } : [NSNull null], @"country": placemark.country ?: [NSNull null], @"countryCode": placemark.ISOcountryCode ?: [NSNull null], @"locality": placemark.locality ?: [NSNull null], @@ -109,7 +128,7 @@ - (NSArray *)placemarksToDictionary:(NSArray *)placemarks { @"postalCode": placemark.postalCode ?: [NSNull null], @"adminArea": placemark.administrativeArea ?: [NSNull null], @"subAdminArea": placemark.subAdministrativeArea ?: [NSNull null], - @"formattedAddress": [lines componentsJoinedByString:@", "] + @"formattedAddress": [lines componentsJoinedByString:@", "], }; [results addObject:result]; diff --git a/js/geocoder.js b/js/geocoder.js index da2c3b7..388426c 100644 --- a/js/geocoder.js +++ b/js/geocoder.js @@ -1,23 +1,46 @@ -import { NativeModules } from 'react-native'; +import { NativeModules, Platform } from 'react-native'; import GoogleApi from './googleApi.js'; const { RNGeocoder } = NativeModules; export default { apiKey: null, + language: null, fallbackToGoogle(key) { this.apiKey = key; }, + setLanguage(language) { + RNGeocoder.setLanguage(language, (result) => { + this.language = result; + }); + }, + + geocodePositionFallback(position) { + if (!this.apiKey) { throw new Error("Google API key required"); } + + return GoogleApi.geocodePosition(this.apiKey, position, this.language); + }, + + geocodeAddressFallback(address) { + if (!this.apiKey) { throw new Error("Google API key required"); } + + return GoogleApi.geocodeAddress(this.apiKey, address, this.language); + }, + geocodePosition(position) { if (!position || !position.lat || !position.lng) { return Promise.reject(new Error("invalid position: {lat, lng} required")); } + if (this.language && (Platform.OS === 'ios')) { + return this.geocodePositionFallback(position); + } + return RNGeocoder.geocodePosition(position).catch(err => { - if (!this.apiKey || err.code !== 'NOT_AVAILABLE') { throw err; } - return GoogleApi.geocodePosition(this.apiKey, position); + if (err.code !== 'NOT_AVAILABLE') { throw err; } + return this.geocodePositionFallback(position); }); }, @@ -26,9 +49,13 @@ export default { return Promise.reject(new Error("address is null")); } + if (this.language && (Platform.OS === 'ios')) { + return this.geocodeAddressFallback(address); + } + return RNGeocoder.geocodeAddress(address).catch(err => { - if (!this.apiKey || err.code !== 'NOT_AVAILABLE') { throw err; } - return GoogleApi.geocodeAddress(this.apiKey, address); + if (err.code !== 'NOT_AVAILABLE') { throw err; } + return this.geocodeAddressFallback(address); }); }, } diff --git a/js/googleApi.js b/js/googleApi.js index 4358de7..80b1563 100644 --- a/js/googleApi.js +++ b/js/googleApi.js @@ -1,77 +1,111 @@ -const googleUrl = 'https://maps.google.com/maps/api/geocode/json'; - -function format(raw) { - const address = { - position: {}, - formattedAddress: raw.formatted_address || '', - feature: null, - streetNumber: null, - streetName: null, - postalCode: null, - locality: null, - country: null, - countryCode: null, - adminArea: null, - subAdminArea: null, - subLocality: null, - }; - - if (raw.geometry && raw.geometry.location) { - address.position = { - lat: raw.geometry.location.lat, - lng: raw.geometry.location.lng, - } - } +import { getCenterOfBounds, getDistance } from 'geolib'; - raw.address_components.forEach(component => { - if (component.types.indexOf('route') !== -1) { - address.streetName = component.long_name; - } - else if (component.types.indexOf('street_number') !== -1) { - address.streetNumber = component.long_name; - } - else if (component.types.indexOf('country') !== -1) { - address.country = component.long_name; - address.countryCode = component.short_name; - } - else if (component.types.indexOf('locality') !== -1) { - address.locality = component.long_name; - } - else if (component.types.indexOf('postal_code') !== -1) { - address.postalCode = component.long_name; - } - else if (component.types.indexOf('administrative_area_level_1') !== -1) { - address.adminArea = component.long_name; - } - else if (component.types.indexOf('administrative_area_level_2') !== -1) { - address.subAdminArea = component.long_name; - } - else if (component.types.indexOf('sublocality') !== -1 || component.types.indexOf('sublocality_level_1') !== -1) { - address.subLocality = component.long_name; - } - else if (component.types.indexOf('point_of_interest') !== -1 || component.types.indexOf('colloquial_area') !== -1) { - address.feature = component.long_name; +export default { + googleUrl: 'https://maps.googleapis.com/maps/api/geocode/json', + + format(raw) { + const address = { + position: {}, + region: null, + formattedAddress: raw.formatted_address || '', + feature: null, + streetNumber: null, + streetName: null, + postalCode: null, + locality: null, + country: null, + countryCode: null, + adminArea: null, + subAdminArea: null, + subLocality: null, + }; + + if (raw.geometry && raw.geometry.location) { + address.position = { + lat: raw.geometry.location.lat, + lng: raw.geometry.location.lng, + } + + if (raw.geometry.viewport) { + const northEast = { + latitude: raw.geometry.viewport.northeast.lat, + longitude: raw.geometry.viewport.northeast.lng, + }; + const southWest = { + latitude: raw.geometry.viewport.southwest.lat, + longitude: raw.geometry.viewport.southwest.lng, + }; + const center = getCenterOfBounds([northEast, southWest]); + const radius = Math.max(getDistance(center, northEast), getDistance(center, southWest)); + + address.region = { + center: { + lat: center.latitude, + lng: center.longitude, + }, + radius, + } + } } - }); - return address; -} + raw.address_components.forEach(component => { + if (component.types.indexOf('route') !== -1) { + address.streetName = component.long_name; + } + else if (component.types.indexOf('street_number') !== -1) { + address.streetNumber = component.long_name; + } + else if (component.types.indexOf('country') !== -1) { + address.country = component.long_name; + address.countryCode = component.short_name; + } + else if (component.types.indexOf('locality') !== -1) { + address.locality = component.long_name; + } + else if (component.types.indexOf('postal_code') !== -1) { + address.postalCode = component.long_name; + } + else if (component.types.indexOf('administrative_area_level_1') !== -1) { + address.adminArea = component.long_name; + } + else if (component.types.indexOf('administrative_area_level_2') !== -1) { + address.subAdminArea = component.long_name; + } + else if (component.types.indexOf('sublocality') !== -1 || component.types.indexOf('sublocality_level_1') !== -1) { + address.subLocality = component.long_name; + } + else if (component.types.indexOf('point_of_interest') !== -1 || component.types.indexOf('colloquial_area') !== -1) { + address.feature = component.long_name; + } + }); -export default { - geocodePosition(apiKey, position) { + return address; + }, + + geocodePosition(apiKey, position, language = null) { if (!apiKey || !position || !position.lat || !position.lng) { return Promise.reject(new Error("invalid apiKey / position")); } - return this.geocodeRequest(`${googleUrl}?key=${apiKey}&latlng=${position.lat},${position.lng}`); + let url = `${this.googleUrl}?key=${apiKey}&latlng=${position.lat},${position.lng}`; + if (language) { + url = `${url}&language=${language}`; + } + + return this.geocodeRequest(url); }, - geocodeAddress(apiKey, address) { + geocodeAddress(apiKey, address, language = null) { if (!apiKey || !address) { return Promise.reject(new Error("invalid apiKey / address")); } - return this.geocodeRequest(`${googleUrl}?key=${apiKey}&address=${encodeURI(address)}`); + let url = `${this.googleUrl}?key=${apiKey}&address=${encodeURI(address)}`; + if (language) { + url = `${url}&language=${language}`; + } + + return this.geocodeRequest(url); }, async geocodeRequest(url) { @@ -82,6 +116,6 @@ export default { return Promise.reject(new Error(`geocoding error ${json.status}, ${json.error_message}`)); } - return json.results.map(format); + return json.results.map(this.format); } } diff --git a/package.json b/package.json index 913f14a..e7d9a14 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-geocoder", - "version": "0.4.5", + "version": "0.5.0", "description": "react native geocoding and reverse geocoding", "main": "index.js", "scripts": { @@ -39,5 +39,12 @@ "sinon": "^1.17.5", "sinon-chai": "^2.8.0", "wd": "^0.4.0" + }, + "dependencies": { + "geolib": "^2.0.21" + }, + "peerDependencies": { + "react": ">=15.4.0", + "react-native": ">=0.40" } } diff --git a/test/unit/geocoder.test.js b/test/unit/geocoder.test.js index 7759e53..497b2fb 100644 --- a/test/unit/geocoder.test.js +++ b/test/unit/geocoder.test.js @@ -11,6 +11,7 @@ describe('geocoder', function() { }; RNGeocoder = { + setLanguage: sinon.stub().callsArgWith(1, null), geocodePosition: sinon.stub().returns(Promise.resolve()), geocodeAddress: sinon.stub().returns(Promise.resolve()), }; @@ -21,6 +22,7 @@ describe('geocoder', function() { './googleApi.js': GoogleApi, 'react-native': { NativeModules: { RNGeocoder }, + Platform: { OS: 'android' }, } }).default; }); @@ -52,6 +54,16 @@ describe('geocoder', function() { expect(RNGeocoder.geocodePosition).to.have.been.calledWith(position); }); + it ('returns geocoding results with a specific language', async function() { + const position = {lat: 1.234, lng: 4.567}; + const language = 'ko'; + RNGeocoder.setLanguage = sinon.stub().callsArgWith(1, language); + Geocoder.setLanguage(language); + expect(RNGeocoder.setLanguage).to.have.been.calledWith(language); + await Geocoder.geocodePosition(position); + expect(RNGeocoder.geocodePosition).to.have.been.calledWith(position); + }); + it ('does not call google api if no apiKey', function() { const position = {lat: 1.234, lng: 4.567}; RNGeocoder.geocodePosition = sinon.stub().returns(Promise.reject()); @@ -70,6 +82,27 @@ describe('geocoder', function() { expect(ret).to.eql('google'); }); + it ('fallbacks to google api when with a specific language on iOS', async function() { + Geocoder = proxyquire + .noCallThru() + .load('../../js/geocoder.js', { + './googleApi.js': GoogleApi, + 'react-native': { + NativeModules: { RNGeocoder }, + Platform: { OS: 'ios' }, + } + }).default; + const position = {lat: 1.234, lng: 4.567}; + const language = 'ko'; + RNGeocoder.setLanguage = sinon.stub().callsArgWith(1, language); + Geocoder.setLanguage(language); + expect(RNGeocoder.setLanguage).to.have.been.calledWith(language); + Geocoder.fallbackToGoogle('myGoogleMapsAPIKey'); + const ret = await Geocoder.geocodePosition(position); + expect(GoogleApi.geocodePosition).to.have.been.calledWith('myGoogleMapsAPIKey', position); + expect(ret).to.eql('google'); + }); + it ('does not fallback to google api on error', function() { const position = {lat: 1.234, lng: 4.567}; RNGeocoder.geocodePosition = sinon.stub().returns(Promise.reject(new Error('something wrong'))); diff --git a/test/unit/googleApi.test.js b/test/unit/googleApi.test.js index f02e8b9..19991e0 100644 --- a/test/unit/googleApi.test.js +++ b/test/unit/googleApi.test.js @@ -18,14 +18,28 @@ describe('googleApi', function() { it ('position', async function() { let ret = await GoogleApi.geocodePosition('myKey', {lat: 1.234, lng: 1.14}); expect(geocodeRequest).to.have.been.calledWith( - 'https://maps.google.com/maps/api/geocode/json?key=myKey&latlng=1.234,1.14'); + 'https://maps.googleapis.com/maps/api/geocode/json?key=myKey&latlng=1.234,1.14'); + expect(ret).to.eql('yo'); + }); + + it ('position with a specific language', async function() { + let ret = await GoogleApi.geocodePosition('myKey', {lat: 1.234, lng: 1.14}, 'ko'); + expect(geocodeRequest).to.have.been.calledWith( + 'https://maps.googleapis.com/maps/api/geocode/json?key=myKey&latlng=1.234,1.14&language=ko'); expect(ret).to.eql('yo'); }); it ('address', async function() { - let ret = await GoogleApi.geocodeAddress('myKey', "london"); + let ret = await GoogleApi.geocodeAddress('myKey', 'london'); + expect(geocodeRequest).to.have.been.calledWith( + 'https://maps.googleapis.com/maps/api/geocode/json?key=myKey&address=london'); + expect(ret).to.eql('yo'); + }); + + it ('address with a specific language', async function() { + let ret = await GoogleApi.geocodeAddress('myKey', 'london', 'ko'); expect(geocodeRequest).to.have.been.calledWith( - 'https://maps.google.com/maps/api/geocode/json?key=myKey&address=london'); + 'https://maps.googleapis.com/maps/api/geocode/json?key=myKey&address=london&language=ko'); expect(ret).to.eql('yo'); }); }); @@ -39,10 +53,10 @@ describe('googleApi', function() { } it ('throws if invalid results', function() { - mockFetch({ status: "NOT_OK" }); + mockFetch({ status: 'NOT_OK' }); return GoogleApi.geocodeRequest().then( () => { throw new Error('cannot be there') }, - (err) => { expect(err.message).to.contain("NOT_OK"); } + (err) => { expect(err.message).to.contain('NOT_OK'); } ); }); @@ -51,6 +65,9 @@ describe('googleApi', function() { it ('for waterloo-bridge', async function() { mockFetch(require('./fixtures/waterloo-bridge.js')); let [first, ...ret] = await GoogleApi.geocodeRequest(); + expect(first.region.center.lat).to.eql(51.506349); + expect(first.region.center.lng).to.eql(-0.114699); + expect(first.region.radius).to.eql(177); expect(first.countryCode).to.eql('GB'); expect(first.feature).to.be.eql(null); expect(first.locality).to.eql('London'); @@ -61,6 +78,9 @@ describe('googleApi', function() { it ('for yosemite park', async function() { mockFetch(require('./fixtures/yosemite-park.js')); let [first, ...ret] = await GoogleApi.geocodeRequest(); + expect(first.region.center.lat).to.eql(37.865101); + expect(first.region.center.lng).to.eql(-119.538329); + expect(first.region.radius).to.eql(191); expect(first.countryCode).to.eql('US'); expect(first.feature).to.be.eql('Yosemite National Park'); expect(first.streetName).to.be.eql(null);