aboutsummaryrefslogtreecommitdiff
path: root/java/src/com/android/i18n/addressinput/CacheData.java
blob: 39942c986c08605cc96568fd02f08bf719c3c925 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
/*
 * 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 static com.android.i18n.addressinput.Util.checkNotNull;

import com.android.i18n.addressinput.JsonpRequestBuilder.AsyncCallback;

import android.util.Log;

import org.json.JSONException;
import org.json.JSONObject;

import java.util.EventListener;
import java.util.HashMap;
import java.util.HashSet;

/**
 * Cache for dynamic address data.
 */
public final class CacheData {

  /**
   * Used to identify the source of a log message.
   */
  private static final String TAG = "CacheData";

  /**
   * Time out value for the server to respond in millisecond.
   */
  private static final int TIMEOUT = 5000;

  /**
   * URL to get address data. You can also reset it by calling {@link #setUrl(String)}.
   */
  private String serviceUrl;

  /**
   * Storage for all dynamically retrieved data.
   */
  private final JsoMap cache;

  /**
   * CacheManager that handles caching that is needed by the client of the Address Widget.
   */
  private final ClientCacheManager clientCacheManager;

  /**
   * All requests that have been sent.
   */
  private final HashSet<String> requestedKeys = new HashSet<String>();

  /**
   * All invalid requested keys. For example, if we request a random string "asdfsdf9o", and the
   * server responds by saying this key is invalid, it will be stored here.
   */
  private final HashSet<String> badKeys = new HashSet<String>();

  /**
   * Temporary store for {@code CacheListener}s. When a key is requested and still waiting for
   * server's response, the listeners for the same key will be temporary stored here. When the
   * server responded, these listeners will be triggered and then removed.
   */
  private final HashMap<LookupKey, HashSet<CacheListener>> temporaryListenerStore =
      new HashMap<LookupKey, HashSet<CacheListener>>();

  /**
   * Creates an instance of CacheData with an empty cache, and uses no caching that is external
   * to the AddressWidget.
   */
  public CacheData() {
    this(new SimpleClientCacheManager());
  }

  /**
   * Creates an instance of CacheData with an empty cache, and uses additional caching (external
   * to the AddressWidget) specified by clientCacheManager.
   */
  public CacheData(ClientCacheManager clientCacheManager) {
    this.clientCacheManager = clientCacheManager;
    setUrl(clientCacheManager.getAddressServerUrl());
    cache = JsoMap.createEmptyJsoMap();
  }

  /**
   * This constructor is meant to be used together with external caching.
   *
   * Use case:
   *
   * After having finished using the address widget:
   * String allCachedData = getJsonString();
   * Cache (save) allCachedData wherever makes sense for your service / activity
   *
   * Before using it next time:
   * Get the saved allCachedData string
   * new ClientData(new CacheData(allCachedData))
   *
   * If you don't have any saved data you can either just pass an empty string to
   * this constructor or use the other constructor.
   *
   * @param jsonString cached data from last time the class was used
   */
  public CacheData(String jsonString) {
    clientCacheManager = new SimpleClientCacheManager();
    setUrl(clientCacheManager.getAddressServerUrl());
    JsoMap tempMap = null;
    try {
      tempMap = JsoMap.buildJsoMap(jsonString);
    } catch (JSONException jsonE) {
      // If parsing the JSON string throws an exception, default to
      // starting with an empty cache.
      Log.w(TAG, "Could not parse json string, creating empty cache instead.");
      tempMap = JsoMap.createEmptyJsoMap();
    } finally {
      cache = tempMap;
    }
  }

  /**
   * Interface for all listeners to {@link CacheData} change. This is only used when multiple
   * requests of the same key is dispatched and server has not responded yet.
   */
  private static interface CacheListener extends EventListener {

    /**
     * The function that will be called when valid data is about to be put in the cache.
     *
     * @param key the key for newly arrived data.
     */
    void onAdd(String key);
  }

  /**
   * Class to handle JSON response.
   */
  private class JsonHandler {

