/* * Copyright 2015 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.provider; import com.google.samples.apps.iosched.Config; import com.google.samples.apps.iosched.appwidget.ScheduleWidgetProvider; import com.google.samples.apps.iosched.provider.ScheduleContract.Announcements; import com.google.samples.apps.iosched.provider.ScheduleContract.Blocks; import com.google.samples.apps.iosched.provider.ScheduleContract.Feedback; import com.google.samples.apps.iosched.provider.ScheduleContract.HashtagColumns; import com.google.samples.apps.iosched.provider.ScheduleContract.Hashtags; import com.google.samples.apps.iosched.provider.ScheduleContract.MapMarkers; import com.google.samples.apps.iosched.provider.ScheduleContract.MapTiles; import com.google.samples.apps.iosched.provider.ScheduleContract.MyFeedbackSubmitted; import com.google.samples.apps.iosched.provider.ScheduleContract.MySchedule; import com.google.samples.apps.iosched.provider.ScheduleContract.MyScheduleColumns; import com.google.samples.apps.iosched.provider.ScheduleContract.MyViewedVideos; import com.google.samples.apps.iosched.provider.ScheduleContract.Rooms; import com.google.samples.apps.iosched.provider.ScheduleContract.SearchSuggest; import com.google.samples.apps.iosched.provider.ScheduleContract.SearchTopicsSessions; import com.google.samples.apps.iosched.provider.ScheduleContract.Sessions; import com.google.samples.apps.iosched.provider.ScheduleContract.Speakers; import com.google.samples.apps.iosched.provider.ScheduleContract.Tags; import com.google.samples.apps.iosched.provider.ScheduleContract.VideoColumns; import com.google.samples.apps.iosched.provider.ScheduleContract.Videos; import com.google.samples.apps.iosched.provider.ScheduleDatabase.SessionsSearchColumns; import com.google.samples.apps.iosched.provider.ScheduleDatabase.SessionsSpeakers; import com.google.samples.apps.iosched.provider.ScheduleDatabase.Tables; import com.google.samples.apps.iosched.settings.SettingsUtils; import com.google.samples.apps.iosched.util.AccountUtils; import com.google.samples.apps.iosched.util.SelectionBuilder; import android.app.SearchManager; import android.content.ContentProvider; import android.content.ContentProviderOperation; import android.content.ContentProviderResult; import android.content.ContentValues; import android.content.Context; import android.content.OperationApplicationException; import android.database.Cursor; import android.database.MatrixCursor; import android.database.sqlite.SQLiteConstraintException; import android.database.sqlite.SQLiteDatabase; import android.net.Uri; import android.os.ParcelFileDescriptor; import android.provider.BaseColumns; import android.text.TextUtils; import android.util.Log; import java.io.FileDescriptor; import java.io.FileNotFoundException; import java.io.PrintWriter; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import static com.google.samples.apps.iosched.util.LogUtils.LOGD; import static com.google.samples.apps.iosched.util.LogUtils.LOGE; import static com.google.samples.apps.iosched.util.LogUtils.LOGV; import static com.google.samples.apps.iosched.util.LogUtils.LOGW; import static com.google.samples.apps.iosched.util.LogUtils.makeLogTag; /** * {@link android.content.ContentProvider} that stores {@link ScheduleContract} data. Data is * usually inserted by {@link com.google.samples.apps.iosched.sync.SyncHelper}, and queried using * {@link android.app.LoaderManager} pattern. */ public class ScheduleProvider extends ContentProvider { private static final String TAG = makeLogTag(ScheduleProvider.class); private ScheduleDatabase mOpenHelper; private ScheduleProviderUriMatcher mUriMatcher; /** * Providing important state information to be included in bug reports. * * !!! Remember !!! Any important data logged to {@code writer} shouldn't contain personally * identifiable information as it can be seen in bugreports. */ @Override public void dump(FileDescriptor fd, PrintWriter writer, String[] args) { Context context = getContext(); // Using try/catch block in case there are issues retrieving information to log. try { // Calling append in multiple calls is typically better than creating net new strings to // pass to method invocations. writer.print("Last sync attempted: "); writer.println(new java.util.Date(SettingsUtils.getLastSyncAttemptedTime(context))); writer.print("Last sync successful: "); writer.println(new java.util.Date(SettingsUtils.getLastSyncSucceededTime(context))); writer.print("Current sync interval: "); writer.println(SettingsUtils.getCurSyncInterval(context)); writer.print("Is an account active: "); writer.println(AccountUtils.hasActiveAccount(context)); boolean canGetAuthToken = !TextUtils.isEmpty(AccountUtils.getAuthToken(context)); writer.print("Can an auth token be retrieved: "); writer.println(canGetAuthToken); } catch (Exception exception) { writer.append("Exception while dumping state: "); exception.printStackTrace(writer); } } @Override public boolean onCreate() { mOpenHelper = new ScheduleDatabase(getContext()); mUriMatcher = new ScheduleProviderUriMatcher(); return true; } private void deleteDatabase() { // TODO: wait for content provider operations to finish, then tear down mOpenHelper.close(); Context context = getContext(); ScheduleDatabase.deleteDatabase(context); mOpenHelper = new ScheduleDatabase(getContext()); } /** {@inheritDoc} */ @Override public String getType(Uri uri) { ScheduleUriEnum matchingUriEnum = mUriMatcher.matchUri(uri); return matchingUriEnum.contentType; } /** * Returns a tuple of question marks. For example, if {@code count} is 3, returns "(?,?,?)". */ private String makeQuestionMarkTuple(int count) { if (count < 1) { return "()"; } StringBuilder stringBuilder = new StringBuilder(); stringBuilder.append("(?"); for (int i = 1; i < count; i++) { stringBuilder.append(",?"); } stringBuilder.append(")"); return stringBuilder.toString(); } /** * Adds the {@code tagsFilter} query parameter to the given {@code builder}. This query * parameter is used by the {@link com.google.samples.apps.iosched.explore.ExploreSessionsActivity} * when the user makes a selection containing multiple filters. */ private void addTagsFilter(SelectionBuilder builder, String tagsFilter, String numCategories) { // Note: for context, remember that session queries are done on a join of sessions // and the sessions_tags relationship table, and are GROUP'ed BY the session ID. String[] requiredTags = tagsFilter.split(","); if (requiredTags.length == 0) { // filtering by 0 tags -- no-op return; } else if (requiredTags.length == 1) { // filtering by only one tag, so a simple WHERE clause suffices builder.where(Tags.TAG_ID + "=?", requiredTags[0]); } else { // Filtering by multiple tags, so we must add a WHERE clause with an IN operator, // and add a HAVING statement to exclude groups that fall short of the number // of required tags. For example, if requiredTags is { "X", "Y", "Z" }, and a certain // session only has tags "X" and "Y", it will be excluded by the HAVING statement. int categories = 1; if (numCategories != null && TextUtils.isDigitsOnly(numCategories)) { try { categories = Integer.parseInt(numCategories); LOGD(TAG, "Categories being used " + categories); } catch (Exception ex) { LOGE(TAG, "exception parsing categories ", ex); } } String questionMarkTuple = makeQuestionMarkTuple(requiredTags.length); builder.where(Tags.TAG_ID + " IN " + questionMarkTuple, requiredTags); builder.having( "COUNT(" + Qualified.SESSIONS_SESSION_ID + ") >= " + categories); } } /** {@inheritDoc} */ @Override public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { final SQLiteDatabase db = mOpenHelper.getReadableDatabase(); String tagsFilter = uri.getQueryParameter(Sessions.QUERY_PARAMETER_TAG_FILTER); String categories = uri.getQueryParameter(Sessions.QUERY_PARAMETER_CATEGORIES); ScheduleUriEnum matchingUriEnum = mUriMatcher.matchUri(uri); // Avoid the expensive string concatenation below if not loggable. if (Log.isLoggable(TAG, Log.VERBOSE)) { LOGV(TAG, "uri=" + uri + " code=" + matchingUriEnum.code + " proj=" + Arrays.toString(projection) + " selection=" + selection + " args=" + Arrays.toString(selectionArgs) + ")"); } switch (matchingUriEnum) { default: { // Most cases are handled with simple SelectionBuilder. final SelectionBuilder builder = buildExpandedSelection(uri, matchingUriEnum.code); // If a special filter was specified, try to apply it. if (!TextUtils.isEmpty(tagsFilter) && !TextUtils.isEmpty(categories)) { addTagsFilter(builder, tagsFilter, categories); } boolean distinct = ScheduleContractHelper.isQueryDistinct(uri); Cursor cursor = builder .where(selection, selectionArgs) .query(db, distinct, projection, sortOrder, null); Context context = getContext(); if (null != context) { cursor.setNotificationUri(context.getContentResolver(), uri); } return cursor; } case SEARCH_SUGGEST: { final SelectionBuilder builder = new SelectionBuilder(); // Adjust incoming query to become SQL text match. selectionArgs[0] = selectionArgs[0] + "%"; builder.table(Tables.SEARCH_SUGGEST); builder.where(selection, selectionArgs); builder.map(SearchManager.SUGGEST_COLUMN_QUERY, SearchManager.SUGGEST_COLUMN_TEXT_1); projection = new String[]{ BaseColumns._ID, SearchManager.SUGGEST_COLUMN_TEXT_1, SearchManager.SUGGEST_COLUMN_QUERY }; final String limit = uri.getQueryParameter(SearchManager.SUGGEST_PARAMETER_LIMIT); return builder.query(db, false, projection, SearchSuggest.DEFAULT_SORT, limit); } case SEARCH_TOPICS_SESSIONS: { if (selectionArgs == null || selectionArgs.length == 0) { return createMergedSearchCursor(null, null); } String selectionArg = selectionArgs[0] == null ? "" : selectionArgs[0]; // First we query the Tags table to find any tags that match the given query Cursor tags = query(Tags.CONTENT_URI, SearchTopicsSessions.TOPIC_TAG_PROJECTION, SearchTopicsSessions.TOPIC_TAG_SELECTION, new String[] {Config.Tags.CATEGORY_TRACK, selectionArg + "%"}, SearchTopicsSessions.TOPIC_TAG_SORT); // Then we query the sessions_search table and get a list of sessions that match // the given keywords. Cursor search = null; if (selectionArgs[0] != null) { // dont query if there was no selectionArg. search = query(ScheduleContract.Sessions.buildSearchUri(selectionArg), SearchTopicsSessions.SEARCH_SESSIONS_PROJECTION, null, null, ScheduleContract.Sessions.SORT_BY_TYPE_THEN_TIME); } // Now that we have two cursors, we merge the cursors and return a unified view // of the two result sets. return createMergedSearchCursor(tags, search); } } } /** * Create a {@link MatrixCursor} given the tags and search cursors. * @param tags Cursor with the projection {@link SearchTopicsSessions#TOPIC_TAG_PROJECTION}. * @param search Cursor with the projection * {@link SearchTopicsSessions#SEARCH_SESSIONS_PROJECTION}. * @return Returns a MatrixCursor always with {@link SearchTopicsSessions#DEFAULT_PROJECTION} */ private Cursor createMergedSearchCursor(Cursor tags, Cursor search) { // How big should our MatrixCursor be? int maxCount = (tags == null ? 0 : tags.getCount()) + (search == null ? 0 : search.getCount()); MatrixCursor matrixCursor = new MatrixCursor( SearchTopicsSessions.DEFAULT_PROJECTION, maxCount); // Iterate over the tags cursor and add rows. if (tags != null && tags.moveToFirst()) { do { matrixCursor.addRow( new Object[]{ tags.getLong(0), tags.getString(1), /*tag_id*/ "{" + tags.getString(2) + "}", /*search_snippet*/ 1}); /*is_topic_tag*/ } while (tags.moveToNext()); } // Iterate over the search cursor and add rows. if (search != null && search.moveToFirst()) { do { matrixCursor.addRow( new Object[]{ search.getLong(0), search.getString(1), search.getString(2), /*search_snippet*/ 0}); /*is_topic_tag*/ } while (search.moveToNext()); } return matrixCursor; } /** {@inheritDoc} */ @Override public Uri insert(Uri uri, ContentValues values) { LOGV(TAG, "insert(uri=" + uri + ", values=" + values.toString() + ", account=" + getCurrentAccountName(uri, false) + ")"); final SQLiteDatabase db = mOpenHelper.getWritableDatabase(); ScheduleUriEnum matchingUriEnum = mUriMatcher.matchUri(uri); if (matchingUriEnum.table != null) { try { db.insertOrThrow(matchingUriEnum.table, null, values); notifyChange(uri); } catch (SQLiteConstraintException exception) { // Leaving this here as it's handy to to breakpoint on this throw when debugging a // bootstrap file issue. throw exception; } } switch (matchingUriEnum) { case BLOCKS: { return Blocks.buildBlockUri(values.getAsString(Blocks.BLOCK_ID)); } case CARDS: { return ScheduleContract.Cards.buildCardUri(values.getAsString( ScheduleContract.Cards.CARD_ID)); } case TAGS: { return Tags.buildTagUri(values.getAsString(Tags.TAG_ID)); } case ROOMS: { return Rooms.buildRoomUri(values.getAsString(Rooms.ROOM_ID)); } case SESSIONS: { return Sessions.buildSessionUri(values.getAsString(Sessions.SESSION_ID)); } case SESSIONS_ID_SPEAKERS: { return Speakers.buildSpeakerUri(values.getAsString(SessionsSpeakers.SPEAKER_ID)); } case SESSIONS_ID_TAGS: { return Tags.buildTagUri(values.getAsString(Tags.TAG_ID)); } case MY_SCHEDULE: { values.put(MySchedule.MY_SCHEDULE_ACCOUNT_NAME, getCurrentAccountName(uri, false)); db.insertOrThrow(Tables.MY_SCHEDULE, null, values); notifyChange(uri); Uri sessionUri = Sessions.buildSessionUri( values.getAsString(MyScheduleColumns.SESSION_ID)); notifyChange(sessionUri); return sessionUri; } case MY_VIEWED_VIDEOS: { values.put(MyViewedVideos.MY_VIEWED_VIDEOS_ACCOUNT_NAME, getCurrentAccountName(uri, false)); db.insertOrThrow(Tables.MY_VIEWED_VIDEO, null, values); notifyChange(uri); Uri videoUri = Videos.buildVideoUri( values.getAsString(MyViewedVideos.VIDEO_ID)); notifyChange(videoUri); return videoUri; } case MY_FEEDBACK_SUBMITTED: { values.put(MyFeedbackSubmitted.MY_FEEDBACK_SUBMITTED_ACCOUNT_NAME, getCurrentAccountName(uri, false)); db.insertOrThrow(Tables.MY_FEEDBACK_SUBMITTED, null, values); notifyChange(uri); Uri sessionUri = Sessions.buildSessionUri( values.getAsString(MyFeedbackSubmitted.SESSION_ID)); notifyChange(sessionUri); return sessionUri; } case SPEAKERS: { return Speakers.buildSpeakerUri(values.getAsString(Speakers.SPEAKER_ID)); } case ANNOUNCEMENTS: { return Announcements.buildAnnouncementUri(values .getAsString(Announcements.ANNOUNCEMENT_ID)); } case SEARCH_SUGGEST: { return SearchSuggest.CONTENT_URI; } case MAPMARKERS: { return MapMarkers.buildMarkerUri(values.getAsString(MapMarkers.MARKER_ID)); } case MAPTILES: { return MapTiles.buildFloorUri(values.getAsString(MapTiles.TILE_FLOOR)); } case FEEDBACK_FOR_SESSION: { return Feedback.buildFeedbackUri(values.getAsString(Feedback.SESSION_ID)); } case HASHTAGS: { return Hashtags.buildHashtagUri(values.getAsString(Hashtags.HASHTAG_NAME)); } case VIDEOS: { return Videos.buildVideoUri(values.getAsString(Videos.VIDEO_ID)); } default: { throw new UnsupportedOperationException("Unknown insert uri: " + uri); } } } /** {@inheritDoc} */ @Override public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { String accountName = getCurrentAccountName(uri, false); LOGV(TAG, "update(uri=" + uri + ", values=" + values.toString() + ", account=" + accountName + ")"); final SQLiteDatabase db = mOpenHelper.getWritableDatabase(); ScheduleUriEnum matchingUriEnum = mUriMatcher.matchUri(uri); if (matchingUriEnum == ScheduleUriEnum.SEARCH_INDEX) { // update the search index ScheduleDatabase.updateSessionSearchIndex(db); return 1; } final SelectionBuilder builder = buildSimpleSelection(uri); if (matchingUriEnum == ScheduleUriEnum.MY_SCHEDULE) { values.remove(MySchedule.MY_SCHEDULE_ACCOUNT_NAME); builder.where(MySchedule.MY_SCHEDULE_ACCOUNT_NAME + "=?", accountName); } if (matchingUriEnum == ScheduleUriEnum.MY_VIEWED_VIDEOS) { values.remove(MyViewedVideos.MY_VIEWED_VIDEOS_ACCOUNT_NAME); builder.where(MyViewedVideos.MY_VIEWED_VIDEOS_ACCOUNT_NAME + "=?", accountName); } if (matchingUriEnum == ScheduleUriEnum.MY_FEEDBACK_SUBMITTED) { values.remove(MyFeedbackSubmitted.MY_FEEDBACK_SUBMITTED_ACCOUNT_NAME); builder.where(MyFeedbackSubmitted.MY_FEEDBACK_SUBMITTED_ACCOUNT_NAME + "=?", accountName); } int retVal = builder.where(selection, selectionArgs).update(db, values); notifyChange(uri); return retVal; } /** {@inheritDoc} */ @Override public int delete(Uri uri, String selection, String[] selectionArgs) { String accountName = getCurrentAccountName(uri, false); LOGV(TAG, "delete(uri=" + uri + ", account=" + accountName + ")"); if (uri == ScheduleContract.BASE_CONTENT_URI) { // Handle whole database deletes (e.g. when signing out) deleteDatabase(); notifyChange(uri); return 1; } final SQLiteDatabase db = mOpenHelper.getWritableDatabase(); final SelectionBuilder builder = buildSimpleSelection(uri); ScheduleUriEnum matchingUriEnum = mUriMatcher.matchUri(uri); if (matchingUriEnum == ScheduleUriEnum.MY_SCHEDULE) { builder.where(MySchedule.MY_SCHEDULE_ACCOUNT_NAME + "=?", accountName); } if (matchingUriEnum == ScheduleUriEnum.MY_VIEWED_VIDEOS) { builder.where(MyViewedVideos.MY_VIEWED_VIDEOS_ACCOUNT_NAME + "=?", accountName); } if (matchingUriEnum == ScheduleUriEnum.MY_FEEDBACK_SUBMITTED) { builder.where( MyFeedbackSubmitted.MY_FEEDBACK_SUBMITTED_ACCOUNT_NAME + "=?", accountName); } int retVal = builder.where(selection, selectionArgs).delete(db); notifyChange(uri); return retVal; } /** * Notifies the system that the given {@code uri} data has changed. * <p/> * We only notify changes if the uri wasn't called by the sync adapter, to avoid issuing a large * amount of notifications while doing a sync. The * {@link com.google.samples.apps.iosched.sync.ConferenceDataHandler} notifies all top level * conference paths once the conference data sync is done, and the * {@link com.google.samples.apps.iosched.sync.userdata.AbstractUserDataSyncHelper} notifies all * user data related paths once the user data sync is done. */ private void notifyChange(Uri uri) { if (!ScheduleContractHelper.isUriCalledFromSyncAdapter(uri)) { Context context = getContext(); context.getContentResolver().notifyChange(uri, null); // Widgets can't register content observers so we refresh widgets separately. context.sendBroadcast(ScheduleWidgetProvider.getRefreshBroadcastIntent(context, false)); } } /** * Apply the given set of {@link ContentProviderOperation}, executing inside * a {@link SQLiteDatabase} transaction. All changes will be rolled back if * any single one fails. */ @Override public ContentProviderResult[] applyBatch(ArrayList<ContentProviderOperation> operations) throws OperationApplicationException { final SQLiteDatabase db = mOpenHelper.getWritableDatabase(); db.beginTransaction(); try { final int numOperations = operations.size(); final ContentProviderResult[] results = new ContentProviderResult[numOperations]; for (int i = 0; i < numOperations; i++) { results[i] = operations.get(i).apply(this, results, i); } db.setTransactionSuccessful(); return results; } finally { db.endTransaction(); } } /** * Build a simple {@link SelectionBuilder} to match the requested * {@link Uri}. This is usually enough to support {@link #insert}, * {@link #update}, and {@link #delete} operations. */ private SelectionBuilder buildSimpleSelection(Uri uri) { final SelectionBuilder builder = new SelectionBuilder(); ScheduleUriEnum matchingUriEnum = mUriMatcher.matchUri(uri); // The main Uris, corresponding to the root of each type of Uri, do not have any selection // criteria so the full table is used. The others apply a selection criteria. switch (matchingUriEnum) { case BLOCKS: case CARDS: case TAGS: case ROOMS: case SESSIONS: case SPEAKERS: case ANNOUNCEMENTS: case MAPMARKERS: case MAPTILES: case SEARCH_SUGGEST: case HASHTAGS: case VIDEOS: return builder.table(matchingUriEnum.table); case BLOCKS_ID: { final String blockId = Blocks.getBlockId(uri); return builder.table(Tables.BLOCKS) .where(Blocks.BLOCK_ID + "=?", blockId); } case TAGS_ID: { final String tagId = Tags.getTagId(uri); return builder.table(Tables.TAGS) .where(Tags.TAG_ID + "=?", tagId); } case ROOMS_ID: { final String roomId = Rooms.getRoomId(uri); return builder.table(Tables.ROOMS) .where(Rooms.ROOM_ID + "=?", roomId); } case SESSIONS_ID: { final String sessionId = Sessions.getSessionId(uri); return builder.table(Tables.SESSIONS) .where(Sessions.SESSION_ID + "=?", sessionId); } case SESSIONS_ID_SPEAKERS: { final String sessionId = Sessions.getSessionId(uri); return builder.table(Tables.SESSIONS_SPEAKERS) .where(Sessions.SESSION_ID + "=?", sessionId); } case SESSIONS_ID_TAGS: { final String sessionId = Sessions.getSessionId(uri); return builder.table(Tables.SESSIONS_TAGS) .where(Sessions.SESSION_ID + "=?", sessionId); } case SESSIONS_MY_SCHEDULE: { final String sessionId = Sessions.getSessionId(uri); return builder.table(Tables.MY_SCHEDULE) .where(ScheduleContract.MyScheduleColumns.SESSION_ID + "=?", sessionId); } case MY_SCHEDULE: { return builder.table(Tables.MY_SCHEDULE) .where(MySchedule.MY_SCHEDULE_ACCOUNT_NAME + "=?", getCurrentAccountName(uri, false)); } case MY_VIEWED_VIDEOS: { return builder.table(Tables.MY_VIEWED_VIDEO) .where(MyViewedVideos.MY_VIEWED_VIDEOS_ACCOUNT_NAME + "=?", getCurrentAccountName(uri, false)); } case MY_FEEDBACK_SUBMITTED: { return builder.table(Tables.MY_FEEDBACK_SUBMITTED) .where(MyFeedbackSubmitted.MY_FEEDBACK_SUBMITTED_ACCOUNT_NAME + "=?", getCurrentAccountName(uri, false)); } case SPEAKERS_ID: { final String speakerId = Speakers.getSpeakerId(uri); return builder.table(Tables.SPEAKERS) .where(Speakers.SPEAKER_ID + "=?", speakerId); } case ANNOUNCEMENTS_ID: { final String announcementId = Announcements.getAnnouncementId(uri); return builder.table(Tables.ANNOUNCEMENTS) .where(Announcements.ANNOUNCEMENT_ID + "=?", announcementId); } case MAPMARKERS_FLOOR: { final String floor = MapMarkers.getMarkerFloor(uri); return builder.table(Tables.MAPMARKERS) .where(MapMarkers.MARKER_FLOOR + "=?", floor); } case MAPMARKERS_ID: { final String markerId = MapMarkers.getMarkerId(uri); return builder.table(Tables.MAPMARKERS) .where(MapMarkers.MARKER_ID + "=?", markerId); } case MAPTILES_FLOOR: { final String floor = MapTiles.getFloorId(uri); return builder.table(Tables.MAPTILES) .where(MapTiles.TILE_FLOOR + "=?", floor); } case FEEDBACK_FOR_SESSION: { final String session_id = Feedback.getSessionId(uri); return builder.table(Tables.FEEDBACK) .where(Feedback.SESSION_ID + "=?", session_id); } case FEEDBACK_ALL: { return builder.table(Tables.FEEDBACK); } case HASHTAGS_NAME: { final String hashtagName = Hashtags.getHashtagName(uri); return builder.table(Tables.HASHTAGS) .where(Hashtags.HASHTAG_NAME + "=?", hashtagName); } case VIDEOS_ID: { final String videoId = Videos.getVideoId(uri); return builder.table(Tables.VIDEOS).where(Videos.VIDEO_ID + "=?", videoId); } default: { throw new UnsupportedOperationException("Unknown uri for " + uri); } } } private String getCurrentAccountName(Uri uri, boolean sanitize) { String accountName = ScheduleContractHelper.getOverrideAccountName(uri); if (accountName == null) { accountName = AccountUtils.getActiveAccountName(getContext()); } if (sanitize) { // sanitize accountName when concatenating (http://xkcd.com/327/) accountName = (accountName != null) ? accountName.replace("'", "''") : null; } return accountName; } /** * Build an advanced {@link SelectionBuilder} to match the requested * {@link Uri}. This is usually only used by {@link #query}, since it * performs table joins useful for {@link Cursor} data. */ private SelectionBuilder buildExpandedSelection(Uri uri, int match) { final SelectionBuilder builder = new SelectionBuilder(); ScheduleUriEnum matchingUriEnum = mUriMatcher.matchCode(match); if (matchingUriEnum == null) { throw new UnsupportedOperationException("Unknown uri: " + uri); } switch (matchingUriEnum) { case BLOCKS: { return builder.table(Tables.BLOCKS); } case BLOCKS_BETWEEN: { final List<String> segments = uri.getPathSegments(); final String startTime = segments.get(2); final String endTime = segments.get(3); return builder.table(Tables.BLOCKS) .where(Blocks.BLOCK_START + ">=?", startTime) .where(Blocks.BLOCK_START + "<=?", endTime); } case BLOCKS_ID: { final String blockId = Blocks.getBlockId(uri); return builder.table(Tables.BLOCKS) .where(Blocks.BLOCK_ID + "=?", blockId); } case CARDS: { return builder.table(Tables.CARDS); } case TAGS: { return builder.table(Tables.TAGS); } case TAGS_ID: { final String tagId = Tags.getTagId(uri); return builder.table(Tables.TAGS) .where(Tags.TAG_ID + "=?", tagId); } case ROOMS: { return builder.table(Tables.ROOMS); } case ROOMS_ID: { final String roomId = Rooms.getRoomId(uri); return builder.table(Tables.ROOMS) .where(Rooms.ROOM_ID + "=?", roomId); } case ROOMS_ID_SESSIONS: { final String roomId = Rooms.getRoomId(uri); return builder.table(Tables.SESSIONS_JOIN_ROOMS, getCurrentAccountName(uri, true)) .mapToTable(Sessions._ID, Tables.SESSIONS) .mapToTable(Sessions.ROOM_ID, Tables.SESSIONS) .where(Qualified.SESSIONS_ROOM_ID + "=?", roomId) .groupBy(Qualified.SESSIONS_SESSION_ID); } case SESSIONS: { // We query sessions on the joined table of sessions with rooms and tags. // Since there may be more than one tag per session, we GROUP BY session ID. // The starred sessions ("my schedule") are associated with a user, so we // use the current user to select them properly return builder .table(Tables.SESSIONS_JOIN_ROOMS_TAGS, getCurrentAccountName(uri, true)) .mapToTable(Sessions._ID, Tables.SESSIONS) .mapToTable(Sessions.ROOM_ID, Tables.SESSIONS) .mapToTable(Sessions.SESSION_ID, Tables.SESSIONS) .map(Sessions.SESSION_IN_MY_SCHEDULE, "IFNULL(in_schedule, 0)") .groupBy(Qualified.SESSIONS_SESSION_ID); } case SESSIONS_COUNTER: { return builder .table(Tables.SESSIONS_JOIN_MYSCHEDULE, getCurrentAccountName(uri, true)) .map(Sessions.SESSION_INTERVAL_COUNT, "count(1)") .map(Sessions.SESSION_IN_MY_SCHEDULE, "IFNULL(in_schedule, 0)") .groupBy(Sessions.SESSION_START + ", " + Sessions.SESSION_END); } case SESSIONS_MY_SCHEDULE: { return builder.table(Tables.SESSIONS_JOIN_ROOMS_TAGS_FEEDBACK_MYSCHEDULE, getCurrentAccountName(uri, true)) .mapToTable(Sessions._ID, Tables.SESSIONS) .mapToTable(Sessions.ROOM_ID, Tables.SESSIONS) .mapToTable(Sessions.SESSION_ID, Tables.SESSIONS) .map(Sessions.HAS_GIVEN_FEEDBACK, Subquery.SESSION_HAS_GIVEN_FEEDBACK) .map(Sessions.SESSION_IN_MY_SCHEDULE, "IFNULL(in_schedule, 0)") .where("( " + Sessions.SESSION_IN_MY_SCHEDULE + "=1 OR " + Sessions.SESSION_TAGS + " LIKE '%" + Config.Tags.SPECIAL_KEYNOTE + "%' )") .groupBy(Qualified.SESSIONS_SESSION_ID); } case SESSIONS_UNSCHEDULED: { final long[] interval = Sessions.getInterval(uri); return builder.table(Tables.SESSIONS_JOIN_ROOMS_TAGS_FEEDBACK_MYSCHEDULE, getCurrentAccountName(uri, true)) .mapToTable(Sessions._ID, Tables.SESSIONS) .mapToTable(Sessions.ROOM_ID, Tables.SESSIONS) .mapToTable(Sessions.SESSION_ID, Tables.SESSIONS) .map(Sessions.SESSION_IN_MY_SCHEDULE, "IFNULL(in_schedule, 0)") .where(Sessions.SESSION_IN_MY_SCHEDULE + "=0") .where(Sessions.SESSION_START + ">=?", String.valueOf(interval[0])) .where(Sessions.SESSION_START + "<?", String.valueOf(interval[1])) .groupBy(Qualified.SESSIONS_SESSION_ID); } case SESSIONS_SEARCH: { final String query = Sessions.getSearchQuery(uri); return builder.table(Tables.SESSIONS_SEARCH_JOIN_SESSIONS_ROOMS, getCurrentAccountName(uri, true)) .map(Sessions.SEARCH_SNIPPET, Subquery.SESSIONS_SNIPPET) .mapToTable(Sessions._ID, Tables.SESSIONS) .mapToTable(Sessions.SESSION_ID, Tables.SESSIONS) .mapToTable(Sessions.ROOM_ID, Tables.SESSIONS) .map(Sessions.SESSION_IN_MY_SCHEDULE, "IFNULL(in_schedule, 0)") .where(SessionsSearchColumns.BODY + " MATCH ?", query); } case SESSIONS_AT: { final List<String> segments = uri.getPathSegments(); final String time = segments.get(2); return builder.table(Tables.SESSIONS_JOIN_ROOMS, getCurrentAccountName(uri, true)) .mapToTable(Sessions._ID, Tables.SESSIONS) .mapToTable(Sessions.ROOM_ID, Tables.SESSIONS) .where(Sessions.SESSION_START + "<=?", time) .where(Sessions.SESSION_END + ">=?", time); } case SESSIONS_ID: { final String sessionId = Sessions.getSessionId(uri); return builder.table(Tables.SESSIONS_JOIN_ROOMS, getCurrentAccountName(uri, true)) .mapToTable(Sessions._ID, Tables.SESSIONS) .mapToTable(Sessions.ROOM_ID, Tables.SESSIONS) .mapToTable(Sessions.SESSION_ID, Tables.SESSIONS) .map(Sessions.SESSION_IN_MY_SCHEDULE, "IFNULL(in_schedule, 0)") .where(Qualified.SESSIONS_SESSION_ID + "=?", sessionId); } case SESSIONS_ID_SPEAKERS: { final String sessionId = Sessions.getSessionId(uri); return builder.table(Tables.SESSIONS_SPEAKERS_JOIN_SPEAKERS) .mapToTable(Speakers._ID, Tables.SPEAKERS) .mapToTable(Speakers.SPEAKER_ID, Tables.SPEAKERS) .where(Qualified.SESSIONS_SPEAKERS_SESSION_ID + "=?", sessionId); } case SESSIONS_ID_TAGS: { final String sessionId = Sessions.getSessionId(uri); return builder.table(Tables.SESSIONS_TAGS_JOIN_TAGS) .mapToTable(Tags._ID, Tables.TAGS) .mapToTable(Tags.TAG_ID, Tables.TAGS) .where(Qualified.SESSIONS_TAGS_SESSION_ID + "=?", sessionId); } case SESSIONS_ROOM_AFTER: { final String room = Sessions.getRoom(uri); final String time = Sessions.getAfterForRoom(uri); return builder.table(Tables.SESSIONS_JOIN_ROOMS_TAGS, getCurrentAccountName(uri, true)) .mapToTable(Sessions._ID, Tables.SESSIONS) .mapToTable(Sessions.ROOM_ID, Tables.SESSIONS) .mapToTable(Sessions.SESSION_ID, Tables.SESSIONS) .where(Qualified.SESSIONS_ROOM_ID + "=?", room) .where("(" + Sessions.SESSION_START + "<= ? AND " + Sessions.SESSION_END + " >= ?) OR (" + Sessions.SESSION_START + " >= ?)", time, time, time) .map(Sessions.SESSION_IN_MY_SCHEDULE, "IFNULL(in_schedule, 0)") .groupBy(Qualified.SESSIONS_SESSION_ID); } case SESSIONS_AFTER: { final String time = Sessions.getAfter(uri); return builder.table(Tables.SESSIONS_JOIN_ROOMS_TAGS, getCurrentAccountName(uri, true)) .mapToTable(Sessions._ID, Tables.SESSIONS) .mapToTable(Sessions.SESSION_ID, Tables.SESSIONS) .mapToTable(Sessions.ROOM_ID, Tables.SESSIONS) .map(Sessions.SESSION_IN_MY_SCHEDULE, "IFNULL(in_schedule, 0)") .where("(" + Sessions.SESSION_START + "<= ? AND " + Sessions.SESSION_END + " >= ?) OR (" + Sessions.SESSION_START + " >= ?)", time, time, time) .groupBy(Qualified.SESSIONS_SESSION_ID); } case SPEAKERS: { return builder.table(Tables.SPEAKERS); } case MY_SCHEDULE: { // force a where condition to avoid leaking schedule info to another account // Note that, since SelectionBuilder always join multiple where calls using AND, // even if malicious code specifying additional conditions on account_name won't // be able to fetch data from a different account. return builder.table(Tables.MY_SCHEDULE) .where(MySchedule.MY_SCHEDULE_ACCOUNT_NAME + "=?", getCurrentAccountName(uri, true)); } case MY_FEEDBACK_SUBMITTED: { // force a where condition to avoid leaking schedule info to another account // Note that, since SelectionBuilder always join multiple where calls using AND, // even if malicious code specifying additional conditions on account_name won't // be able to fetch data from a different account. return builder.table(Tables.MY_FEEDBACK_SUBMITTED) .where(MyFeedbackSubmitted.MY_FEEDBACK_SUBMITTED_ACCOUNT_NAME + "=?", getCurrentAccountName(uri, true)); } case MY_VIEWED_VIDEOS: { // force a where condition to avoid leaking schedule info to another account // Note that, since SelectionBuilder always join multiple where calls using AND, // even if malicious code specifying additional conditions on account_name won't // be able to fetch data from a different account. return builder.table(Tables.MY_VIEWED_VIDEO) .where(MyViewedVideos.MY_VIEWED_VIDEOS_ACCOUNT_NAME + "=?", getCurrentAccountName(uri, true)); } case SPEAKERS_ID: { final String speakerId = Speakers.getSpeakerId(uri); return builder.table(Tables.SPEAKERS) .where(Speakers.SPEAKER_ID + "=?", speakerId); } case SPEAKERS_ID_SESSIONS: { final String speakerId = Speakers.getSpeakerId(uri); return builder.table(Tables.SESSIONS_SPEAKERS_JOIN_SESSIONS_ROOMS) .mapToTable(Sessions._ID, Tables.SESSIONS) .mapToTable(Sessions.SESSION_ID, Tables.SESSIONS) .mapToTable(Sessions.ROOM_ID, Tables.SESSIONS) .where(Qualified.SESSIONS_SPEAKERS_SPEAKER_ID + "=?", speakerId); } case ANNOUNCEMENTS: { return builder.table(Tables.ANNOUNCEMENTS); } case ANNOUNCEMENTS_ID: { final String announcementId = Announcements.getAnnouncementId(uri); return builder.table(Tables.ANNOUNCEMENTS) .where(Announcements.ANNOUNCEMENT_ID + "=?", announcementId); } case MAPMARKERS: { return builder.table(Tables.MAPMARKERS); } case MAPMARKERS_FLOOR: { final String floor = MapMarkers.getMarkerFloor(uri); return builder.table(Tables.MAPMARKERS) .where(MapMarkers.MARKER_FLOOR + "=?", floor); } case MAPMARKERS_ID: { final String roomId = MapMarkers.getMarkerId(uri); return builder.table(Tables.MAPMARKERS) .where(MapMarkers.MARKER_ID + "=?", roomId); } case MAPTILES: { return builder.table(Tables.MAPTILES); } case MAPTILES_FLOOR: { final String floor = MapTiles.getFloorId(uri); return builder.table(Tables.MAPTILES) .where(MapTiles.TILE_FLOOR + "=?", floor); } case FEEDBACK_FOR_SESSION: { final String sessionId = Feedback.getSessionId(uri); return builder.table(Tables.FEEDBACK) .where(Feedback.SESSION_ID + "=?", sessionId); } case FEEDBACK_ALL: { return builder.table(Tables.FEEDBACK); } case HASHTAGS: { return builder.table(Tables.HASHTAGS); } case HASHTAGS_NAME: { final String hashtagName = Hashtags.getHashtagName(uri); return builder.table(Tables.HASHTAGS) .where(HashtagColumns.HASHTAG_NAME + "=?", hashtagName); } case VIDEOS: { return builder.table(Tables.VIDEOS); } case VIDEOS_ID: { final String videoId = Videos.getVideoId(uri); return builder.table(Tables.VIDEOS) .where(VideoColumns.VIDEO_ID + "=?", videoId); } default: { throw new UnsupportedOperationException("Unknown uri: " + uri); } } } @Override public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException { throw new UnsupportedOperationException("openFile is not supported for " + uri); } private interface Subquery { String SESSION_HAS_GIVEN_FEEDBACK = "(SELECT COUNT(1) FROM " + Tables.FEEDBACK + " WHERE " + Qualified.FEEDBACK_SESSION_ID + "=" + Qualified.SESSIONS_SESSION_ID + ")"; String SESSIONS_SNIPPET = "snippet(" + Tables.SESSIONS_SEARCH + ",'{','}','\u2026')"; } /** * {@link ScheduleContract} fields that are fully qualified with a specific * parent {@link Tables}. Used when needed to work around SQL ambiguity. */ private interface Qualified { String SESSIONS_SESSION_ID = Tables.SESSIONS + "." + Sessions.SESSION_ID; String SESSIONS_ROOM_ID = Tables.SESSIONS + "." + Sessions.ROOM_ID; String SESSIONS_TAGS_SESSION_ID = Tables.SESSIONS_TAGS + "." + ScheduleDatabase.SessionsTags.SESSION_ID; String SESSIONS_SPEAKERS_SESSION_ID = Tables.SESSIONS_SPEAKERS + "." + SessionsSpeakers.SESSION_ID; String SESSIONS_SPEAKERS_SPEAKER_ID = Tables.SESSIONS_SPEAKERS + "." + SessionsSpeakers.SPEAKER_ID; String FEEDBACK_SESSION_ID = Tables.FEEDBACK + "." + Feedback.SESSION_ID; } }