1/* 2 * Copyright (C) 2010 Google Inc. 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.addressinput; 18 19import com.android.i18n.addressinput.LookupKey.KeyType; 20import com.android.i18n.addressinput.LookupKey.ScriptType; 21 22import java.util.ArrayList; 23import java.util.LinkedList; 24import java.util.List; 25import java.util.Queue; 26 27/** 28 * Responsible for looking up data for address fields. This fetches possible 29 * values for the next level down in the address hierarchy, if these are known. 30 */ 31class FormController { 32 // For address hierarchy in lookup key. 33 private static final String SLASH_DELIM = "/"; 34 // For joined values. 35 private static final String TILDE_DELIM = "~"; 36 // For language code info in lookup key (E.g., data/CA--fr). 37 private static final String DASH_DELIM = "--"; 38 private static final LookupKey ROOT_KEY = FormController.getDataKeyForRoot(); 39 private static final String DEFAULT_REGION_CODE = "ZZ"; 40 private static final AddressField[] ADDRESS_HIERARCHY = { 41 AddressField.COUNTRY, 42 AddressField.ADMIN_AREA, 43 AddressField.LOCALITY, 44 AddressField.DEPENDENT_LOCALITY 45 }; 46 47 // Current user language. 48 private String mLanguageCode; 49 private ClientData mIntegratedData; 50 private String mCurrentCountry; 51 52 /** 53 * Constructor that populates this with data. languageCode should be a BCP language code (such 54 * as "en" or "zh-Hant") and currentCountry should be an ISO 2-letter region code (such as "GB" 55 * or "US"). 56 */ 57 FormController(ClientData integratedData, String languageCode, String currentCountry) { 58 Util.checkNotNull(integratedData, "null data not allowed"); 59 mLanguageCode = languageCode; 60 this.mCurrentCountry = currentCountry; 61 62 AddressData address = new AddressData.Builder().setCountry(DEFAULT_REGION_CODE).build(); 63 LookupKey defaultCountryKey = getDataKeyFor(address); 64 65 AddressVerificationNodeData defaultCountryData = 66 integratedData.getDefaultData(defaultCountryKey.toString()); 67 Util.checkNotNull(defaultCountryData, 68 "require data for default country key: " + defaultCountryKey); 69 this.mIntegratedData = integratedData; 70 } 71 72 void setLanguageCode(String languageCode) { 73 mLanguageCode = languageCode; 74 } 75 76 void setCurrentCountry(String currentCountry) { 77 mCurrentCountry = currentCountry; 78 } 79 80 private ScriptType getScriptType() { 81 if (mLanguageCode != null && Util.isExplicitLatinScript(mLanguageCode)) { 82 return ScriptType.LATIN; 83 } 84 return ScriptType.LOCAL; 85 } 86 87 private static LookupKey getDataKeyForRoot() { 88 AddressData address = new AddressData.Builder().build(); 89 return new LookupKey.Builder(KeyType.DATA).setAddressData(address).build(); 90 } 91 92 LookupKey getDataKeyFor(AddressData address) { 93 return new LookupKey.Builder(KeyType.DATA).setAddressData(address).build(); 94 } 95 96 /** 97 * Requests data for the input address. This method chains multiple requests together. For 98 * example, an address for Mt View, California needs data from "data/US", "data/US/CA", and 99 * "data/US/CA/Mt View" to support it. This method will request them one by one (from top level 100 * key down to the most granular) and evokes {@link DataLoadListener#dataLoadingEnd} method when 101 * all data is collected. If the address is invalid, it will request the first valid child key 102 * instead. For example, a request for "data/US/Foo" will end up requesting data for "data/US", 103 * "data/US/AL". 104 * 105 * @param address the current address. 106 * @param listener triggered when requested data for the address is returned. 107 */ 108 void requestDataForAddress(AddressData address, DataLoadListener listener) { 109 Util.checkNotNull(address.getPostalCountry(), "null country not allowed"); 110 111 // Gets the key for deepest available node. 112 Queue<String> subkeys = new LinkedList<String>(); 113 114 for (AddressField field : ADDRESS_HIERARCHY) { 115 String value = address.getFieldValue(field); 116 if (value == null) { 117 break; 118 } 119 subkeys.add(value); 120 } 121 if (subkeys.size() == 0) { 122 throw new RuntimeException("Need at least country level info"); 123 } 124 125 if (listener != null) { 126 listener.dataLoadingBegin(); 127 } 128 requestDataRecursively(ROOT_KEY, subkeys, listener); 129 } 130 131 private void requestDataRecursively(final LookupKey key, 132 final Queue<String> subkeys, final DataLoadListener listener) { 133 Util.checkNotNull(key, "Null key not allowed"); 134 Util.checkNotNull(subkeys, "Null subkeys not allowed"); 135 136 mIntegratedData.requestData(key, new DataLoadListener() { 137 @Override 138 public void dataLoadingBegin() { 139 } 140 141 @Override 142 public void dataLoadingEnd() { 143 List<RegionData> subregions = getRegionData(key); 144 if (subregions.isEmpty()) { 145 if (listener != null) { 146 listener.dataLoadingEnd(); 147 } 148 // TODO: Should update the selectors here. 149 return; 150 } else if (subkeys.size() > 0) { 151 String subkey = subkeys.remove(); 152 for (RegionData subregion : subregions) { 153 if (subregion.isValidName(subkey)) { 154 LookupKey nextKey = buildDataLookupKey(key, subregion.getKey()); 155 requestDataRecursively(nextKey, subkeys, listener); 156 return; 157 } 158 } 159 } 160 161 // Current value in the field is not valid, use the first valid subkey 162 // to request more data instead. 163 String firstSubkey = subregions.get(0).getKey(); 164 LookupKey nextKey = buildDataLookupKey(key, firstSubkey); 165 Queue<String> emptyList = new LinkedList<String>(); 166 requestDataRecursively(nextKey, emptyList, listener); 167 } 168 }); 169 } 170 171 private LookupKey buildDataLookupKey(LookupKey lookupKey, String subKey) { 172 String[] subKeys = lookupKey.toString().split(SLASH_DELIM); 173 String languageCodeSubTag = 174 (mLanguageCode == null) ? null : Util.getLanguageSubtag(mLanguageCode); 175 String key = lookupKey.toString() + SLASH_DELIM + subKey; 176 177 // Country level key 178 if (subKeys.length == 1 && 179 languageCodeSubTag != null && !isDefaultLanguage(languageCodeSubTag)) { 180 key += DASH_DELIM + languageCodeSubTag.toString(); 181 } 182 return new LookupKey.Builder(key).build(); 183 } 184 185 /** 186 * Compares the language subtags of input {@code languageCode} and default language code. For 187 * example, "zh-Hant" and "zh" are viewed as identical. 188 */ 189 boolean isDefaultLanguage(String languageCode) { 190 if (languageCode == null) { 191 return true; 192 } 193 AddressData addr = new AddressData.Builder().setCountry(mCurrentCountry).build(); 194 LookupKey lookupKey = getDataKeyFor(addr); 195 AddressVerificationNodeData data = 196 mIntegratedData.getDefaultData(lookupKey.toString()); 197 String defaultLanguage = data.get(AddressDataKey.LANG); 198 199 // Current language is not the default language for the country. 200 if (Util.trimToNull(defaultLanguage) != null && 201 !Util.getLanguageSubtag(languageCode).equals(Util.getLanguageSubtag(languageCode))) { 202 return false; 203 } 204 return true; 205 } 206 207 /** 208 * Gets a list of {@link RegionData} for sub-regions for a given key. For example, sub regions 209 * for "data/US" are AL/Alabama, AK/Alaska, etc. 210 * 211 * <p> TODO: It seems more straight forward to return a list of pairs instead of RegionData. 212 * Actually, we can remove RegionData since it does not contain anything more than key/value 213 * pairs now. 214 * 215 * @return A list of sub-regions, each sub-region represented by a {@link RegionData}. 216 */ 217 List<RegionData> getRegionData(LookupKey key) { 218 if (key.getKeyType() == KeyType.EXAMPLES) { 219 throw new RuntimeException("example key not allowed for getting region data"); 220 } 221 Util.checkNotNull(key, "null regionKey not allowed"); 222 LookupKey normalizedKey = normalizeLookupKey(key); 223 List<RegionData> results = new ArrayList<RegionData>(); 224 225 // Root key. 226 if (normalizedKey.equals(ROOT_KEY)) { 227 AddressVerificationNodeData data = 228 mIntegratedData.getDefaultData(normalizedKey.toString()); 229 String[] countries = splitData(data.get(AddressDataKey.COUNTRIES)); 230 for (int i = 0; i < countries.length; i++) { 231 RegionData rd = new RegionData.Builder() 232 .setKey(countries[i]) 233 .setName(countries[i]) 234 .build(); 235 results.add(rd); 236 } 237 return results; 238 } 239 240 AddressVerificationNodeData data = 241 mIntegratedData.get(normalizedKey.toString()); 242 if (data != null) { 243 String[] keys = splitData(data.get(AddressDataKey.SUB_KEYS)); 244 String[] names = (getScriptType() == ScriptType.LOCAL) 245 ? splitData(data.get(AddressDataKey.SUB_NAMES)) 246 : splitData(data.get(AddressDataKey.SUB_LNAMES)); 247 248 for (int i = 0; i < keys.length; i++) { 249 RegionData rd = 250 new RegionData.Builder() 251 .setKey(keys[i]) 252 .setName((i < names.length) ? names[i] : keys[i]) 253 .build(); 254 results.add(rd); 255 } 256 } 257 return results; 258 } 259 260 /** 261 * Split a '~' delimited string into an array of strings. This method is null tolerant and 262 * considers an empty string to contain no elements. 263 * 264 * @param raw The data to split 265 * @return an array of strings 266 */ 267 private String[] splitData(String raw) { 268 if (raw == null || raw.length() == 0) { 269 return new String[]{}; 270 } 271 return raw.split(TILDE_DELIM); 272 } 273 274 private String getSubKey(LookupKey parentKey, String name) { 275 for (RegionData subRegion : getRegionData(parentKey)) { 276 if (subRegion.isValidName(name)) { 277 return subRegion.getKey(); 278 } 279 } 280 return null; 281 } 282 283 /** 284 * Normalizes {@code key} by replacing field values with sub-keys. For example, California is 285 * replaced with CA. The normalization goes from top (country) to bottom (dependent locality) 286 * and if any field value is empty, unknown, or invalid, it will stop and return whatever it 287 * gets. For example, a key "data/US/California/foobar/kar" will be normalized into 288 * "data/US/CA/foobar/kar" since "foobar" is unknown. This method supports only key of 289 * {@link KeyType#DATA} type. 290 * 291 * @return normalized {@link LookupKey}. 292 */ 293 private LookupKey normalizeLookupKey(LookupKey key) { 294 Util.checkNotNull(key); 295 if (key.getKeyType() != KeyType.DATA) { 296 throw new RuntimeException("Only DATA keyType is supported"); 297 } 298 299 String subStr[] = key.toString().split(SLASH_DELIM); 300 301 // Root key does not need to be normalized. 302 if (subStr.length < 2) { 303 return key; 304 } 305 306 StringBuilder sb = new StringBuilder(subStr[0]); 307 for (int i = 1; i < subStr.length; ++i) { 308 // Strips the language code if contained. 309 String languageCode = null; 310 if (i == 1 && subStr[i].contains(DASH_DELIM)) { 311 String[] s = subStr[i].split(DASH_DELIM); 312 subStr[i] = s[0]; 313 languageCode = s[1]; 314 } 315 316 String normalizedSubKey = getSubKey(new LookupKey.Builder(sb.toString()).build(), 317 subStr[i]); 318 319 // Can't find normalized sub-key; assembles the lookup key with the 320 // remaining sub-keys and returns it. 321 if (normalizedSubKey == null) { 322 for (; i < subStr.length; ++i) { 323 sb.append(SLASH_DELIM).append(subStr[i]); 324 } 325 break; 326 } 327 sb.append(SLASH_DELIM).append(normalizedSubKey); 328 if (languageCode != null) { 329 sb.append(DASH_DELIM).append(languageCode); 330 } 331 } 332 return new LookupKey.Builder(sb.toString()).build(); 333 } 334} 335