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 static com.android.i18n.addressinput.Util.checkNotNull;
20
21import com.android.i18n.addressinput.JsonpRequestBuilder.AsyncCallback;
22
23import android.util.Log;
24
25import org.json.JSONException;
26import org.json.JSONObject;
27
28import java.util.EventListener;
29import java.util.HashMap;
30import java.util.HashSet;
31
32/**
33 * Cache for dynamic address data.
34 */
35public final class CacheData {
36
37    /**
38     * Used to identify the source of a log message.
39     */
40    private static final String TAG = "CacheData";
41
42    /**
43     * Time out value for the server to respond in millisecond.
44     */
45    private static final int TIMEOUT = 5000;
46
47    /**
48     * URL to get address data. You can also reset it by calling {@link #setUrl(String)}.
49     */
50    private String mServiceUrl;
51
52    /**
53     * Storage for all dynamically retrieved data.
54     */
55    private final JsoMap mCache;
56
57    /**
58     * CacheManager that handles caching that is needed by the client of the Address Widget.
59     */
60    private final ClientCacheManager mClientCacheManager;
61
62    /**
63     * All requests that have been sent.
64     */
65    private final HashSet<String> mRequestedKeys = new HashSet<String>();
66
67    /**
68     * All invalid requested keys. For example, if we request a random string "asdfsdf9o", and the
69     * server responds by saying this key is invalid, it will be stored here.
70     */
71    private final HashSet<String> mBadKeys = new HashSet<String>();
72
73    /**
74     * Temporary store for {@code CacheListener}s. When a key is requested and still waiting for
75     * server's response, the listeners for the same key will be temporary stored here. When the
76     * server responded, these listeners will be triggered and then removed.
77     */
78    private final HashMap<LookupKey, HashSet<CacheListener>> mTemporaryListenerStore =
79            new HashMap<LookupKey, HashSet<CacheListener>>();
80
81    /**
82     * Creates an instance of CacheData with an empty cache, and uses no caching that is external
83     * to the AddressWidget.
84     */
85    public CacheData() {
86        this(new SimpleClientCacheManager());
87    }
88
89    /**
90     * Creates an instance of CacheData with an empty cache, and uses additional caching (external
91     * to the AddressWidget) specified by clientCacheManager.
92     */
93    public CacheData(ClientCacheManager clientCacheManager) {
94        mClientCacheManager = clientCacheManager;
95        setUrl(mClientCacheManager.getAddressServerUrl());
96        mCache = JsoMap.createEmptyJsoMap();
97    }
98
99    /**
100     * This constructor is meant to be used together with external caching.
101     *
102     * Use case:
103     *
104     * After having finished using the address widget:
105     * String allCachedData = getJsonString();
106     * Cache (save) allCachedData wherever makes sense for your service / activity
107     *
108     * Before using it next time:
109     * Get the saved allCachedData string
110     * new ClientData(new CacheData(allCachedData))
111     *
112     * If you don't have any saved data you can either just pass an empty string to
113     * this constructor or use the other constructor.
114     *
115     * @param jsonString cached data from last time the class was used
116     */
117    public CacheData(String jsonString) {
118        mClientCacheManager = new SimpleClientCacheManager();
119        setUrl(mClientCacheManager.getAddressServerUrl());
120        JsoMap tempMap = null;
121        try {
122            tempMap = JsoMap.buildJsoMap(jsonString);
123        } catch (JSONException jsonE) {
124            // If parsing the JSON string throws an exception, default to
125            // starting with an empty cache.
126            Log.w(TAG, "Could not parse json string, creating empty cache instead.");
127            tempMap = JsoMap.createEmptyJsoMap();
128        } finally {
129            mCache = tempMap;
130        }
131    }
132
133    /**
134     * Interface for all listeners to {@link CacheData} change. This is only used when multiple
135     * requests of the same key is dispatched and server has not responded yet.
136     */
137    private static interface CacheListener extends EventListener {
138
139        /**
140         * The function that will be called when valid data is about to be put in the cache.
141         *
142         * @param key the key for newly arrived data.
143         */
144        void onAdd(String key);
145    }
146
147    /**
148     * Class to handle JSON response.
149     */
150    private class JsonHandler {
151
152        /**
153         * Key for the requested data.
154         */
155        private final String mKey;
156
157        /**
158         * Pre-existing data for the requested key. Null is allowed.
159         */
160        private final JSONObject mExistingJso;
161
162        private final DataLoadListener mListener;
163
164        /**
165         * Constructs a JsonHandler instance.
166         *
167         * @param key    The key for requested data.
168         * @param oldJso Pre-existing data for this key or null.
169         */
170        private JsonHandler(String key, JSONObject oldJso, DataLoadListener listener) {
171            checkNotNull(key);
172            mKey = key;
173            mExistingJso = oldJso;
174            mListener = listener;
175        }
176
177        /**
178         * Saves valid responded data to the cache once data arrives, or if the key is invalid,
179         * saves it in the invalid cache. If there is pre-existing data for the key, it will merge
180         * the new data will the old one. It also triggers {@link DataLoadListener#dataLoadingEnd()}
181         * method before it returns (even when the key is invalid, or input jso is null). This is
182         * called from a background thread.
183         *
184         * @param map The received JSON data as a map.
185         */
186        private void handleJson(JsoMap map) {
187            // Can this ever happen?
188            if (map == null) {
189                Log.w(TAG, "server returns null for key:" + mKey);
190                mBadKeys.add(mKey);
191                notifyListenersAfterJobDone(mKey);
192                triggerDataLoadingEndIfNotNull(mListener);
193                return;
194            }
195
196            JSONObject json = map;
197            String idKey = AddressDataKey.ID.name().toLowerCase();
198            if (!json.has(idKey)) {
199                Log.w(TAG, "invalid or empty data returned for key: " + mKey);
200                mBadKeys.add(mKey);
201                notifyListenersAfterJobDone(mKey);
202                triggerDataLoadingEndIfNotNull(mListener);
203                return;
204            }
205
206            if (mExistingJso != null) {
207                map.mergeData((JsoMap) mExistingJso);
208            }
209
210            mCache.putObj(mKey, map);
211            notifyListenersAfterJobDone(mKey);
212            triggerDataLoadingEndIfNotNull(mListener);
213        }
214    }
215
216    /**
217     * Sets address data server URL. Input URL cannot be null.
218     *
219     * @param url The service URL.
220     */
221    public void setUrl(String url) {
222        checkNotNull(url, "Cannot set URL of address data server to null.");
223        mServiceUrl = url;
224    }
225
226    /**
227     * Gets address data server URL.
228     */
229    public String getUrl() {
230        return mServiceUrl;
231    }
232
233    /**
234     * Returns a JSON string representing the data currently stored in this cache. It can be used
235     * to later create a new CacheData object containing the same cached data.
236     *
237     * @return a JSON string representing the data stored in this cache
238     */
239    public String getJsonString() {
240        return mCache.toString();
241    }
242
243    /**
244     * Checks if key and its value is cached (Note that only valid ones are cached).
245     */
246    public boolean containsKey(String key) {
247        return mCache.containsKey(key);
248    }
249
250    // This method is called from a background thread.
251    private void triggerDataLoadingEndIfNotNull(DataLoadListener listener) {
252        if (listener != null) {
253            listener.dataLoadingEnd();
254        }
255    }
256
257    /**
258     * Fetches data from server, or returns if the data is already cached. If the fetched data is
259     * valid, it will be added to the cache. This method also triggers {@link
260     * DataLoadListener#dataLoadingEnd()} method before it returns.
261     *
262     * @param existingJso Pre-existing data for this key or null if none.
263     * @param listener    An optional listener to call when done.
264     */
265    void fetchDynamicData(final LookupKey key, JSONObject existingJso,
266            final DataLoadListener listener) {
267        checkNotNull(key, "null key not allowed.");
268
269        if (listener != null) {
270            listener.dataLoadingBegin();
271        }
272
273        // Key is valid and cached.
274        if (mCache.containsKey(key.toString())) {
275            triggerDataLoadingEndIfNotNull(listener);
276            return;
277        }
278
279        // Key is invalid and cached.
280        if (mBadKeys.contains(key.toString())) {
281            triggerDataLoadingEndIfNotNull(listener);
282            return;
283        }
284
285        // Already requested the key, and is still waiting for server's response.
286        if (!mRequestedKeys.add(key.toString())) {
287            Log.d(TAG, "data for key " + key + " requested but not cached yet");
288            addListenerToTempStore(key, new CacheListener() {
289                @Override
290                public void onAdd(String myKey) {
291                    triggerDataLoadingEndIfNotNull(listener);
292                }
293            });
294            return;
295        }
296
297        // Key is in the cache maintained by the client of the AddressWidget.
298        String dataFromClientCache = mClientCacheManager.get(key.toString());
299        if (dataFromClientCache != null && dataFromClientCache.length() > 0) {
300            final JsonHandler handler = new JsonHandler(key.toString(),
301                existingJso, listener);
302            try {
303                handler.handleJson(JsoMap.buildJsoMap(dataFromClientCache));
304                return;
305            } catch (JSONException e) {
306                Log.w(TAG, "Data from client's cache is in the wrong format: "
307                        + dataFromClientCache);
308            }
309        }
310
311        // Key is not cached yet, now sending the request to the server.
312        JsonpRequestBuilder jsonp = new JsonpRequestBuilder();
313        jsonp.setTimeout(TIMEOUT);
314        final JsonHandler handler = new JsonHandler(key.toString(),
315                existingJso, listener);
316        jsonp.requestObject(mServiceUrl + "/" + key.toString(),
317                new AsyncCallback<JsoMap>() {
318                    @Override
319                    public void onFailure(Throwable caught) {
320                        Log.w(TAG, "Request for key " + key + " failed");
321                        mRequestedKeys.remove(key.toString());
322                        notifyListenersAfterJobDone(key.toString());
323                        triggerDataLoadingEndIfNotNull(listener);
324                    }
325
326                    @Override
327                    public void onSuccess(JsoMap result) {
328                        handler.handleJson(result);
329                        // Put metadata into the cache maintained by the client of the
330                        // AddressWidget.
331                        String dataRetrieved = result.toString();
332                        mClientCacheManager.put(key.toString(), dataRetrieved);
333                    }
334                });
335    }
336
337    /**
338     * Gets region data from our compiled-in java file and stores it in the
339     * cache. This is only called when data cannot be obtained from the server,
340     * so there will be no pre-existing data for this key.
341     */
342    void getFromRegionDataConstants(final LookupKey key) {
343        checkNotNull(key, "null key not allowed.");
344        String data = RegionDataConstants.getCountryFormatMap().get(
345                key.getValueForUpperLevelField(AddressField.COUNTRY));
346        if (data != null) {
347            try {
348                mCache.putObj(key.toString(), JsoMap.buildJsoMap(data));
349            } catch (JSONException e) {
350                Log.w(TAG, "Failed to parse data for key " + key +
351                      " from RegionDataConstants");
352            }
353        }
354    }
355
356    /**
357     * Retrieves string data identified by key.
358     *
359     * @param key Non-null key. E.g., "data/US/CA".
360     * @return String value for specified key.
361     */
362    public String get(String key) {
363        checkNotNull(key, "null key not allowed");
364        return mCache.get(key);
365    }
366
367    /**
368     * Retrieves JsoMap data identified by key.
369     *
370     * @param key Non-null key. E.g., "data/US/CA".
371     * @return String value for specified key.
372     */
373    public JsoMap getObj(String key) {
374        checkNotNull(key, "null key not allowed");
375        return mCache.getObj(key);
376    }
377
378    private void notifyListenersAfterJobDone(String key) {
379        LookupKey lookupKey = new LookupKey.Builder(key).build();
380        HashSet<CacheListener> listeners = mTemporaryListenerStore.get(lookupKey);
381        if (listeners != null) {
382            for (CacheListener listener : listeners) {
383                listener.onAdd(key.toString());
384            }
385            listeners.clear();
386        }
387    }
388
389    private void addListenerToTempStore(LookupKey key, CacheListener listener) {
390        checkNotNull(key);
391        checkNotNull(listener);
392        HashSet<CacheListener> listeners = mTemporaryListenerStore.get(key);
393        if (listeners == null) {
394            listeners = new HashSet<CacheListener>();
395            mTemporaryListenerStore.put(key, listeners);
396        }
397        listeners.add(listener);
398    }
399
400    /**
401     * Added for testing purposes.
402     * Adds a new object into the cache.
403     * @param id string of the format "data/country/.." ie. "data/US/CA"
404     * @param object The JSONObject to be put into cache.
405     */
406    void addToJsoMap(String id, JSONObject object) {
407        mCache.putObj(id, object);
408    }
409
410    /**
411     * Added for testing purposes.
412     * Checks to see if the cache is empty,
413     * @return true if the internal cache is empty
414     */
415    boolean isEmpty() {
416        return mCache.length() == 0;
417    }
418}
419