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