/* Viewer for Khan Academy Copyright (C) 2012 Concentric Sky, Inc. This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. */ package com.concentricsky.android.khanacademy.data.remote; import static com.concentricsky.android.khanacademy.Constants.RESULT_CODE_FAILURE; import static com.concentricsky.android.khanacademy.Constants.RESULT_CODE_SUCCESS; import static com.concentricsky.android.khanacademy.Constants.SETTINGS_NAME; import static com.concentricsky.android.khanacademy.Constants.SETTING_LIBRARY_ETAG; import java.io.IOException; import java.io.InputStream; import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.URL; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import org.apache.http.HttpStatus; import android.content.ComponentName; import android.content.ContentValues; import android.content.Context; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.net.ConnectivityManager; import android.net.NetworkInfo; import android.os.AsyncTask; import com.concentricsky.android.khan.R; import com.concentricsky.android.khanacademy.WifiReceiver; import com.concentricsky.android.khanacademy.data.KADataService; import com.concentricsky.android.khanacademy.data.db.DatabaseHelper; import com.concentricsky.android.khanacademy.data.db.Video; import com.concentricsky.android.khanacademy.util.Log; import com.fasterxml.jackson.core.JsonFactory; import com.fasterxml.jackson.core.JsonParseException; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonToken; import com.j256.ormlite.android.apptools.OrmLiteSqliteOpenHelper; import com.j256.ormlite.support.ConnectionSource; public class LibraryUpdaterTask extends AsyncTask<Void, Void, Integer> { public static final String LOG_TAG = LibraryUpdaterTask.class.getSimpleName(); private final OrmLiteSqliteOpenHelper tempDbHelper; private final String topicTableName = "topic"; private final String videoTableName = "video"; private final String topicvideoTableName = "topicvideo"; private final KADataService dataService; private final ConnectivityManager connectivityManager; private final String url = "http://www.khanacademy.org/api/v1/topictree"; public boolean force = false; private static final List<String> downloadUrlFields = Arrays.asList(new String[] {"png", "mp4", "m3u8"}); private static final List<String> stringFields = Arrays.asList(new String[] {"kind", "title", "description", "ka_url", "id", "readable_id", "youtube_id", "keywords", "progress_key", "date_added"}); // 10 private static final List<String> booleanFields = Arrays.asList(new String[] {"hide"}); private static final List<String> intFields = Arrays.asList(new String[] {"views", "duration"}); // "children", "download_urls" are special /** The return values we need when parsing an array of children. */ private class ChildArrayResults { List<String> childIds = new ArrayList<String>(); String childKind; int videoCount; String thumbId; } public LibraryUpdaterTask(KADataService dataService) { this.dataService = dataService; this.tempDbHelper = new TempHelper(dataService, dataService.getHelper().getReadableDatabase()); connectivityManager = (ConnectivityManager) dataService.getSystemService(Context.CONNECTIVITY_SERVICE); } @Override protected Integer doInBackground(Void... params) { SharedPreferences prefs = dataService.getSharedPreferences(SETTINGS_NAME, Context.MODE_PRIVATE); // Connectivity receiver. final NetworkInfo activeNetwork = connectivityManager.getActiveNetworkInfo(); ComponentName receiver = new ComponentName(dataService, WifiReceiver.class); PackageManager pm = dataService.getPackageManager(); if (activeNetwork == null || !activeNetwork.isConnected()) { // We've missed a scheduled update. Enable the receiver so it can launch an update when we reconnect. Log.d(LOG_TAG, "Missed library update: not connected. Enabling connectivity receiver."); pm.setComponentEnabledSetting(receiver, PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP); return RESULT_CODE_FAILURE; } else { // We are connected. Disable the receiver. Log.d(LOG_TAG, "Library updater connected. Disabling connectivity receiver."); pm.setComponentEnabledSetting(receiver, PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP); } InputStream in = null; String etag = prefs.getString(SETTING_LIBRARY_ETAG, null); try { HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); if (etag != null && !force) { conn.setRequestProperty("If-None-Match", etag); } int code = conn.getResponseCode(); switch (code) { case HttpStatus.SC_NOT_MODIFIED: // If we got a 304, we're done. // Use failure code to indicate there is no temp db to copy over. Log.d(LOG_TAG, "304 in library response."); return RESULT_CODE_FAILURE; default: // Odd, but on 1/3/13 I received correct json responses with a -1 for responseCode. Fall through. Log.w(LOG_TAG, "Error code in library response: " + code); case HttpStatus.SC_OK: // Parse response. in = conn.getInputStream(); JsonFactory factory = new JsonFactory(); final JsonParser parser = factory.createJsonParser(in); SQLiteDatabase tempDb = tempDbHelper.getWritableDatabase(); tempDb.beginTransaction(); try { tempDb.execSQL("delete from topic"); tempDb.execSQL("delete from topicvideo"); tempDb.execSQL("delete from video"); parseObject(parser, tempDb, null, 0); tempDb.setTransactionSuccessful(); } catch (Exception e) { e.printStackTrace(); return RESULT_CODE_FAILURE; } finally { tempDb.endTransaction(); tempDb.close(); } // Save etag once we've successfully parsed the response. etag = conn.getHeaderField("ETag"); prefs.edit().putString(SETTING_LIBRARY_ETAG, etag).apply(); // Move this new content from the temp db into the main one. mergeDbs(); return RESULT_CODE_SUCCESS; } } catch (MalformedURLException e) { e.printStackTrace(); } catch (JsonParseException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } finally { tempDbHelper.close(); } return RESULT_CODE_FAILURE; } private ContentValues parseObject(JsonParser parser, SQLiteDatabase tempDb, String parentId, int seq) throws JsonParseException, IOException { // TODO : Grab id of root topic here, and store it in shared prefs, in case it ever // changes. Currently we assume "root" and a change would be catastrophic. ContentValues result = new ContentValues(); ChildArrayResults childResults = null; boolean badKind = false; result.put("parentTopic_id", parentId); result.put("seq", seq); while (parser.nextValue() != JsonToken.END_OBJECT) { // Allows us to burn through the rest of the object once we discover it's an exercise or something else we don't care about. if (badKind) continue; String fieldName = parser.getCurrentName(); // Keys present will determine object type. if (stringFields.contains(fieldName)) { // Use getValueAsString over getText; getText returns "null" while getValueAsString returns null. String value = parser.getValueAsString(); result.put(fieldName, value); if ("id".equals(fieldName)) { if (childResults != null) { addParentIdToChildren(tempDb, childResults, value); } } } else if (intFields.contains(fieldName)) { result.put(fieldName, parser.getIntValue()); } else if (booleanFields.contains(fieldName)) { result.put(fieldName, parser.getBooleanValue()); } else if ("children".equals(fieldName)) { childResults = parseChildArray(parser, tempDb, result.containsKey("id") ? result.getAsString("id") : null); result.put("video_count", childResults.videoCount); result.put("child_kind", childResults.childKind); result.put("thumb_id", childResults.thumbId); } else if ("download_urls".equals(fieldName)) { parseDownloadUrls(parser, result); } else if (null == fieldName) { // Noop. Just in case. } else { JsonToken next = parser.getCurrentToken(); if (next == JsonToken.START_OBJECT || next == JsonToken.START_ARRAY) { // Skip this object or array, leaving us pointing at the matching end_object / end_array token. parser.skipChildren(); } } } // Ignore types we don't need. if (badKind) { return null; } // Having parsed this whole object, we can insert it. if (result.containsKey("kind")) { String kind = result.getAsString("kind"); if ("Topic".equals(kind)) { if (result.containsKey("id")) { result.put("_id", result.getAsString("id")); result.remove("id"); } if (result.containsKey("child_kind")) { String child_kind = result.getAsString("child_kind"); if ("Topic".equals(child_kind) || "Video".equals(child_kind)) { insertTopic(tempDb, result); } } } else if ("Video".equals(kind)) { if (result.containsKey("id")) { result.put("video_id", result.getAsString("id")); result.remove("id"); } insertTopicVideo(tempDb, result); insertVideo(tempDb, result); } } return result; } // return list of ids of the children in this array, so the parent can attach a parentTopic_id after the fact. private ChildArrayResults parseChildArray(JsonParser parser, SQLiteDatabase tempDb, String parentTopic_id) throws JsonParseException, IOException { ChildArrayResults result = new ChildArrayResults(); int seq = 0; JsonToken currentToken = parser.getCurrentToken(); if (currentToken == JsonToken.START_ARRAY) { while (parser.nextValue() == JsonToken.START_OBJECT) { // Otherwise, we will be at END_ARRAY here. ContentValues values = parseObject(parser, tempDb, parentTopic_id, seq++); if (values != null && values.containsKey("kind")) { String kind = values.getAsString("kind"); if ("Topic".equals(kind) && values.containsKey("_id")) { result.childKind = kind; result.childIds.add(values.getAsString("_id")); result.videoCount += values.getAsInteger("video_count"); if (result.thumbId == null && values.containsKey("thumb_id")) { // Return the first available thumb id as this topic's thumb id. result.thumbId = values.getAsString("thumb_id"); } } else if ("Video".equals(kind) && values.containsKey("readable_id")) { result.childKind = kind; result.childIds.add(values.getAsString("readable_id")); result.videoCount += 1; if (result.thumbId == null && values.containsKey("pngurl")) { // Return youtube_id of first video with a thumbnail as this topic's thumbnail id. result.thumbId = values.getAsString("youtube_id"); } } } } } return result; } private void parseDownloadUrls(JsonParser parser, ContentValues result) throws JsonParseException, IOException { // parser points at begin object token right now if (parser.getCurrentToken() == JsonToken.START_OBJECT) { while (parser.nextValue() != JsonToken.END_OBJECT) { String fieldName = parser.getCurrentName(); if (downloadUrlFields.contains(fieldName)) { result.put(fieldName + "url", parser.getValueAsString()); } } } // else we must not have any download urls (null, '', something like that.) } // When we encounter our id, if we have already parsed children, then we must select all of // the correct type, with the ids we received from the children, who don't have parentTopic_ids, and set them. // It appears, looking at the tree as returned 12/30/12, that this is unnecessary. It's here just in case. private void addParentIdToChildren(SQLiteDatabase tempDb, ChildArrayResults childResults, String parentId) { if (childResults == null || childResults.childIds == null || childResults.childIds.size() == 0) { return; } ContentValues values = new ContentValues(); values.put("parentTopic_id", parentId); boolean isTopic = "Topic".equals(childResults.childKind); String table = isTopic ? topicTableName : videoTableName; StringBuilder where = new StringBuilder(); where.append(isTopic ? "_id IN (?" : "readable_id IN (?"); // First '?' already added, so start at 1. for (int i = 1; i < childResults.childIds.size(); ++i) { where.append(",?"); } where.append(")"); String whereClause = where.toString(); String[] whereArg = childResults.childIds.toArray(new String[childResults.childIds.size()]); tempDb.update(table, values, whereClause, whereArg); } private void insertTopic(SQLiteDatabase tempDb, ContentValues values) { tempDb.insert(topicTableName, null, values); } private void insertTopicVideo(SQLiteDatabase tempDb, ContentValues values) { // This values is for a video. ContentValues v = new ContentValues(); v.put("topic_id", values.getAsString("parentTopic_id")); v.put("video_id", values.getAsString("readable_id")); tempDb.insertWithOnConflict(topicvideoTableName, null, v, SQLiteDatabase.CONFLICT_IGNORE); } private void insertVideo(SQLiteDatabase tempDb, ContentValues values) { tempDb.insertWithOnConflict(videoTableName, null, values, SQLiteDatabase.CONFLICT_IGNORE); } private void mergeDbs() { Log.d(LOG_TAG, "update received - juggling dbs"); // Get main database, attach temp db to it. SQLiteDatabase mainDb = dataService.getHelper().getWritableDatabase(); mainDb.execSQL("attach database ? as ka_temp", new Object[] {dataService.getDatabasePath("ka_temp").getAbsolutePath()}); mainDb.beginTransaction(); try { // Maintain download status. String sql = "select max(download_status), dlm_id, youtube_id from video where download_status != ? group by youtube_id"; Cursor c = mainDb.rawQuery(sql, new String[] {"" + Video.DL_STATUS_NOT_STARTED}); Cursor c1; String[] videoIds = new String[c.getCount()]; int i = 0; while (c.moveToNext()) { String youtube_id = c.getString(c.getColumnIndex("youtube_id")); String download_status = c.getString(c.getColumnIndex("max(download_status)")); long dlm_id = c.getLong(c.getColumnIndex("dlm_id")); videoIds[i++] = youtube_id; ContentValues v = new ContentValues(); v.put("download_status", download_status); v.put("dlm_id", dlm_id); String[] idArg = new String[] {youtube_id}; mainDb.update("ka_temp.video", v, "youtube_id = ?", idArg); // cursor over parent topics of this video sql = "select ka_temp.topic._id from ka_temp.topic, ka_temp.topicvideo, ka_temp.video where ka_temp.video.youtube_id=? and ka_temp.topicvideo.video_id=ka_temp.video.readable_id and ka_temp.topicvideo.topic_id=ka_temp.topic._id"; c1 = mainDb.rawQuery(sql, idArg); Log.d(LOG_TAG, String.format("updating counts for %d topics", c1.getCount())); while (c1.moveToNext()) { String topicId = c1.getString(c1.getColumnIndex("_id")); DatabaseHelper.incrementDownloadedVideoCounts(mainDb, topicId, "ka_temp.topic"); } c1.close(); } c.close(); mainDb.execSQL("delete from topic"); mainDb.execSQL("insert into topic select * from ka_temp.topic"); mainDb.execSQL("delete from topicvideo"); mainDb.execSQL("insert into topicvideo select * from ka_temp.topicvideo"); mainDb.execSQL("delete from video"); mainDb.execSQL("insert into video select * from ka_temp.video"); mainDb.setTransactionSuccessful(); } finally { mainDb.endTransaction(); mainDb.execSQL("detach database ka_temp"); } Log.d(LOG_TAG, "finished juggling"); } private class TempHelper extends OrmLiteSqliteOpenHelper { static final String DB_NAME = "ka_temp"; final List<String> schema = new ArrayList<String>(); TempHelper(Context context, SQLiteDatabase mainDb) { super(context, DB_NAME, null, 1, R.raw.ormlite_config); context.deleteDatabase(DB_NAME); Cursor cursor = mainDb.rawQuery("select sql from sqlite_master where tbl_name=?", new String[] {"topic"}); while (cursor.moveToNext()) { String sql = cursor.getString(0); Log.d(LOG_TAG, "TempHelper: " + sql); if (sql != null) { schema.add(sql); } } cursor.close(); cursor = mainDb.rawQuery("select sql from sqlite_master where tbl_name=?", new String[] {"video"}); while (cursor.moveToNext()) { String sql = cursor.getString(0); Log.d(LOG_TAG, "TempHelper: " + sql); if (sql != null) { schema.add(sql); } } cursor.close(); cursor = mainDb.rawQuery("select sql from sqlite_master where tbl_name=?", new String[] {"topicvideo"}); while (cursor.moveToNext()) { String sql = cursor.getString(0); Log.d(LOG_TAG, "TempHelper: " + sql); if (sql != null) { schema.add(sql); } } cursor.close(); } @Override public void onCreate(SQLiteDatabase database, ConnectionSource connectionSource) { Log.d(LOG_TAG, "TempHelper.onCreate"); for (String sql : schema) { Log.d(LOG_TAG, sql); database.execSQL(sql); } } @Override public void onUpgrade(SQLiteDatabase database, ConnectionSource connectionSource, int oldVersion, int newVersion) { } } }