    /**
     * Key for the requested data.
     */
    private final String key;

    /**
     * Pre-existing data for the requested key. Null is allowed.
     */
    private final JSONObject existingJso;

    private final DataLoadListener listener;

    /**
     * Constructs a JsonHandler instance.
     *
     * @param key    The key for requested data.
     * @param oldJso Pre-existing data for this key or null.
     */
    private JsonHandler(String key, JSONObject oldJso, DataLoadListener listener) {
      checkNotNull(key);
      this.key = key;
      this.existingJso = oldJso;
      this.listener = listener;
    }

    /**
     * Saves valid responded data to the cache once data arrives, or if the key is invalid,
     * saves it in the invalid cache. If there is pre-existing data for the key, it will merge
     * the new data will the old one. It also triggers {@link DataLoadListener#dataLoadingEnd()}
     * method before it returns (even when the key is invalid, or input jso is null). This is
     * called from a background thread.
     *
     * @param map The received JSON data as a map.
     */
    private void handleJson(JsoMap map) {
      // Can this ever happen?
      if (map == null) {
        Log.w(TAG, "server returns null for key:" + key);
        badKeys.add(key);
        notifyListenersAfterJobDone(key);
        triggerDataLoadingEndIfNotNull(listener);
        return;
      }

      JSONObject json = map;
      String idKey = AddressDataKey.ID.name().toLowerCase();
      if (!json.has(idKey)) {
        Log.w(TAG, "invalid or empty data returned for key: " + key);
        badKeys.add(key);
        notifyListenersAfterJobDone(key);
        triggerDataLoadingEndIfNotNull(listener);
        return;
      }

      if (existingJso != null) {
        map.mergeData((JsoMap) existingJso);
      }

      cache.putObj(key, map);
      notifyListenersAfterJobDone(key);
      triggerDataLoadingEndIfNotNull(listener);
    }
  }

  /**
   * Sets address data server URL. Input URL cannot be null.
   *
   * @param url The service URL.
   */
  public void setUrl(String url) {
    checkNotNull(url, "Cannot set URL of address data server to null.");
    serviceUrl = url;
  }

  /**
   * Gets address data server URL.
   */
  public String getUrl() {
    return serviceUrl;
  }

  /**
   * Returns a JSON string representing the data currently stored in this cache. It can be used
   * to later create a new CacheData object containing the same cached data.
   *
   * @return a JSON string representing the data stored in this cache
   */
  public String getJsonString() {
    return cache.toString();
  }

  /**
   * Checks if key and its value is cached (Note that only valid ones are cached).
   */
  public boolean containsKey(String key) {
    return cache.containsKey(key);
  }

  // This method is called from a background thread.
  private void triggerDataLoadingEndIfNotNull(DataLoadListener listener) {
    if (listener != null) {
      listener.dataLoadingEnd();
    }
  }

