blob: dedc1b4c883c75cfe97493d20117b58c3f51f181 [file] [log] [blame]
/*
* Copyright (C) 2010 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.i18n.addressinput;
import com.android.i18n.addressinput.LookupKey.KeyType;
import com.android.i18n.addressinput.LookupKey.ScriptType;
import android.util.Log;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Queue;
/**
* Controller for an Address Display and is responsible for:
*
* <ul> <li>Providing a way to iterate over {@link AddressField}s in a country dependent way.
* <li>Fetches new address data from the server as needed (based on hierarchy rules of international
* addresses). <li>Provides {@link FormField} for an {@link AddressField}. A {@link FormField}
* provides Country specific data for that field. For example, returning the correct label for
* Administrative Area. </ul>
*
* <p> In order for FormController to work properly, it requires two things: <ul> <li>An
* implementation of {@link AddressDisplayHandler}, which can be set using {@link
* #initDisplayHandler(AddressDisplayHandler)}. <li>Registering UI widgets which can change using
* {@link #registerField(AddressField, HasChangeHandlers)}. The registration should happen in
* constructor of the display. This registration is necessary so the controller can monitor changes
* and react to them appropriately. For example, a change in Country can cause additional data to be
* fetched and may cause a change in layout. </ul>
*/
public class FormController {
// Used to identify the source of a log message.
private static final String TAG = "FormController";
// For address hierarchy in lookup key.
private static final String SLASH_DELIM = "/";
// For joined values.
private static final String TILDA_DELIM = "~";
// For language code info in lookup key (E.g., data/CA--fr).
private static final String DASH_DELIM = "--";
// Current user language.
private final String LANGUAGE_CODE;
private static final LookupKey ROOT_KEY = FormController.getDataKeyForRoot();
private static final String DEFAULT_REGION_CODE = "ZZ";
private final AddressVerificationNodeData DEFAULT_COUNTRY_DATA;
private static final AddressField[] ADDRESS_HIERARCHY = {
AddressField.COUNTRY,
AddressField.ADMIN_AREA,
AddressField.LOCALITY,
AddressField.DEPENDENT_LOCALITY
};
private ClientData integratedData;
private String currentCountry;
/**
* Constructor that populates this with data. languageCode should be a BCP language code (such
* as "en" or "zh-Hant") and currentCountry should be an ISO 2-letter region code (such as "GB"
* or "US").
*/
public FormController(ClientData integratedData, String languageCode, String currentCountry) {
Util.checkNotNull(integratedData, "null data not allowed");
LANGUAGE_CODE = languageCode;
this.currentCountry = currentCountry;
AddressData address = new AddressData.Builder().setCountry(DEFAULT_REGION_CODE).build();
LookupKey defaultCountryKey = getDataKeyFor(address);
DEFAULT_COUNTRY_DATA = integratedData.getDefaultData(defaultCountryKey.toString());
Util.checkNotNull(DEFAULT_COUNTRY_DATA,
"require data for default country key: " + defaultCountryKey);
this.integratedData = integratedData;
}
void setCurrentCountry(String currentCountry) {
this.currentCountry = currentCountry;
}
private ScriptType getScriptType() {
if (LANGUAGE_CODE != null && Util.isExplicitLatinScript(LANGUAGE_CODE)) {
return ScriptType.LATIN;
}
return ScriptType.LOCAL;
}
private static LookupKey getDataKeyForRoot() {
AddressData address = new AddressData.Builder().build();
return new LookupKey.Builder(KeyType.DATA).setAddressData(address).build();
}
LookupKey getDataKeyFor(AddressData address) {
return new LookupKey.Builder(KeyType.DATA).setAddressData(address).build();
}
/**
* Requests data for the input address. This method chains multiple requests together. For
* example, an address for Mt View, California needs data from "data/US", "data/US/CA", and
* "data/US/CA/Mt View" to support it. This method will request them one by one (from top level
* key down to the most granular) and evokes {@link DataLoadListener#dataLoadingEnd} method when
* all data is collected. If the address is invalid, it will request the first valid child key
* instead. For example, a request for "data/US/Foo" will end up requesting data for "data/US",
* "data/US/AL".
*
* @param address the current address.
* @param listener triggered when requested data for the address is returned.
*/
public void requestDataForAddress(AddressData address, DataLoadListener listener) {
Util.checkNotNull(address.getPostalCountry(), "null country not allowed");
// Gets the key for deepest available node.
Queue<String> subkeys = new LinkedList<String>();
for (AddressField field : ADDRESS_HIERARCHY) {
String value = address.getFieldValue(field);
if (value == null) {
break;
}
subkeys.add(value);
}
if (subkeys.size() == 0) {
throw new RuntimeException("Need at least country level info");
}
if (listener != null) {
listener.dataLoadingBegin();
}
requestDataRecursively(ROOT_KEY, subkeys, listener);
}
private void requestDataRecursively(final LookupKey key,
final Queue<String> subkeys, final DataLoadListener listener) {
Util.checkNotNull(key, "Null key not allowed");
Util.checkNotNull(subkeys, "Null subkeys not allowed");
integratedData.requestData(key, new DataLoadListener() {
// Override
public void dataLoadingBegin() {
Log.w(TAG, "requesting data for key " + key);
}
// Override
public void dataLoadingEnd() {
List<RegionData> subregions = getRegionData(key);
if (subregions.isEmpty()) {
Log.w(TAG, "recursion ends with key " + key);
if (listener != null) {
listener.dataLoadingEnd();
}
// TODO: Should update the selectors here.
return;
} else if (subkeys.size() > 0) {
String subkey = subkeys.remove();
for (RegionData subregion : subregions) {
if (subregion.isValidName(subkey)) {
LookupKey nextKey = buildDataLookupKey(key, subregion.getKey());
requestDataRecursively(nextKey, subkeys, listener);
return;
}
}
}
// Current value in the field is not valid, use the first valid subkey
// to request more data instead.
String firstSubkey = subregions.get(0).getKey();
LookupKey nextKey = buildDataLookupKey(key, firstSubkey);
Queue<String> emptyList = new LinkedList<String>();
requestDataRecursively(nextKey, emptyList, listener);
}
});
}
private LookupKey buildDataLookupKey(LookupKey lookupKey, String subKey) {
String[] subKeys = lookupKey.toString().split(SLASH_DELIM);
String languageCodeSubTag =
(LANGUAGE_CODE == null) ? null : Util.getLanguageSubtag(LANGUAGE_CODE);
String key = lookupKey.toString() + SLASH_DELIM + subKey;
// Country level key
if (subKeys.length == 1 &&
languageCodeSubTag != null && !isDefaultLanguage(languageCodeSubTag)) {
key += DASH_DELIM + languageCodeSubTag.toString();
}
return new LookupKey.Builder(key).build();
}
/**
* Compares the language subtags of input {@code languageCode} and default language code. For
* example, "zh-Hant" and "zh" are viewed as identical.
*/
boolean isDefaultLanguage(String languageCode) {
if (languageCode == null) {
return true;
}
AddressData addr = new AddressData.Builder().setCountry(currentCountry).build();
LookupKey lookupKey = getDataKeyFor(addr);
AddressVerificationNodeData data =
integratedData.getDefaultData(lookupKey.toString());
String defaultLanguage = data.get(AddressDataKey.LANG);
// Current language is not the default language for the country.
if (Util.trimToNull(defaultLanguage) != null &&
Util.getLanguageSubtag(languageCode) != Util.getLanguageSubtag(languageCode)) {
return false;
}
return true;
}
/**
* Gets a list of {@link RegionData} for sub-regions for a given key. For example, sub regions
* for "data/US" are AL/Alabama, AK/Alaska, etc.
*
* <p> TODO: It seems more straight forward to return a list of pairs instead of RegionData.
* Actually, we can remove RegionData since it does not contain anything more than key/value
* pairs now.
*
* @return A list of sub-regions, each sub-region represented by a {@link RegionData}.
*/
List<RegionData> getRegionData(LookupKey key) {
if (key.getKeyType() == KeyType.EXAMPLES) {
throw new RuntimeException("example key not allowed for getting region data");
}
Util.checkNotNull(key, "null regionKey not allowed");
LookupKey normalizedKey = normalizeLookupKey(key);
List<RegionData> results = new ArrayList<RegionData>();
// Root key.
if (normalizedKey.equals(ROOT_KEY)) {
AddressVerificationNodeData data =
integratedData.getDefaultData(normalizedKey.toString());
String[] countries = splitData(data.get(AddressDataKey.COUNTRIES));
for (int i = 0; i < countries.length; i++) {
RegionData rd = new RegionData.Builder()
.setKey(countries[i])
.setName(countries[i])
.build();
results.add(rd);
}
return results;
}
AddressVerificationNodeData data =
integratedData.get(normalizedKey.toString());
if (data != null) {
String[] keys = splitData(data.get(AddressDataKey.SUB_KEYS));
String[] names = (getScriptType() == ScriptType.LOCAL)
? splitData(data.get(AddressDataKey.SUB_NAMES))
: splitData(data.get(AddressDataKey.SUB_LNAMES));
for (int i = 0; i < keys.length; i++) {
RegionData rd =
new RegionData.Builder()
.setKey(keys[i])
.setName((i < names.length) ? names[i] : keys[i])
.build();
results.add(rd);
}
}
return results;
}
/**
* Split a '~' delimited string into an array of strings. This method is null tolerant and
* considers an empty string to contain no elements.
*
* @param raw The data to split
* @return an array of strings
*/
private String[] splitData(String raw) {
if (raw == null || raw.length() == 0) {
return new String[]{};
}
return raw.split(TILDA_DELIM);
}
private String getSubKey(LookupKey parentKey, String name) {
for (RegionData subRegion : getRegionData(parentKey)) {
if (subRegion.isValidName(name)) {
return subRegion.getKey();
}
}
return null;
}
/**
* Normalizes {@code key} by replacing field values with sub-keys. For example, California is
* replaced with CA. The normalization goes from top (country) to bottom (dependent locality)
* and if any field value is empty, unknown, or invalid, it will stop and return whatever it
* gets. For example, a key "data/US/California/foobar/kar" will be normalized into
* "data/US/CA/foobar/kar" since "foobar" is unknown. This method supports only key of
* {@link KeyType#DATA} type.
*
* @return normalized {@link LookupKey}.
*/
private LookupKey normalizeLookupKey(LookupKey key) {
Util.checkNotNull(key);
if (key.getKeyType() != KeyType.DATA) {
throw new RuntimeException("Only DATA keyType is supported");
}
String subStr[] = key.toString().split(SLASH_DELIM);
// Root key does not need to be normalized.
if (subStr.length < 2) {
return key;
}
StringBuilder sb = new StringBuilder(subStr[0]);
for (int i = 1; i < subStr.length; ++i) {
// Strips the language code if contained.
String languageCode = null;
if (i == 1 && subStr[i].contains(DASH_DELIM)) {
String[] s = subStr[i].split(DASH_DELIM);
subStr[i] = s[0];
languageCode = s[1];
}
String normalizedSubKey = getSubKey(new LookupKey.Builder(sb.toString()).build(),
subStr[i]);
// Can't find normalized sub-key; assembles the lookup key with the
// remaining sub-keys and returns it.
if (normalizedSubKey == null) {
for (; i < subStr.length; ++i) {
sb.append(SLASH_DELIM).append(subStr[i]);
}
break;
}
sb.append(SLASH_DELIM).append(normalizedSubKey);
if (languageCode != null) {
sb.append(DASH_DELIM).append(languageCode);
}
}
return new LookupKey.Builder(sb.toString()).build();
}
}