com.concentricsky.android.khanacademy.data.remote.LibraryUpdaterTask.java Source code

Java tutorial

Introduction

Here is the source code for com.concentricsky.android.khanacademy.data.remote.LibraryUpdaterTask.java

Source

/*
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) {
        }
    }

}