package com.openfeint.internal.ui; import java.io.BufferedReader; import java.io.ByteArrayInputStream; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.net.URI; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import javax.xml.parsers.SAXParser; import javax.xml.parsers.SAXParserFactory; import org.xml.sax.Attributes; import org.xml.sax.InputSource; import org.xml.sax.XMLReader; import org.xml.sax.helpers.DefaultHandler; import android.content.Context; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteDiskIOException; import android.database.sqlite.SQLiteStatement; import android.os.Environment; import android.os.Handler; import android.os.Message; import com.openfeint.api.ui.Dashboard; import com.openfeint.internal.OpenFeintInternal; import com.openfeint.internal.Util; import com.openfeint.internal.db.DB; import com.openfeint.internal.request.BaseRequest; import com.openfeint.internal.request.CacheRequest; import com.openfeint.internal.request.OrderedArgList; public class WebViewCache { final static String TAG = "WebViewCache"; //PUBLIC API public static URI serverOverride; //set before initialization public static String manifestProductOverride; //set before initialization public static WebViewCache initialize(Context context) { if (sInstance != null) { // stop any loading in progress. sInstance.finishWithoutLoading(); } sInstance = new WebViewCache(context); return sInstance; } public static void prioritize(String path) { sInstance.prioritizeInner(path); } public static boolean trackPath(String path, WebViewCacheCallback cb) { return sInstance.trackPathInner(path, cb); } public static boolean isLoaded(String path) { return sInstance.isLoadedInner(path); } private static final String WEBUI = "webui"; public final void setRootUriSdcard(final File path) { final File webui = new File(path, WEBUI); final boolean copyDefault = !webui.exists(); if (copyDefault) { File noMedia = new File(path, ".nomedia"); try { noMedia.createNewFile(); } catch (IOException e) { } if (!webui.mkdirs()) { setRootUriInternal(); return; } } rootPath = webui.getAbsolutePath() + "/"; rootUri = "file://"+ rootPath; if (copyDefault) { final File baseDir = appContext.getFilesDir(); final File inPhoneWebui = new File(baseDir, WEBUI); if (inPhoneWebui.isDirectory()) { try { // move copy db here so that // we could create db out of WebViewCache Util.copyFile(appContext.getDatabasePath(DB.DBNAME), new File(webui, DB.DBNAME)); } catch (IOException e) { } } Thread t = new Thread(new Runnable() { public void run() { try { if (inPhoneWebui.isDirectory()) { Util.copyDirectory(inPhoneWebui, webui); deleteAll(); OpenFeintInternal.log(TAG, "copy in phone data finish"); clientManifestReady(); } else { OpenFeintInternal.log(TAG, "copy from asset"); copyDefaultBackground(baseDir); } } catch (IOException e) { OpenFeintInternal.log(TAG, e.getMessage()); setRootUriInternal(); return; } } }); t.start(); return; } else { clientManifestReady(); } deleteAll(); } public final void setRootUriInternal() { OpenFeintInternal.log(TAG, "can't use sdcard"); final File baseDir = appContext.getFilesDir(); File rootDir = new File(baseDir, WEBUI); rootPath = rootDir.getAbsolutePath() +"/"; rootUri = "file://"+ rootPath; final File inPhoneWebui = new File(baseDir, WEBUI); boolean hasInPhoneData = inPhoneWebui.isDirectory(); if (!hasInPhoneData) { Thread t = new Thread(new Runnable() { public void run() { copyDefaultBackground(baseDir); } }); t.start(); } else { clientManifestReady(); } } public static final String getItemUri(String itemPath) { return rootUri + itemPath; } public static void start() { sInstance.updateExternalStorageState(); sInstance.sync(); } //PRIVATE API static WebViewCache sInstance; private static String rootUri; private static String rootPath; Handler mHandler; Handler mDelayHandler; Set<PathAndCallback> trackedPaths; Map<String, ItemAndCallback> trackedItems; static final int kServerManifestReady = 0; static final int kDataLoaded = 1; static final int kBatchLoaded = 2; static final int kClientManifestReady = 3; static final int kNumBatchRetries = 3; static final long kBatchRetryDelayMillis = 5000; boolean loadingFinished = false; boolean globalsFinished = false; WebViewCacheCallback delegate; ManifestData serverManifest; Map<String, String> clientManifest; Set<String> pathsToLoad; Set<String> prioritizedPaths; boolean batchesAreBroken = false; //determining state: // not loaded manifest yet: manifest == null, loadingFinished == NO // manifest failed: manifest == null, loadingFinished = YES // in process of loading items, manifest != null, loadingFinished = NO // all done loading manifest != null, loadinFinished = YES final URI serverURI = getServerURI(); Context appContext; //INNER CLASSES private static class ManifestItem { public String path; public String hash; public Set<String> dependentObjects; ManifestItem(String _path, String _hash) { path = _path; hash = _hash; dependentObjects = new HashSet<String>(); } ManifestItem(ManifestItem item) { path = item.path; dependentObjects = new HashSet<String>(item.dependentObjects); } } private static boolean diskError = false; public static void diskError() { diskError = true; for(PathAndCallback pathAndCb : sInstance.trackedPaths) { pathAndCb.callback.failLoaded(); } sInstance.trackedPaths.clear(); sInstance.finishWithoutLoading(); } private static class ManifestData { Set<String> globals = new HashSet<String>(); Map<String, ManifestItem> objects = new HashMap<String, ManifestItem>(); ManifestData(SQLiteDatabase db) { Cursor result = null; try { result = db.rawQuery("SELECT path, hash, is_global FROM server_manifest", null); if(result.getCount() > 0) { result.moveToFirst(); do { String path = result.getString(0); String hash = result.getString(1); boolean isGlobal = result.getInt(2) != 0; objects.put(path, new ManifestItem(path, hash)); if (isGlobal) globals.add(path); } while (result.moveToNext()); } result.close(); for (String path: objects.keySet()) { // I can't compile this query because it returns multiple rows. Thanks, inexplicable Java limitations result = db.rawQuery("SELECT has_dependency FROM dependencies WHERE path = ?", new String[] {path}); if(result.getCount() > 0) { final ManifestItem manifestItem = objects.get(path); if (null != manifestItem) { Set<String> deps = manifestItem.dependentObjects; result.moveToFirst(); do { deps.add(result.getString(0)); } while (result.moveToNext()); } } result.close(); } } catch (SQLiteDiskIOException e) { WebViewCache.diskError(); } catch (Exception e) { OpenFeintInternal.log(TAG, "SQLite exception. " + e.toString()); } finally { try { result.close(); } catch (Exception jeez) {} } } void saveTo(SQLiteDatabase db) { try { db.beginTransaction(); db.execSQL("DELETE FROM server_manifest;"); db.execSQL("DELETE FROM dependencies;"); SQLiteStatement insertIntoManifest = db.compileStatement("INSERT INTO server_manifest(path, hash, is_global) VALUES(?, ?, ?)"); SQLiteStatement insertIntoDependencies = db.compileStatement("INSERT INTO dependencies(path, has_dependency) VALUES(?, ?)"); for (String path : objects.keySet()) { final ManifestItem item = objects.get(path); insertIntoManifest.bindString(1, path); insertIntoManifest.bindString(2, item.hash); insertIntoManifest.bindLong(3, globals.contains(path) ? 1 : 0); insertIntoManifest.execute(); insertIntoDependencies.bindString(1, path); for (String dep : item.dependentObjects) { insertIntoDependencies.bindString(2, dep); insertIntoDependencies.execute(); } } db.setTransactionSuccessful(); } catch (SQLiteDiskIOException e) { diskError(); } catch (Exception e) { OpenFeintInternal.log(TAG, "SQLite exception. " + e.toString()); } finally { try { db.endTransaction(); } catch (Exception whatever_man) {} } } ManifestData(byte[] stm) throws Exception { String line; ManifestItem item = null; try { InputStreamReader reader = new InputStreamReader(new ByteArrayInputStream(stm)); BufferedReader buffered = new BufferedReader(reader, 8192); while((line = buffered.readLine()) != null) { line = line.trim(); if(line.length() == 0) continue; switch(line.charAt(0)) { case '#': //comment, do nothing break; case '-': if(item != null) { item.dependentObjects.add(line.substring(1).trim()); } else { throw new Exception("Manifest Syntax Error: Dependency without an item"); } break; default: String[] pieces = line.split(" "); String path; if(pieces.length >= 2) { if(pieces[0].charAt(0) == '@') { path = pieces[0].substring(1); globals.add(path); } else { path = pieces[0]; } item = new ManifestItem(path, pieces[1]); objects.put(path, item); } else { throw new Exception("Manifest Syntax Error: Extra items in line"); } //new object break; } } } catch (Exception e) { throw new Exception(e); //this will tell the loader it failed } } } //structures for use inside collections private static class ItemAndCallback { public final ManifestItem item; public final WebViewCacheCallback callback; public ItemAndCallback(ManifestItem _item, WebViewCacheCallback _cb) { item = _item; callback = _cb; } } private static class PathAndCallback { public final String path; public final WebViewCacheCallback callback; public PathAndCallback(String _path, WebViewCacheCallback _cb) { path = _path; callback = _cb; } } private boolean trackPathInner(String path, WebViewCacheCallback cb) { if(loadingFinished) { cb.pathLoaded(path); return false; //all done, so report as loaded } if(serverManifest == null) { cb.onTrackingNeeded(); trackedPaths.add(new PathAndCallback(path, cb)); //store for later return true; } else { ManifestItem loadedItem = serverManifest.objects.get(path); if(loadedItem != null) { //this is in fact an item in the manifest cb.onTrackingNeeded(); ManifestItem newItem = new ManifestItem(loadedItem); newItem.dependentObjects.retainAll(pathsToLoad); trackedItems.put(path, new ItemAndCallback(newItem, cb)); return true; } else { //not in the manifest cb.pathLoaded(path); return false; } } } private boolean isLoadedInner(String path) { if(serverManifest == null) return loadingFinished; //if not loaded yet, say No, if no manifest was found say Yes if(pathsToLoad.contains(path)) return false; return true; } private WebViewCache(Context _appContext) { appContext = _appContext; trackedPaths = new HashSet<PathAndCallback>(); trackedItems = new HashMap<String, ItemAndCallback>(); pathsToLoad = new HashSet<String>(); prioritizedPaths = new HashSet<String>(); mDelayHandler = new Handler(); mHandler = new Handler() { @Override @SuppressWarnings("unchecked") public void dispatchMessage(Message msg) { //the message will contain things like server manifest loaded and item finished //forwards to appropriate method //this will send callbacks to the registered delegate switch(msg.what) { case kServerManifestReady: OpenFeintInternal.log(TAG, "kServerManifestReady"); serverManifest = (ManifestData)msg.obj; triggerUpdates(); break; case kDataLoaded: finishItem((String) msg.obj, msg.arg1 > 0); break; case kBatchLoaded: finishItems((Set<String>) msg.obj, msg.arg1 > 0); break; case kClientManifestReady: clientManifest = (Map<String, String>)msg.obj; triggerUpdates(); break; } } }; } private static final String OPENFEINT_ROOT = "openfeint"; private void updateExternalStorageState() { if (Util.noSdcardPermission()) { OpenFeintInternal.log(TAG, "no sdcard permission"); setRootUriInternal(); return; } String state = Environment.getExternalStorageState(); if (Environment.MEDIA_MOUNTED.equals(state)) { File sdcard = Environment.getExternalStorageDirectory(); File feintRoot = new File(sdcard, OPENFEINT_ROOT); setRootUriSdcard(feintRoot); } else { OpenFeintInternal.log(TAG, state); setRootUriInternal(); } } // TODO: delay get clientManifest till we have server update private void sync() { OpenFeintInternal.log(TAG, "--- WebViewCache Sync ---"); //start loading the server manifest on a thread, it will call back to the handle ManifestRequest req = new ManifestRequest(ManifestRequestKey); req.launch(); } private static final String ManifestRequestKey = "manifest"; private class ManifestRequest extends CacheRequest { private ManifestData data = null; public ManifestRequest(String key) { super(key); } @Override public boolean signed() { return false; } @Override public String path() { return WebViewCache.getManifestPath(appContext); } // This is a NOP - all the work is done off the main thread. @Override public void onResponse(int responseCode, byte[] body) {} @Override public void onResponseOffMainThread(int responseCode, byte[] body) { if(responseCode == 200) { try { data = new ManifestData(body); } catch (Exception e) { OpenFeintInternal.log(TAG, e.toString()); } } else { // try to load the old manifest, if there's been no change. try { data = new ManifestData(DB.storeHelper.getReadableDatabase()); } catch (Exception e) { OpenFeintInternal.log(TAG, e.toString()); } } // 1) if it's a 304 but we've no manifest in the db, we need to download it anyway. if (data == null || data.objects.isEmpty()) { // get rid of any empty manifest data = null; new BaseRequest() { @Override public String method() { return "GET"; } @Override public String path() { return ManifestRequest.this.path(); } @Override public void onResponse(int responseCode, byte[] body) {} // @NOP, see below. @Override public void onResponseOffMainThread(int responseCode, byte[] body) { if (200 == responseCode) { try { data = new ManifestData(body); // Update the ManifestRequest's etag from our headers. finishManifest(); ManifestRequest.this.updateLastModifiedFromResponse(getResponse()); } catch (Exception e) {} } else { OpenFeintInternal.log(TAG, "finishWithoutLoading " + responseCode); finishWithoutLoading(); } } }.launch(); } else { finishManifest(); ManifestRequest.this.updateLastModifiedFromResponse(getResponse()); } } private void finishManifest() { if (data != null) { try { data.saveTo(DB.storeHelper.getWritableDatabase()); } catch (Exception e) { OpenFeintInternal.log(TAG, e.toString()); } Message msg = Message.obtain(mHandler, kServerManifestReady, data); msg.sendToTarget(); } else { finishWithoutLoading(); } } } private void deleteAll() { File baseDir = appContext.getFilesDir(); File webui = new File(baseDir, WEBUI); Util.deleteFiles(webui); appContext.getDatabasePath(DB.DBNAME).delete(); } private void gatherDefaultItems(String path, Set<String> items) { try { String [] stuff = appContext.getAssets().list(path); for(String s : stuff) { String fullpath = path + "/" + s; try { InputStream check = appContext.getAssets().open(fullpath); items.add(fullpath); check.close(); } catch (IOException e) { //must not have been a file gatherDefaultItems(fullpath, items); } } } catch (IOException e) { OpenFeintInternal.log(TAG, e.toString()); } } private void copySingleItem(File baseDir, String path) { try { File filePath = new File(baseDir, path); InputStream inputStream = appContext.getAssets().open(path); DataInputStream reader = new DataInputStream(inputStream); filePath.getParentFile().mkdirs(); FileOutputStream fileStream = new FileOutputStream(filePath); DataOutputStream writer = new DataOutputStream(fileStream); Util.copyStream(reader, writer); } catch(Exception e) { OpenFeintInternal.log(TAG, e.toString()); } } private Set<String> stripUnused(Set<String>table) { String currentDpi = Util.getDpiName(appContext); String test = currentDpi.equals("mdpi") ? ".hdpi." : ".mdpi."; Set<String> reducedSet = new HashSet<String>(); for(String path : table) { if(!path.contains(test)) reducedSet.add(path); } return reducedSet; } private void copySpecific(File baseDir, String path, Set<String> items) { if(items.contains(path)) { copySingleItem(baseDir, path); items.remove(path); } } private void copyDirectory(File baseDir, String root, Set<String> items) { Set<String> dirItems = new HashSet<String>(); for(String path : items) { if(path.startsWith(root)) dirItems.add(path); } for(String path : dirItems) copySpecific(baseDir, path, items); } //TODO: prioritize the manifest and introflow loading private void copyDefaultBackground(File baseDir) { Set<String> defaultItems = new HashSet<String>(); gatherDefaultItems(WEBUI, defaultItems); defaultItems = stripUnused(defaultItems); copySpecific(baseDir, "webui/manifest.plist", defaultItems); copyDirectory(baseDir, "webui/javascripts/", defaultItems); copyDirectory(baseDir, "webui/stylesheets/", defaultItems); copyDirectory(baseDir, "webui/intro/", defaultItems); if(Util.getDpiName(appContext).equals("mdpi")) { copySpecific(baseDir, "webui/images/space.grid.mdpi.png", defaultItems); copySpecific(baseDir, "webui/images/button.gray.mdpi.png", defaultItems); copySpecific(baseDir, "webui/images/button.gray.hit.mdpi.png", defaultItems); copySpecific(baseDir, "webui/images/button.green.mdpi.png", defaultItems); copySpecific(baseDir, "webui/images/button.green.hit.mdpi.png", defaultItems); copySpecific(baseDir, "webui/images/logo.small.mdpi.png", defaultItems); copySpecific(baseDir, "webui/images/header_bg.mdpi.png", defaultItems); copySpecific(baseDir, "webui/images/loading.spinner.mdpi.png", defaultItems); copySpecific(baseDir, "webui/images/input.text.mdpi.png", defaultItems); copySpecific(baseDir, "webui/images/frame.small.mdpi.png", defaultItems); copySpecific(baseDir, "webui/images/icon.leaf.gray.mdpi.png", defaultItems); copySpecific(baseDir, "webui/images/tab.divider.mdpi.png", defaultItems); copySpecific(baseDir, "webui/images/tab.active_indicator.mdpi.png", defaultItems); copySpecific(baseDir, "webui/images/logo.mdpi.png", defaultItems); copySpecific(baseDir, "webui/images/header_bg.mdpi.png", defaultItems); copySpecific(baseDir, "webui/images/loading.spinner.mdpi.png", defaultItems); copySpecific(baseDir, "webui/images/icon.user.male.mdpi.png", defaultItems); copySpecific(baseDir, "webui/images/intro.leaderboards.mdpi.png", defaultItems); copySpecific(baseDir, "webui/images/intro.friends.mdpi.png", defaultItems); copySpecific(baseDir, "webui/images/intro.achievements.mdpi.png", defaultItems); copySpecific(baseDir, "webui/images/intro.games.mdpi.png", defaultItems); } else { copySpecific(baseDir, "webui/images/space.grid.hdpi.png", defaultItems); copySpecific(baseDir, "webui/images/button.gray.hdpi.png", defaultItems); copySpecific(baseDir, "webui/images/button.gray.hit.hdpi.png", defaultItems); copySpecific(baseDir, "webui/images/button.green.hdpi.png", defaultItems); copySpecific(baseDir, "webui/images/button.green.hit.hdpi.png", defaultItems); copySpecific(baseDir, "webui/images/logo.small.hdpi.png", defaultItems); copySpecific(baseDir, "webui/images/header_bg.hdpi.png", defaultItems); copySpecific(baseDir, "webui/images/loading.spinner.hdpi.png", defaultItems); copySpecific(baseDir, "webui/images/input.text.hdpi.png", defaultItems); copySpecific(baseDir, "webui/images/frame.small.hdpi.png", defaultItems); copySpecific(baseDir, "webui/images/icon.leaf.gray.hdpi.png", defaultItems); copySpecific(baseDir, "webui/images/tab.divider.hdpi.png", defaultItems); copySpecific(baseDir, "webui/images/tab.active_indicator.hdpi.png", defaultItems); copySpecific(baseDir, "webui/images/logo.hdpi.png", defaultItems); copySpecific(baseDir, "webui/images/header_bg.hdpi.png", defaultItems); copySpecific(baseDir, "webui/images/loading.spinner.hdpi.png", defaultItems); copySpecific(baseDir, "webui/images/icon.user.male.hdpi.png", defaultItems); copySpecific(baseDir, "webui/images/intro.leaderboards.hdpi.png", defaultItems); copySpecific(baseDir, "webui/images/intro.friends.hdpi.png", defaultItems); copySpecific(baseDir, "webui/images/intro.achievements.hdpi.png", defaultItems); copySpecific(baseDir, "webui/images/intro.games.hdpi.png", defaultItems); } clientManifestReady(); for(String path : defaultItems) { copySingleItem(baseDir, path); } } private void clientManifestReady() { Object obj = getDefaultClientManifest(); if (obj == null) return; Message msg = Message.obtain(mHandler, kClientManifestReady); msg.obj = obj; msg.sendToTarget(); } private class SaxHandler extends DefaultHandler { String loadingString; String key; Map<String, String> outputMap = new HashMap<String, String>(); public Map<String, String> getOutputMap() { return outputMap; } @Override public void startElement(String uri, String name, String qName, Attributes attr) { loadingString = ""; } @Override public void endElement(String uri, String name, String qName) { String clipped = name.trim(); if(clipped.equals("key")) key = loadingString; else if(clipped.equals("string")) { outputMap.put(key, loadingString); DB.setClientManifest(key, loadingString); } } @Override public void characters(char ch[], int start, int length) { loadingString = new String(ch).substring(start, start + length); } } static boolean isDiskError() { return diskError; } static boolean recover() { if (diskError) return false; return sInstance.recoverInternal(); } void markSyncRequired() { loadingFinished = false; globalsFinished = false; } boolean recoverInternal() { boolean success = DB.recover(appContext); serverManifest = null; if (success) { clientManifest = getDefaultClientManifestFromAsset(); success = clientManifest != null; } markSyncRequired(); sync(); return success; } // This doesn't throw. It'll return an empty manifest if there's a problem. private Map<String, String> getDefaultClientManifest() { Cursor result = null; SQLiteDatabase db = null; try { db = DB.storeHelper.getReadableDatabase(); result = db.rawQuery("SELECT * FROM manifest", null); if(result.getCount() > 0) { //database exists, use it final Map<String, String> outManifest = new HashMap<String, String>(); result.moveToFirst(); do { String path = result.getString(0); String hash = result.getString(1); outManifest.put(path, hash); } while (result.moveToNext()); result.close(); OpenFeintInternal.log(TAG, "create client Manifest from db"); return outManifest; } } catch (SQLiteDiskIOException e) { WebViewCache.diskError(); } catch (Exception e) { // Some SQLite exception, doesn't matter. We'll fall through and return the asset manifest. OpenFeintInternal.log(TAG, "SQLite exception. " + e.toString()); // @TEMP } finally { try { result.close(); } catch (Exception jeez) {} } return getDefaultClientManifestFromAsset(); } // This doesn't throw. It'll return an empty manifest if there's a problem. private Map<String, String> getDefaultClientManifestFromAsset() { //read from the file File manifestFile = new File(rootPath, "manifest.plist"); if(manifestFile.isFile()) { try { SAXParserFactory spf = SAXParserFactory.newInstance(); SAXParser sp = spf.newSAXParser(); XMLReader xr = sp.getXMLReader(); SaxHandler handler = new SaxHandler(); xr.setContentHandler(handler); InputStream inputStream = new FileInputStream(manifestFile.getPath()); xr.parse(new InputSource(inputStream)); return handler.getOutputMap(); } catch(Exception e) { OpenFeintInternal.log(TAG, e.toString()); } } return new HashMap<String, String>(); } static private final URI getServerURI() { try { if(serverOverride != null) return serverOverride; return new URI(OpenFeintInternal.getInstance().getServerUrl()); } catch(Exception e) { return null; } } static private final String getManifestPath(Context ctx) { final String platform = "android"; final String product = manifestProductOverride != null ? manifestProductOverride : "embed"; return String.format("/webui/manifest/%s.%s.%s", platform, product, Util.getDpiName(ctx)); } private void triggerUpdates() { OpenFeintInternal.log(TAG, "loadedManifest"); // If both the server and client manifest are ready, we'll go. If not, we'll wait on the other. if(serverManifest != null && clientManifest != null) { //set up the itemsToLoad from the manifest for(ManifestItem item : serverManifest.objects.values()) { if(!item.hash.equals(clientManifest.get(item.path))) { pathsToLoad.add(item.path); } } loadNextItem(); } } private void finishWithoutLoading() { OpenFeintInternal.log(TAG, "finishWithoutLoading"); //no manifest, so tell anyone waiting we are finished for(PathAndCallback pathAndCb : trackedPaths) { pathAndCb.callback.pathLoaded(pathAndCb.path); } trackedPaths.clear(); prioritizedPaths.clear(); serverManifest.globals.clear(); pathsToLoad.clear(); finishLoading(); } private void finishLoading() { DB.storeHelper.close(); loadingFinished = true; } private void batchFetch(final String url, final int retriesLeft, final Set<String> paths) { new BaseRequest() { @Override public boolean signed() { return false; } @Override public String method() { return "GET"; } @Override public String path() { return ""; } @Override public String url() { return url; } @Override public void onResponse(int responseCode, final byte[] body) {} // nop, interesting stuff happens off main thread. @Override public void onResponseOffMainThread(int responseCode, final byte[] body) { handleBatchBody(responseCode, body, currentURL(), retriesLeft, paths); } }.launch(); } private void batchRequest(final Set<String> paths) { OpenFeintInternal.log(TAG, String.format("Syncing %d items", paths.size())); OrderedArgList oal = new OrderedArgList(); for (String s : paths) { final ManifestItem manifestItem = serverManifest.objects.get(s); oal.put("files[][path]", manifestItem.path); oal.put("files[][hash]", manifestItem.hash); } new BaseRequest(oal) { @Override public boolean signed() { return false; } @Override public String method() { return "POST"; } @Override public String path() { return "/webui/assets"; } @Override protected void onResponseOffMainThread(int responseCode, byte[] body) { handleBatchBody(responseCode, body, currentURL(), kNumBatchRetries, paths); } @Override public void onResponse(int responseCode, byte[] body) {} // nop, all the interesting this happens off main thread }.launch(); } private void handleBatchBody(final int responseCode, final byte[] body, final String url, final int retriesLeft, final Set<String> paths) { if (0 == responseCode || (200 <= responseCode && responseCode < 300)) { processBatch(paths, body); } else if (302 == responseCode || 303 == responseCode) { // redirect without decreasing the retry counter. batchFetch(url, retriesLeft, paths); } else if (400 <= responseCode && responseCode < 500) { if (retriesLeft > 0) { // sleep and retry mDelayHandler.postDelayed(new Runnable() { public void run() { batchFetch(url, retriesLeft-1, paths); } }, kBatchRetryDelayMillis); } else { // failure Message msg = Message.obtain(mHandler, kBatchLoaded, 0, 0, paths); msg.sendToTarget(); } } else { // no good. give up. Message msg = Message.obtain(mHandler, kBatchLoaded, 0, 0, paths); msg.sendToTarget(); } } private void finishGlobals() { //now scan the trackedPath items and callback any that aren't being loaded //this is done second pass so it will find already loaded items or ones not in manifest for(PathAndCallback pathAndCb : trackedPaths) { if(!pathsToLoad.contains(pathAndCb.path)) { pathAndCb.callback.pathLoaded(pathAndCb.path); } else { //still needs loading, move to the item tracking ManifestItem item = serverManifest.objects.get(pathAndCb.path); ManifestItem newItem = new ManifestItem(item); newItem.dependentObjects.retainAll(pathsToLoad); trackedItems.put(pathAndCb.path, new ItemAndCallback(newItem, pathAndCb.callback)); } } trackedPaths.clear(); //now check the prioritized items and add any dependencies Set<String> priorityDependents = new HashSet<String>(); for(String path : prioritizedPaths) { if(!pathsToLoad.contains(path)) continue; ManifestItem item = serverManifest.objects.get(path); if(item != null) { priorityDependents.addAll(item.dependentObjects); } } priorityDependents.retainAll(pathsToLoad); //keep only the ones we really want prioritizedPaths.addAll(priorityDependents); globalsFinished = true; } private void loadNextItem() { OpenFeintInternal.log(TAG, "loadNextItem"); serverManifest.globals.retainAll(pathsToLoad); //cleanup of anything not in the loading item list if (!globalsFinished && serverManifest.globals.isEmpty()) { finishGlobals(); } prioritizedPaths.retainAll(pathsToLoad); //technically, this should be redundant, but I'm being defensive int numGlobalsAndPrioritized = serverManifest.globals.size() + prioritizedPaths.size(); if (!batchesAreBroken && numGlobalsAndPrioritized > 1) { Set<String> combinedGlobalsAndPrio = new HashSet<String>(); combinedGlobalsAndPrio.addAll(serverManifest.globals); combinedGlobalsAndPrio.addAll(prioritizedPaths); batchRequest(combinedGlobalsAndPrio); } else if(serverManifest.globals.size() > 0) { singleRequest(serverManifest.globals.iterator().next()); } else if(prioritizedPaths.size() > 0) { singleRequest(prioritizedPaths.iterator().next()); } else if(!batchesAreBroken && pathsToLoad.size() > 1) { batchRequest(pathsToLoad); } else if(pathsToLoad.size() > 0) { singleRequest(pathsToLoad.iterator().next()); } else { finishLoading(); } } private final void singleRequest(final String finalPath) { OpenFeintInternal.log(TAG, "Syncing item: "+ finalPath); new BaseRequest() { @Override public boolean signed() { return false; } @Override public String method() { return "GET"; } @Override public String path() { return "/webui/" + finalPath; } @Override public void onResponse(int responseCode, byte[] body) { if(responseCode != 200) { Message msg = Message.obtain(mHandler, kDataLoaded, 0, 0, finalPath); msg.sendToTarget(); return; } try { Util.saveFile(body, rootPath + finalPath); } catch (Exception e) { //anything goes wrong, just fail out Message msg = Message.obtain(mHandler, kDataLoaded, 0, 0, finalPath); msg.sendToTarget(); return; } //TODO: handle thread interruptions? Message msg = Message.obtain(mHandler, kDataLoaded, 1, 0, finalPath); msg.sendToTarget(); } }.launch(); } private void finishItem(String path, boolean succeeded) { HashSet<String> tiny = new HashSet<String>(1); tiny.add(path); finishItems(tiny, succeeded); } private void finishItems(Set<String> paths, boolean succeeded) { if (serverManifest == null) return; if (!succeeded) { // There was a failure in downloading the batch. // revert to single-item downloads and continue. batchesAreBroken = true; } else { //first pass, remove from items to load, and dependencies for(ItemAndCallback itemAndCb : trackedItems.values()) { itemAndCb.item.dependentObjects.removeAll(paths); } pathsToLoad.removeAll(paths); serverManifest.globals.removeAll(paths); prioritizedPaths.removeAll(paths); //second pass, send callbacks if a tracked item doesn't have anything more to load if (globalsFinished) { HashSet<String> pathsToRemove = new HashSet<String>(); for(ItemAndCallback itemAndCb: trackedItems.values()) { if(!pathsToLoad.contains(itemAndCb.item.path) && itemAndCb.item.dependentObjects.size() == 0) { pathsToRemove.add(itemAndCb.item.path); itemAndCb.callback.pathLoaded(itemAndCb.item.path); } } for(String removePath: pathsToRemove) { trackedItems.remove(removePath); } } //update local manifest String pathsArray[] = new String[paths.size()]; String hashArray[] = new String[pathsArray.length]; int i=0; for (String path : paths) { final String hashValue = serverManifest.objects.get(path).hash; pathsArray[i] = path; hashArray[i] = hashValue; ++i; clientManifest.put(path, hashValue); } DB.setClientManifestBatch(pathsArray, hashArray); } loadNextItem(); } private void prioritizeInner(String path) { if(loadingFinished) return; prioritizedPaths.add(path); if(serverManifest != null) { //have the manifest, so add all the dependencies ManifestItem item = serverManifest.objects.get(path); if(item != null) { Set<String> loadingDependents = new HashSet<String>(item.dependentObjects); // OpenFeintInternal.log("WebViewCache", "Dep:" + loadingDependents.toString()); // OpenFeintInternal.log("WebViewCache", "TOTAL:" + pathsToLoad.toString()); loadingDependents.retainAll(pathsToLoad); prioritizedPaths.addAll(loadingDependents); OpenFeintInternal.log("WebViewCache", "Prioritizing " + path + " deps:" + loadingDependents.toString()); } } } // run this off the main thread, there is computation that happens here. private void processBatch(final Set<String> paths, final byte[] body) { // qualified success. final HashSet<String> fetchedPaths = new HashSet<String>(); final ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(body)); try { ZipEntry ze = null; while ((ze = zis.getNextEntry()) != null) { if (!ze.isDirectory()) { final String finalPath = ze.getName(); Util.saveStreamAndLeaveInputOpen(zis, rootPath + finalPath); fetchedPaths.add(finalPath); } } } catch (Exception e) { // That's sad OpenFeintInternal.log(TAG, e.getMessage()); } if (!fetchedPaths.isEmpty()) { Message msg = Message.obtain(mHandler, kBatchLoaded, 1, 0, fetchedPaths); msg.sendToTarget(); } else { Message msg = Message.obtain(mHandler, kBatchLoaded, 0, 0, paths); msg.sendToTarget(); } } // For testing use only! public static class TestOnlyManifestItem { public String path; public String clientHash; public String serverHash; public TestOnlyManifestItem(String _path, String _clientHash, String _serverHash) { path = _path; clientHash = _clientHash; serverHash = _serverHash; } public enum Status { NotYetDownloaded, NotOnServer, UpToDate, OutOfDate, } public Status status() { if (null == clientHash) return Status.NotYetDownloaded; if (null == serverHash) return Status.NotOnServer; if (serverHash.equals(clientHash)) return Status.UpToDate; return Status.OutOfDate; } public void invalidate() { // clear it in the DB DB.setClientManifest(path, "INVALID"); // clear it in the in-memory client manifest sInstance.clientManifest.put(path, "INVALID"); // remove it from the file system Util.deleteFiles(new File(rootPath + path)); // Trigger a sync sInstance.markSyncRequired(); } public static void syncAndOpenDashboard() { if (!sInstance.loadingFinished) { sInstance.serverManifest = null; sInstance.sync(); } Dashboard.open(); } } // In sqlite, you simulate a full outer join by UNIONing together two left outer joins with the tables switched. // This is just a convenience function for generating this query in a slightly more readable manner. private static String fullOuterJoin(String fields, String table1, String table2, String condition) { String join1 = String.format("SELECT %s from %s LEFT OUTER JOIN %s ON %s", fields, table1, table2, condition); String join2 = String.format("SELECT %s from %s LEFT OUTER JOIN %s ON %s", fields, table2, table1, condition); return String.format("%s UNION %s;", join1, join2); } public static TestOnlyManifestItem[] testOnlyManifestItems() { final SQLiteDatabase db = DB.storeHelper.getReadableDatabase(); Cursor result = null; ArrayList<TestOnlyManifestItem> items = new ArrayList<TestOnlyManifestItem>(); try { result = db.rawQuery( fullOuterJoin("server_manifest.path, server_manifest.hash, manifest.hash", "server_manifest", "manifest", "server_manifest.path = manifest.path"), null); if(result.getCount() > 0) { result.moveToFirst(); do { String path = result.getString(0); if (path != null) { // yes, this actually happened to me String serverHash = result.getString(1); String clientHash = result.getString(2); items.add(new TestOnlyManifestItem(path, clientHash, serverHash)); } } while (result.moveToNext()); } } catch (Exception e) { } finally { try { result.close(); } catch (Exception e) {} } // sort rv by path TestOnlyManifestItem[] rv = items.toArray(new TestOnlyManifestItem[]{}); Arrays.sort(rv, new Comparator<TestOnlyManifestItem>() { public int compare(TestOnlyManifestItem lhs, TestOnlyManifestItem rhs) { return lhs.path.compareTo(rhs.path); } }); return rv; } }