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