/*
* Copyright 2012 Google Inc.
*
* 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.android.apps.iosched.io;
import android.content.ContentProviderOperation;
import android.content.ContentResolver;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.text.TextUtils;
import com.google.android.apps.iosched.Config;
import com.google.android.apps.iosched.R;
import com.google.android.apps.iosched.provider.ScheduleContract;
import com.google.android.apps.iosched.provider.ScheduleContract.Sessions;
import com.google.android.apps.iosched.provider.ScheduleContract.SyncColumns;
import com.google.android.apps.iosched.provider.ScheduleDatabase;
import com.google.android.apps.iosched.util.*;
import com.google.api.client.extensions.android.json.AndroidJsonFactory;
import com.google.api.client.googleapis.json.GoogleJsonResponseException;
import com.google.api.client.json.JsonFactory;
import com.google.api.services.googledevelopers.Googledevelopers;
import com.google.api.services.googledevelopers.model.SessionResponse;
import com.google.api.services.googledevelopers.model.SessionsResponse;
import com.google.api.services.googledevelopers.model.TrackResponse;
import com.google.api.services.googledevelopers.model.TracksResponse;
import java.io.IOException;
import java.util.*;
import static com.google.android.apps.iosched.provider.ScheduleDatabase.SessionsSpeakers;
import static com.google.android.apps.iosched.util.LogUtils.*;
import static com.google.android.apps.iosched.util.ParserUtils.sanitizeId;
public class SessionsHandler {
private static final String TAG = makeLogTag(SessionsHandler.class);
private static final String BASE_SESSION_URL
= "https://developers.google.com/events/io/sessions/";
private static final String EVENT_TYPE_KEYNOTE = Sessions.SESSION_TYPE_KEYNOTE;
private static final String EVENT_TYPE_OFFICE_HOURS = Sessions.SESSION_TYPE_OFFICE_HOURS;
private static final String EVENT_TYPE_CODELAB = Sessions.SESSION_TYPE_CODELAB;
private static final String EVENT_TYPE_SANDBOX = Sessions.SESSION_TYPE_SANDBOX;
private static final int PARSE_FLAG_FORCE_SCHEDULE_REMOVE = 1;
private static final int PARSE_FLAG_FORCE_SCHEDULE_ADD = 2;
private Context mContext;
public SessionsHandler(Context context) {
mContext = context;
}
public ArrayList<ContentProviderOperation> fetchAndParse(
Googledevelopers conferenceAPI)
throws IOException {
// Set up the HTTP transport and JSON factory
SessionsResponse sessions;
SessionsResponse starredSessions = null;
TracksResponse tracks;
try {
sessions = conferenceAPI.events().sessions().list(Config.EVENT_ID).setLimit(9999L).execute();
tracks = conferenceAPI.events().tracks().list(Config.EVENT_ID).execute();
if (sessions == null || sessions.getSessions() == null) {
throw new HandlerException("Sessions list was null.");
}
if (tracks == null || tracks.getTracks() == null) {
throw new HandlerException("trackDetails list was null.");
}
} catch (HandlerException e) {
LOGE(TAG, "Fatal: error fetching sessions/tracks", e);
return Lists.newArrayList();
}
final boolean profileAvailableBefore = PrefUtils.isDevsiteProfileAvailable(mContext);
boolean profileAvailableNow = false;
try {
starredSessions = conferenceAPI.users().events().sessions().list(Config.EVENT_ID).execute();
// If this succeeded, the user has a DevSite profile
PrefUtils.markDevSiteProfileAvailable(mContext, true);
profileAvailableNow = true;
} catch (GoogleJsonResponseException e) {
// Hack: If the user doesn't have a developers.google.com profile, the Conference API
// will respond with HTTP 401 and include something like
// "Provided user does not have a developers.google.com profile" in the message.
if (401 == e.getStatusCode()
&& e.getDetails() != null
&& e.getDetails().getMessage() != null
&& e.getDetails().getMessage().contains("developers.google.com")) {
LOGE(TAG, "User does not have a developers.google.com profile. Not syncing remote " +
"personalized schedule.");
starredSessions = null;
// Record that the user's profile is offline. If this changes later, we'll re-upload any local
// starred sessions.
PrefUtils.markDevSiteProfileAvailable(mContext, false);
} else {
LOGW(TAG, "Auth token invalid, requesting refresh", e);
AccountUtils.refreshAuthToken(mContext);
}
}
if (profileAvailableNow && !profileAvailableBefore) {
LOGI(TAG, "developers.google.com mode change: DEVSITE_PROFILE_AVAILABLE=false -> true");
// User's DevSite profile has come into existence. Re-upload tracks.
ContentResolver cr = mContext.getContentResolver();
String[] projection = new String[] {ScheduleContract.Sessions.SESSION_ID, Sessions.SESSION_TITLE};
Cursor c = cr.query(ScheduleContract.BASE_CONTENT_URI.buildUpon().
appendPath("sessions").appendPath("starred").build(), projection, null, null, null);
if (c != null) {
c.moveToFirst();
while (!c.isAfterLast()) {
String id = c.getString(0);
String title = c.getString(1);
LOGI(TAG, "Adding session: (" + id + ") " + title);
Uri sessionUri = ScheduleContract.Sessions.buildSessionUri(id);
SessionsHelper.uploadStarredSession(mContext, sessionUri, true);
c.moveToNext();
}
}
// Hack: Use local starred sessions for now, to give the new sessions time to take effect
// TODO(trevorjohns): Upload starred sessions should be synchronous to avoid this hack
starredSessions = null;
}
return buildContentProviderOperations(sessions, starredSessions, tracks);
}
public ArrayList<ContentProviderOperation> parseString(String sessionsJson, String tracksJson) {
JsonFactory jsonFactory = new AndroidJsonFactory();
try {
SessionsResponse sessions = jsonFactory.fromString(sessionsJson, SessionsResponse.class);
TracksResponse tracks =
jsonFactory.fromString(tracksJson, TracksResponse.class);
return buildContentProviderOperations(sessions, null, tracks);
} catch (IOException e) {
LOGE(TAG, "Error reading speakers from packaged data", e);
return Lists.newArrayList();
}
}
private ArrayList<ContentProviderOperation> buildContentProviderOperations(
SessionsResponse sessions,
SessionsResponse starredSessions,
TracksResponse tracks) {
// If there was no starred sessions response (e.g. there was an auth issue,
// or this is a local sync), keep all the locally starred sessions.
boolean retainLocallyStarredSessions = (starredSessions == null);
final ArrayList<ContentProviderOperation> batch = Lists.newArrayList();
// Build lookup table for starredSessions mappings
HashSet<String> starredSessionsMap = new HashSet<String>();
if (starredSessions != null) {
List<SessionResponse> starredSessionList =
starredSessions.getSessions();
if (starredSessionList != null) {
for (SessionResponse session : starredSessionList) {
String sessionId = session.getId();
starredSessionsMap.add(sessionId);
}
}
}
// Build lookup table for track mappings
// Assumes that sessions can only have one track. Not guarenteed by the Conference API,
// but is being enforced by conference organizer policy.
HashMap<String, TrackResponse> trackMap
= new HashMap<String, TrackResponse>();
if (tracks != null) {
for (TrackResponse track : tracks.getTracks()) {
List<String> sessionIds = track.getSessions();
if (sessionIds != null) {
for (String sessionId : sessionIds) {
trackMap.put(sessionId, track);
}
}
}
}
if (sessions != null) {
List<SessionResponse> sessionList = sessions.getSessions();
int numSessions = sessionList.size();
if (numSessions > 0) {
LOGI(TAG, "Updating sessions data");
Set<String> starredSessionIds = new HashSet<String>();
if (retainLocallyStarredSessions) {
Cursor starredSessionsCursor = mContext.getContentResolver().query(
Sessions.CONTENT_STARRED_URI,
new String[]{ScheduleContract.Sessions.SESSION_ID},
null, null, null);
while (starredSessionsCursor.moveToNext()) {
starredSessionIds.add(starredSessionsCursor.getString(0));
}
starredSessionsCursor.close();
}
// Clear out existing sessions
batch.add(ContentProviderOperation
.newDelete(ScheduleContract.addCallerIsSyncAdapterParameter(
Sessions.CONTENT_URI))
.build());
// Maintain a list of created session block IDs
Set<String> blockIds = new HashSet<String>();
// Maintain a map of insert operations for sandbox-only blocks
HashMap<String, ContentProviderOperation> sandboxBlocks = new HashMap<String, ContentProviderOperation>();
for (SessionResponse session : sessionList) {
int flags = 0;
String sessionId = session.getId();
if (retainLocallyStarredSessions) {
flags = (starredSessionIds.contains(sessionId)
? PARSE_FLAG_FORCE_SCHEDULE_ADD
: PARSE_FLAG_FORCE_SCHEDULE_REMOVE);
}
if (TextUtils.isEmpty(sessionId)) {
LOGW(TAG, "Found session with empty ID in API response.");
continue;
}
// Session title
String sessionTitle = session.getTitle();
String sessionSubtype = session.getSubtype();
if (EVENT_TYPE_CODELAB.equals(sessionSubtype)) {
sessionTitle = mContext.getString(
R.string.codelab_title_template, sessionTitle);
}
// Whether or not it's in the schedule
boolean inSchedule = starredSessionsMap.contains(sessionId);
if ((flags & PARSE_FLAG_FORCE_SCHEDULE_ADD) != 0
|| (flags & PARSE_FLAG_FORCE_SCHEDULE_REMOVE) != 0) {
inSchedule = (flags & PARSE_FLAG_FORCE_SCHEDULE_ADD) != 0;
}
if (EVENT_TYPE_KEYNOTE.equals(sessionSubtype)) {
// Keynotes are always in your schedule.
inSchedule = true;
}
// Clean up session abstract
String sessionAbstract = session.getDescription();
if (sessionAbstract != null) {
sessionAbstract = sessionAbstract.replace('\r', '\n');
}
// Hashtags
TrackResponse track = trackMap.get(sessionId);
String hashtag = null;
if (track != null) {
hashtag = ParserUtils.sanitizeId(track.getTitle());
}
boolean isLivestream = false;
try {
isLivestream = session.getIsLivestream();
} catch (NullPointerException ignored) {
}
String youtubeUrl = session.getYoutubeUrl();
// Get block id
long sessionStartTime = session.getStartTimestamp().longValue() * 1000;
long sessionEndTime = session.getEndTimestamp().longValue() * 1000;
String blockId = ScheduleContract.Blocks.generateBlockId(
sessionStartTime, sessionEndTime);
if (!blockIds.contains(blockId) && !EVENT_TYPE_SANDBOX.equals(sessionSubtype)) {
// New non-sandbox block
if (sandboxBlocks.containsKey(blockId)) {
sandboxBlocks.remove(blockId);
}
String blockType;
String blockTitle;
if (EVENT_TYPE_KEYNOTE.equals(sessionSubtype)) {
blockType = ScheduleContract.Blocks.BLOCK_TYPE_KEYNOTE;
blockTitle = mContext.getString(R.string.schedule_block_title_keynote);
} else if (EVENT_TYPE_CODELAB.equals(sessionSubtype)) {
blockType = ScheduleContract.Blocks.BLOCK_TYPE_CODELAB;
blockTitle = mContext.getString(
R.string.schedule_block_title_code_labs);
} else if (EVENT_TYPE_OFFICE_HOURS.equals(sessionSubtype)) {
blockType = ScheduleContract.Blocks.BLOCK_TYPE_OFFICE_HOURS;
blockTitle = mContext.getString(
R.string.schedule_block_title_office_hours);
} else {
blockType = ScheduleContract.Blocks.BLOCK_TYPE_SESSION;
blockTitle = mContext.getString(
R.string.schedule_block_title_sessions);
}
batch.add(ContentProviderOperation
.newInsert(ScheduleContract.Blocks.CONTENT_URI)
.withValue(ScheduleContract.Blocks.BLOCK_ID, blockId)
.withValue(ScheduleContract.Blocks.BLOCK_TYPE, blockType)
.withValue(ScheduleContract.Blocks.BLOCK_TITLE, blockTitle)
.withValue(ScheduleContract.Blocks.BLOCK_START, sessionStartTime)
.withValue(ScheduleContract.Blocks.BLOCK_END, sessionEndTime)
.build());
blockIds.add(blockId);
} else if (!sandboxBlocks.containsKey(blockId) && !blockIds.contains(blockId) && EVENT_TYPE_SANDBOX.equals(sessionSubtype)) {
// New sandbox-only block, add insert operation to map
String blockType = ScheduleContract.Blocks.BLOCK_TYPE_SANDBOX;
String blockTitle = mContext.getString(
R.string.schedule_block_title_sandbox);
sandboxBlocks.put(blockId,
ContentProviderOperation
.newInsert(ScheduleContract.Blocks.CONTENT_URI)
.withValue(ScheduleContract.Blocks.BLOCK_ID, blockId)
.withValue(ScheduleContract.Blocks.BLOCK_TYPE, blockType)
.withValue(ScheduleContract.Blocks.BLOCK_TITLE, blockTitle)
.withValue(ScheduleContract.Blocks.BLOCK_START, sessionStartTime)
.withValue(ScheduleContract.Blocks.BLOCK_END, sessionEndTime)
.build());
}
// Insert session info
final ContentProviderOperation.Builder builder;
if (EVENT_TYPE_SANDBOX.equals(sessionSubtype)) {
// Sandbox companies go in the special sandbox table
builder = ContentProviderOperation
.newInsert(ScheduleContract
.addCallerIsSyncAdapterParameter(ScheduleContract.Sandbox.CONTENT_URI))
.withValue(SyncColumns.UPDATED, System.currentTimeMillis())
.withValue(ScheduleContract.Sandbox.COMPANY_ID, sessionId)
.withValue(ScheduleContract.Sandbox.COMPANY_NAME, sessionTitle)
.withValue(ScheduleContract.Sandbox.COMPANY_DESC, sessionAbstract)
.withValue(ScheduleContract.Sandbox.COMPANY_URL, makeSessionUrl(sessionId))
.withValue(ScheduleContract.Sandbox.COMPANY_LOGO_URL, session.getIconUrl())
.withValue(ScheduleContract.Sandbox.ROOM_ID, sanitizeId(session.getLocation()))
.withValue(ScheduleContract.Sandbox.TRACK_ID, (track != null ? track.getId() : null))
.withValue(ScheduleContract.Sandbox.BLOCK_ID, blockId);
batch.add(builder.build());
} else {
// All other fields go in the normal sessions table
builder = ContentProviderOperation
.newInsert(ScheduleContract
.addCallerIsSyncAdapterParameter(Sessions.CONTENT_URI))
.withValue(SyncColumns.UPDATED, System.currentTimeMillis())
.withValue(Sessions.SESSION_ID, sessionId)
.withValue(Sessions.SESSION_TYPE, sessionSubtype)
.withValue(Sessions.SESSION_LEVEL, null) // Not available
.withValue(Sessions.SESSION_TITLE, sessionTitle)
.withValue(Sessions.SESSION_ABSTRACT, sessionAbstract)
.withValue(Sessions.SESSION_HASHTAGS, hashtag)
.withValue(Sessions.SESSION_TAGS, null) // Not available
.withValue(Sessions.SESSION_URL, makeSessionUrl(sessionId))
.withValue(Sessions.SESSION_LIVESTREAM_URL,
isLivestream ? youtubeUrl : null)
.withValue(Sessions.SESSION_MODERATOR_URL, null) // Not available
.withValue(Sessions.SESSION_REQUIREMENTS, null) // Not available
.withValue(Sessions.SESSION_STARRED, inSchedule)
.withValue(Sessions.SESSION_YOUTUBE_URL,
isLivestream ? null : youtubeUrl)
.withValue(Sessions.SESSION_PDF_URL, null) // Not available
.withValue(Sessions.SESSION_NOTES_URL, null) // Not available
.withValue(Sessions.ROOM_ID, sanitizeId(session.getLocation()))
.withValue(Sessions.BLOCK_ID, blockId);
batch.add(builder.build());
}
// Replace all session speakers
final Uri sessionSpeakersUri = Sessions.buildSpeakersDirUri(sessionId);
batch.add(ContentProviderOperation
.newDelete(ScheduleContract
.addCallerIsSyncAdapterParameter(sessionSpeakersUri))
.build());
List<String> presenterIds = session.getPresenterIds();
if (presenterIds != null) {
for (String presenterId : presenterIds) {
batch.add(ContentProviderOperation.newInsert(sessionSpeakersUri)
.withValue(SessionsSpeakers.SESSION_ID, sessionId)
.withValue(SessionsSpeakers.SPEAKER_ID, presenterId).build());
}
}
// Add track mapping
if (track != null) {
String trackId = track.getId();
if (trackId != null) {
final Uri sessionTracksUri = ScheduleContract.addCallerIsSyncAdapterParameter(
ScheduleContract.Sessions.buildTracksDirUri(sessionId));
batch.add(ContentProviderOperation.newInsert(sessionTracksUri)
.withValue(ScheduleDatabase.SessionsTracks.SESSION_ID, sessionId)
.withValue(ScheduleDatabase.SessionsTracks.TRACK_ID, trackId).build());
}
}
// Codelabs: Add mapping to codelab table
if (EVENT_TYPE_CODELAB.equals(sessionSubtype)) {
final Uri sessionTracksUri = ScheduleContract.addCallerIsSyncAdapterParameter(
ScheduleContract.Sessions.buildTracksDirUri(sessionId));
batch.add(ContentProviderOperation.newInsert(sessionTracksUri)
.withValue(ScheduleDatabase.SessionsTracks.SESSION_ID, sessionId)
.withValue(ScheduleDatabase.SessionsTracks.TRACK_ID, "CODE_LABS").build());
}
}
// Insert sandbox-only blocks
batch.addAll(sandboxBlocks.values());
}
}
return batch;
}
private String makeSessionUrl(String sessionId) {
if (TextUtils.isEmpty(sessionId)) {
return null;
}
return BASE_SESSION_URL + sessionId;
}
}