/* * Copyright 2014 Google Inc. All rights reserved. * * 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.google.samples.apps.iosched.io; import android.content.ContentProviderOperation; import android.content.Context; import android.database.Cursor; import android.graphics.Color; import android.net.Uri; import android.provider.BaseColumns; import android.text.TextUtils; import com.google.samples.apps.iosched.Config; import com.google.samples.apps.iosched.R; import com.google.samples.apps.iosched.io.model.Session; import com.google.samples.apps.iosched.io.model.Speaker; import com.google.samples.apps.iosched.io.model.Tag; import com.google.samples.apps.iosched.provider.ScheduleContract; import com.google.samples.apps.iosched.provider.ScheduleDatabase; import com.google.samples.apps.iosched.util.TimeUtils; import com.google.gson.Gson; import com.google.gson.JsonElement; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.List; import static com.google.samples.apps.iosched.util.LogUtils.*; public class SessionsHandler extends JSONHandler { private static final String TAG = makeLogTag(SessionsHandler.class); private HashMap<String, Session> mSessions = new HashMap<String, Session>(); private HashMap<String, Tag> mTagMap = null; private HashMap<String, Speaker> mSpeakerMap = null; private int mDefaultSessionColor; public SessionsHandler(Context context) { super(context); mDefaultSessionColor = mContext.getResources().getColor(R.color.default_session_color); } @Override public void process(JsonElement element) { for (Session session : new Gson().fromJson(element, Session[].class)) { mSessions.put(session.id, session); } } @Override public void makeContentProviderOperations(ArrayList<ContentProviderOperation> list) { Uri uri = ScheduleContract.addCallerIsSyncAdapterParameter( ScheduleContract.Sessions.CONTENT_URI); // build a map of session to session import hashcode so we know what to update, // what to insert, and what to delete HashMap<String, String> sessionHashCodes = loadSessionHashCodes(); boolean incrementalUpdate = (sessionHashCodes != null) && (sessionHashCodes.size() > 0); // set of sessions that we want to keep after the sync HashSet<String> sessionsToKeep = new HashSet<String>(); if (incrementalUpdate) { LOGD(TAG, "Doing incremental update for sessions."); } else { LOGD(TAG, "Doing full (non-incremental) update for sessions."); list.add(ContentProviderOperation.newDelete(uri).build()); } int updatedSessions = 0; for (Session session : mSessions.values()) { // Set the session grouping order in the object, so it can be used in hash calculation session.groupingOrder = computeTypeOrder(session); // compute the incoming session's hashcode to figure out if we need to update String hashCode = session.getImportHashCode(); sessionsToKeep.add(session.id); // add session, if necessary if (!incrementalUpdate || !sessionHashCodes.containsKey(session.id) || !sessionHashCodes.get(session.id).equals(hashCode)) { ++updatedSessions; boolean isNew = !incrementalUpdate || !sessionHashCodes.containsKey(session.id); buildSession(isNew, session, list); // add relationships to speakers and track buildSessionSpeakerMapping(session, list); buildTagsMapping(session, list); } } int deletedSessions = 0; if (incrementalUpdate) { for (String sessionId : sessionHashCodes.keySet()) { if (!sessionsToKeep.contains(sessionId)) { buildDeleteOperation(sessionId, list); ++deletedSessions; } } } LOGD(TAG, "Sessions: " + (incrementalUpdate ? "INCREMENTAL" : "FULL") + " update. " + updatedSessions + " to update, " + deletedSessions + " to delete. New total: " + mSessions.size()); } private void buildDeleteOperation(String sessionId, List<ContentProviderOperation> list) { Uri sessionUri = ScheduleContract.addCallerIsSyncAdapterParameter( ScheduleContract.Sessions.buildSessionUri(sessionId)); list.add(ContentProviderOperation.newDelete(sessionUri).build()); } private HashMap<String, String> loadSessionHashCodes() { Uri uri = ScheduleContract.addCallerIsSyncAdapterParameter( ScheduleContract.Sessions.CONTENT_URI); LOGD(TAG, "Loading session hashcodes for session import optimization."); Cursor cursor = mContext.getContentResolver().query(uri, SessionHashcodeQuery.PROJECTION, null, null, null); if (cursor == null || cursor.getCount() < 1) { LOGW(TAG, "Warning: failed to load session hashcodes. Not optimizing session import."); if (cursor != null) { cursor.close(); } return null; } HashMap<String, String> hashcodeMap = new HashMap<String, String>(); while (cursor.moveToNext()) { String sessionId = cursor.getString(SessionHashcodeQuery.SESSION_ID); String hashcode = cursor.getString(SessionHashcodeQuery.SESSION_IMPORT_HASHCODE); hashcodeMap.put(sessionId, hashcode == null ? "" : hashcode); } LOGD(TAG, "Session hashcodes loaded for " + hashcodeMap.size() + " sessions."); cursor.close(); return hashcodeMap; } StringBuilder mStringBuilder = new StringBuilder(); private void buildSession(boolean isInsert, Session session, ArrayList<ContentProviderOperation> list) { ContentProviderOperation.Builder builder; Uri allSessionsUri = ScheduleContract .addCallerIsSyncAdapterParameter(ScheduleContract.Sessions.CONTENT_URI); Uri thisSessionUri = ScheduleContract .addCallerIsSyncAdapterParameter(ScheduleContract.Sessions.buildSessionUri( session.id)); if (isInsert) { builder = ContentProviderOperation.newInsert(allSessionsUri); } else { builder = ContentProviderOperation.newUpdate(thisSessionUri); } String speakerNames = ""; if (mSpeakerMap != null) { // build human-readable list of speakers mStringBuilder.setLength(0); for (int i = 0; i < session.speakers.length; ++i) { if (mSpeakerMap.containsKey(session.speakers[i])) { mStringBuilder .append(i == 0 ? "" : i == session.speakers.length - 1 ? " and " : ", ") .append(mSpeakerMap.get(session.speakers[i]).name.trim()); } else { LOGW(TAG, "Unknown speaker ID " + session.speakers[i] + " in session " + session.id); } } speakerNames = mStringBuilder.toString(); } else { LOGE(TAG, "Can't build speaker names -- speaker map is null."); } int color = mDefaultSessionColor; try { if (!TextUtils.isEmpty(session.color)) { color = Color.parseColor(session.color); } } catch (IllegalArgumentException ex) { LOGD(TAG, "Ignoring invalid formatted session color: "+session.color); } builder.withValue(ScheduleContract.SyncColumns.UPDATED, System.currentTimeMillis()) .withValue(ScheduleContract.Sessions.SESSION_ID, session.id) .withValue(ScheduleContract.Sessions.SESSION_LEVEL, null) // Not available .withValue(ScheduleContract.Sessions.SESSION_TITLE, session.title) .withValue(ScheduleContract.Sessions.SESSION_ABSTRACT, session.description) .withValue(ScheduleContract.Sessions.SESSION_HASHTAG, session.hashtag) .withValue(ScheduleContract.Sessions.SESSION_START, TimeUtils.timestampToMillis(session.startTimestamp, 0)) .withValue(ScheduleContract.Sessions.SESSION_END, TimeUtils.timestampToMillis(session.endTimestamp, 0)) .withValue(ScheduleContract.Sessions.SESSION_TAGS, session.makeTagsList()) // Note: we store this comma-separated list of tags IN ADDITION // to storing the tags in proper relational format (in the sessions_tags // relationship table). This is because when querying for sessions, // we don't want to incur the performance penalty of having to do a // subquery for every record to figure out the list of tags of each session. .withValue(ScheduleContract.Sessions.SESSION_SPEAKER_NAMES, speakerNames) // Note: we store the human-readable list of speakers (which is redundant // with the sessions_speakers relationship table) so that we can // display it easily in lists without having to make an additional DB query // (or another join) for each record. .withValue(ScheduleContract.Sessions.SESSION_KEYWORDS, null) // Not available .withValue(ScheduleContract.Sessions.SESSION_URL, session.url) .withValue(ScheduleContract.Sessions.SESSION_LIVESTREAM_URL, session.isLivestream ? session.youtubeUrl : null) .withValue(ScheduleContract.Sessions.SESSION_MODERATOR_URL, null) // Not available .withValue(ScheduleContract.Sessions.SESSION_REQUIREMENTS, null) // Not available .withValue(ScheduleContract.Sessions.SESSION_YOUTUBE_URL, session.isLivestream ? null : session.youtubeUrl) .withValue(ScheduleContract.Sessions.SESSION_PDF_URL, null) // Not available .withValue(ScheduleContract.Sessions.SESSION_NOTES_URL, null) // Not available .withValue(ScheduleContract.Sessions.ROOM_ID, session.room) .withValue(ScheduleContract.Sessions.SESSION_GROUPING_ORDER, session.groupingOrder) .withValue(ScheduleContract.Sessions.SESSION_IMPORT_HASHCODE, session.getImportHashCode()) .withValue(ScheduleContract.Sessions.SESSION_MAIN_TAG, session.mainTag) .withValue(ScheduleContract.Sessions.SESSION_CAPTIONS_URL, session.captionsUrl) .withValue(ScheduleContract.Sessions.SESSION_PHOTO_URL, session.photoUrl) .withValue(ScheduleContract.Sessions.SESSION_RELATED_CONTENT, session.relatedContent) .withValue(ScheduleContract.Sessions.SESSION_COLOR, color); list.add(builder.build()); } // The type order of a session is the order# (in its category) of the tag that indicates // its type. So if we sort sessions by type order, they will be neatly grouped by type, // with the types appearing in the order given by the tag category that represents the // concept of session type. private int computeTypeOrder(Session session) { int order = Integer.MAX_VALUE; int keynoteOrder = -1; if (mTagMap == null) { throw new IllegalStateException("Attempt to compute type order without tag map."); } for (String tagId : session.tags) { if (Config.Tags.SPECIAL_KEYNOTE.equals(tagId)) { return keynoteOrder; } Tag tag = mTagMap.get(tagId); if (tag != null && Config.Tags.SESSION_GROUPING_TAG_CATEGORY.equals(tag.category)) { if (tag.order_in_category < order) { order = tag.order_in_category; } } } return order; } private void buildSessionSpeakerMapping(Session session, ArrayList<ContentProviderOperation> list) { final Uri uri = ScheduleContract.addCallerIsSyncAdapterParameter( ScheduleContract.Sessions.buildSpeakersDirUri(session.id)); // delete any existing relationship between this session and speakers list.add(ContentProviderOperation.newDelete(uri).build()); // add relationship records to indicate the speakers for this session if (session.speakers != null) { for (String speakerId : session.speakers) { list.add(ContentProviderOperation.newInsert(uri) .withValue(ScheduleDatabase.SessionsSpeakers.SESSION_ID, session.id) .withValue(ScheduleDatabase.SessionsSpeakers.SPEAKER_ID, speakerId) .build()); } } } private void buildTagsMapping(Session session, ArrayList<ContentProviderOperation> list) { final Uri uri = ScheduleContract.addCallerIsSyncAdapterParameter( ScheduleContract.Sessions.buildTagsDirUri(session.id)); // delete any existing mappings list.add(ContentProviderOperation.newDelete(uri).build()); // add a mapping (a session+tag tuple) for each tag in the session for (String tag : session.tags) { list.add(ContentProviderOperation.newInsert(uri) .withValue(ScheduleDatabase.SessionsTags.SESSION_ID, session.id) .withValue(ScheduleDatabase.SessionsTags.TAG_ID, tag).build()); } } public void setTagMap(HashMap<String, Tag> tagMap) { mTagMap = tagMap; } public void setSpeakerMap(HashMap<String, Speaker> speakerMap) { mSpeakerMap = speakerMap; } private interface SessionHashcodeQuery { String[] PROJECTION = { BaseColumns._ID, ScheduleContract.Sessions.SESSION_ID, ScheduleContract.Sessions.SESSION_IMPORT_HASHCODE }; int _ID = 0; int SESSION_ID = 1; int SESSION_IMPORT_HASHCODE = 2; }; }