  /**
   * Fetches data from server, or returns if the data is already cached. If the fetched data is
   * valid, it will be added to the cache. This method also triggers {@link
   * DataLoadListener#dataLoadingEnd()} method before it returns.
   *
   * @param existingJso Pre-existing data for this key or null if none.
   * @param listener    An optional listener to call when done.
   */
  void fetchDynamicData(final LookupKey key, JSONObject existingJso,
      final DataLoadListener listener) {
    checkNotNull(key, "null key not allowed.");

    if (listener != null) {
      listener.dataLoadingBegin();
    }

    // Key is valid and cached.
    if (cache.containsKey(key.toString())) {
      triggerDataLoadingEndIfNotNull(listener);
      return;
    }

    // Key is invalid and cached.
    if (badKeys.contains(key.toString())) {
      triggerDataLoadingEndIfNotNull(listener);
      return;
    }

    // Already requested the key, and is still waiting for server's response.
    if (!requestedKeys.add(key.toString())) {
      Log.d(TAG, "data for key " + key + " requested but not cached yet");
      addListenerToTempStore(key, new CacheListener() {
        @Override
        public void onAdd(String myKey) {
          triggerDataLoadingEndIfNotNull(listener);
        }
      });
      return;
    }

    // Key is in the cache maintained by the client of the AddressWidget.
    String dataFromClientCache = clientCacheManager.get(key.toString());
    if (dataFromClientCache != null && dataFromClientCache.length() > 0) {
      final JsonHandler handler = new JsonHandler(key.toString(),
          existingJso, listener);
      try {
        handler.handleJson(JsoMap.buildJsoMap(dataFromClientCache));
        return;
      } catch (JSONException e) {
        Log.w(TAG, "Data from client's cache is in the wrong format: "
            + dataFromClientCache);
      }
    }

    // Key is not cached yet, now sending the request to the server.
    JsonpRequestBuilder jsonp = new JsonpRequestBuilder();
    jsonp.setTimeout(TIMEOUT);
    final JsonHandler handler = new JsonHandler(key.toString(),
        existingJso, listener);
    jsonp.requestObject(serviceUrl + "/" + key.toString(),
        new AsyncCallback<JsoMap>() {
          @Override
          public void onFailure(Throwable caught) {
            Log.w(TAG, "Request for key " + key + " failed");
            requestedKeys.remove(key.toString());
            notifyListenersAfterJobDone(key.toString());
            triggerDataLoadingEndIfNotNull(listener);
          }

          @Override
          public void onSuccess(JsoMap result) {
            handler.handleJson(result);
            // Put metadata into the cache maintained by the client of the
            // AddressWidget.
            String dataRetrieved = result.toString();
            clientCacheManager.put(key.toString(), dataRetrieved);
          }
        });
  }

  /**
   * Gets region data from our compiled-in java file and stores it in the
   * cache. This is only called when data cannot be obtained from the server,
   * so there will be no pre-existing data for this key.
   */
  void getFromRegionDataConstants(final LookupKey key) {
    checkNotNull(key, "null key not allowed.");
    String data = RegionDataConstants.getCountryFormatMap().get(
        key.getValueForUpperLevelField(AddressField.COUNTRY));
    if (data != null) {
      try {
        cache.putObj(key.toString(), JsoMap.buildJsoMap(data));
      } catch (JSONException e) {
        Log.w(TAG, "Failed to parse data for key " + key +
            " from RegionDataConstants");
      }
    }
  }

  /**
   * Retrieves string data identified by key.
   *
   * @param key Non-null key. E.g., "data/US/CA".
   * @return String value for specified key.
   */
  public String get(String key) {
    checkNotNull(key, "null key not allowed");
    return cache.get(key);
  }

  /**
   * Retrieves JsoMap data identified by key.
   *
   * @param key Non-null key. E.g., "data/US/CA".
   * @return String value for specified key.
   */
  public JsoMap getObj(String key) {
    checkNotNull(key, "null key not allowed");
    return cache.getObj(key);
  }

  private void notifyListenersAfterJobDone(String key) {
    LookupKey lookupKey = new LookupKey.Builder(key).build();
    HashSet<CacheListener> listeners = temporaryListenerStore.get(lookupKey);
    if (listeners != null) {
      for (CacheListener listener : listeners) {
        listener.onAdd(key.toString());
      }
      listeners.clear();
    }
  }

  private void addListenerToTempStore(LookupKey key, CacheListener listener) {
    checkNotNull(key);
    checkNotNull(listener);
    HashSet<CacheListener> listeners = temporaryListenerStore.get(key);
    if (listeners == null) {
      listeners = new HashSet<CacheListener>();
      temporaryListenerStore.put(key, listeners);
    }
    listeners.add(listener);
  }

  /**
   * Added for testing purposes.
   * Adds a new object into the cache.
   * @param id string of the format "data/country/.." ie. "data/US/CA"
   * @param object The JSONObject to be put into cache.
   */
  void addToJsoMap(String id, JSONObject object) {
    cache.putObj(id, object);
  }

  /**
   * Added for testing purposes.
   * Checks to see if the cache is empty,
   * @return true if the internal cache is empty
   */
  boolean isEmpty() {
    return cache.length() == 0;
  }
}