1/*
2 * Copyright (C) 2011 The Libphonenumber Authors
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.i18n.phonenumbers.geocoding;
18
19import com.android.i18n.phonenumbers.PhoneNumberUtil;
20import com.android.i18n.phonenumbers.Phonenumber.PhoneNumber;
21
22import java.io.IOException;
23import java.io.InputStream;
24import java.io.ObjectInputStream;
25import java.util.HashMap;
26import java.util.Locale;
27import java.util.Map;
28import java.util.logging.Level;
29import java.util.logging.Logger;
30
31/**
32 * An offline geocoder which provides geographical information related to a phone number.
33 *
34 * @author Shaopeng Jia
35 */
36public class PhoneNumberOfflineGeocoder {
37  private static PhoneNumberOfflineGeocoder instance = null;
38  private static final String MAPPING_DATA_DIRECTORY =
39      "/com/android/i18n/phonenumbers/geocoding/data/";
40  private static final Logger LOGGER = Logger.getLogger(PhoneNumberOfflineGeocoder.class.getName());
41
42  private final PhoneNumberUtil phoneUtil = PhoneNumberUtil.getInstance();
43  private final String phonePrefixDataDirectory;
44
45  // The mappingFileProvider knows for which combination of countryCallingCode and language a phone
46  // prefix mapping file is available in the file system, so that a file can be loaded when needed.
47  private MappingFileProvider mappingFileProvider = new MappingFileProvider();
48
49  // A mapping from countryCallingCode_lang to the corresponding phone prefix map that has been
50  // loaded.
51  private Map<String, AreaCodeMap> availablePhonePrefixMaps = new HashMap<String, AreaCodeMap>();
52
53  // @VisibleForTesting
54  PhoneNumberOfflineGeocoder(String phonePrefixDataDirectory) {
55    this.phonePrefixDataDirectory = phonePrefixDataDirectory;
56    loadMappingFileProvider();
57  }
58
59  private void loadMappingFileProvider() {
60    InputStream source =
61        PhoneNumberOfflineGeocoder.class.getResourceAsStream(phonePrefixDataDirectory + "config");
62    ObjectInputStream in = null;
63    try {
64      in = new ObjectInputStream(source);
65      mappingFileProvider.readExternal(in);
66    } catch (IOException e) {
67      LOGGER.log(Level.WARNING, e.toString());
68    } finally {
69      close(in);
70    }
71  }
72
73  private AreaCodeMap getPhonePrefixDescriptions(
74      int prefixMapKey, String language, String script, String region) {
75    String fileName = mappingFileProvider.getFileName(prefixMapKey, language, script, region);
76    if (fileName.length() == 0) {
77      return null;
78    }
79    if (!availablePhonePrefixMaps.containsKey(fileName)) {
80      loadAreaCodeMapFromFile(fileName);
81    }
82    return availablePhonePrefixMaps.get(fileName);
83  }
84
85  private void loadAreaCodeMapFromFile(String fileName) {
86    InputStream source =
87        PhoneNumberOfflineGeocoder.class.getResourceAsStream(phonePrefixDataDirectory + fileName);
88    ObjectInputStream in = null;
89    try {
90      in = new ObjectInputStream(source);
91      AreaCodeMap map = new AreaCodeMap();
92      map.readExternal(in);
93      availablePhonePrefixMaps.put(fileName, map);
94    } catch (IOException e) {
95      LOGGER.log(Level.WARNING, e.toString());
96    } finally {
97      close(in);
98    }
99  }
100
101  private static void close(InputStream in) {
102    if (in != null) {
103      try {
104        in.close();
105      } catch (IOException e) {
106        LOGGER.log(Level.WARNING, e.toString());
107      }
108    }
109  }
110
111  /**
112   * Gets a {@link PhoneNumberOfflineGeocoder} instance to carry out international phone number
113   * geocoding.
114   *
115   * <p> The {@link PhoneNumberOfflineGeocoder} is implemented as a singleton. Therefore, calling
116   * this method multiple times will only result in one instance being created.
117   *
118   * @return  a {@link PhoneNumberOfflineGeocoder} instance
119   */
120  public static synchronized PhoneNumberOfflineGeocoder getInstance() {
121    if (instance == null) {
122      instance = new PhoneNumberOfflineGeocoder(MAPPING_DATA_DIRECTORY);
123    }
124    return instance;
125  }
126
127  /**
128   * Returns the customary display name in the given language for the given territory the phone
129   * number is from.
130   */
131  private String getCountryNameForNumber(PhoneNumber number, Locale language) {
132    String regionCode = phoneUtil.getRegionCodeForNumber(number);
133    return getRegionDisplayName(regionCode, language);
134  }
135
136  /**
137   * Returns the customary display name in the given language for the given region.
138   */
139  private String getRegionDisplayName(String regionCode, Locale language) {
140    return (regionCode == null || regionCode.equals("ZZ") ||
141            regionCode.equals(PhoneNumberUtil.REGION_CODE_FOR_NON_GEO_ENTITY))
142        ? "" : new Locale("", regionCode).getDisplayCountry(language);
143  }
144
145  /**
146   * Returns a text description for the given phone number, in the language provided. The
147   * description might consist of the name of the country where the phone number is from, or the
148   * name of the geographical area the phone number is from if more detailed information is
149   * available.
150   *
151   * <p>This method assumes the validity of the number passed in has already been checked.
152   *
153   * @param number  a valid phone number for which we want to get a text description
154   * @param languageCode  the language code for which the description should be written
155   * @return  a text description for the given language code for the given phone number
156   */
157  public String getDescriptionForValidNumber(PhoneNumber number, Locale languageCode) {
158    String langStr = languageCode.getLanguage();
159    String scriptStr = "";  // No script is specified
160    String regionStr = languageCode.getCountry();
161
162    String areaDescription =
163        getAreaDescriptionForNumber(number, langStr, scriptStr, regionStr);
164    return (areaDescription.length() > 0)
165        ? areaDescription : getCountryNameForNumber(number, languageCode);
166  }
167
168  /**
169   * As per {@link #getDescriptionForValidNumber(PhoneNumber, Locale)} but also considers the
170   * region of the user. If the phone number is from the same region as the user, only a lower-level
171   * description will be returned, if one exists. Otherwise, the phone number's region will be
172   * returned, with optionally some more detailed information.
173   *
174   * <p>For example, for a user from the region "US" (United States), we would show "Mountain View,
175   * CA" for a particular number, omitting the United States from the description. For a user from
176   * the United Kingdom (region "GB"), for the same number we may show "Mountain View, CA, United
177   * States" or even just "United States".
178   *
179   * <p>This method assumes the validity of the number passed in has already been checked.
180   *
181   * @param number  the phone number for which we want to get a text description
182   * @param languageCode  the language code for which the description should be written
183   * @param userRegion  the region code for a given user. This region will be omitted from the
184   *     description if the phone number comes from this region. It is a two-letter uppercase ISO
185   *     country code as defined by ISO 3166-1.
186   * @return  a text description for the given language code for the given phone number, or empty
187   *     string if the number passed in is invalid
188   */
189  public String getDescriptionForValidNumber(PhoneNumber number, Locale languageCode,
190                                             String userRegion) {
191    // If the user region matches the number's region, then we just show the lower-level
192    // description, if one exists - if no description exists, we will show the region(country) name
193    // for the number.
194    String regionCode = phoneUtil.getRegionCodeForNumber(number);
195    if (userRegion.equals(regionCode)) {
196      return getDescriptionForValidNumber(number, languageCode);
197    }
198    // Otherwise, we just show the region(country) name for now.
199    return getRegionDisplayName(regionCode, languageCode);
200    // TODO: Concatenate the lower-level and country-name information in an appropriate
201    // way for each language.
202  }
203
204  /**
205   * As per {@link #getDescriptionForValidNumber(PhoneNumber, Locale)} but explicitly checks
206   * the validity of the number passed in.
207   *
208   * @param number  the phone number for which we want to get a text description
209   * @param languageCode  the language code for which the description should be written
210   * @return  a text description for the given language code for the given phone number, or empty
211   *     string if the number passed in is invalid
212   */
213  public String getDescriptionForNumber(PhoneNumber number, Locale languageCode) {
214    if (!phoneUtil.isValidNumber(number)) {
215      return "";
216    }
217    return getDescriptionForValidNumber(number, languageCode);
218  }
219
220  /**
221   * As per {@link #getDescriptionForValidNumber(PhoneNumber, Locale, String)} but
222   * explicitly checks the validity of the number passed in.
223   *
224   * @param number  the phone number for which we want to get a text description
225   * @param languageCode  the language code for which the description should be written
226   * @param userRegion  the region code for a given user. This region will be omitted from the
227   *     description if the phone number comes from this region. It is a two-letter uppercase ISO
228   *     country code as defined by ISO 3166-1.
229   * @return  a text description for the given language code for the given phone number, or empty
230   *     string if the number passed in is invalid
231   */
232  public String getDescriptionForNumber(PhoneNumber number, Locale languageCode,
233                                        String userRegion) {
234    if (!phoneUtil.isValidNumber(number)) {
235      return "";
236    }
237    return getDescriptionForValidNumber(number, languageCode, userRegion);
238  }
239
240  /**
241   * Returns an area-level text description in the given language for the given phone number.
242   *
243   * @param number  the phone number for which we want to get a text description
244   * @param lang  two-letter lowercase ISO language codes as defined by ISO 639-1
245   * @param script  four-letter titlecase (the first letter is uppercase and the rest of the letters
246   *     are lowercase) ISO script codes as defined in ISO 15924
247   * @param region  two-letter uppercase ISO country codes as defined by ISO 3166-1
248   * @return  an area-level text description in the given language for the given phone number, or an
249   *     empty string if such a description is not available
250   */
251  private String getAreaDescriptionForNumber(
252      PhoneNumber number, String lang, String script, String region) {
253    int countryCallingCode = number.getCountryCode();
254    // As the NANPA data is split into multiple files covering 3-digit areas, use a phone number
255    // prefix of 4 digits for NANPA instead, e.g. 1650.
256    int phonePrefix = (countryCallingCode != 1) ?
257        countryCallingCode : (1000 + (int) (number.getNationalNumber() / 10000000));
258    AreaCodeMap phonePrefixDescriptions =
259        getPhonePrefixDescriptions(phonePrefix, lang, script, region);
260    String description = (phonePrefixDescriptions != null)
261        ? phonePrefixDescriptions.lookup(number)
262        : null;
263    // When a location is not available in the requested language, fall back to English.
264    if ((description == null || description.length() == 0) && mayFallBackToEnglish(lang)) {
265      AreaCodeMap defaultMap = getPhonePrefixDescriptions(phonePrefix, "en", "", "");
266      if (defaultMap == null) {
267        return "";
268      }
269      description = defaultMap.lookup(number);
270    }
271    return description != null ? description : "";
272  }
273
274  private boolean mayFallBackToEnglish(String lang) {
275    // Don't fall back to English if the requested language is among the following:
276    // - Chinese
277    // - Japanese
278    // - Korean
279    return !lang.equals("zh") && !lang.equals("ja") && !lang.equals("ko");
280  }
281}
282