/*
* 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.videolibrary;
import android.app.LoaderManager;
import android.content.AsyncQueryHandler;
import android.content.ContentValues;
import android.content.Context;
import android.content.CursorLoader;
import android.content.Loader;
import android.database.Cursor;
import android.graphics.Color;
import android.net.Uri;
import android.os.Bundle;
import android.support.annotation.ColorInt;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.text.TextUtils;
import com.google.common.annotations.VisibleForTesting;
import com.google.samples.apps.iosched.R;
import com.google.samples.apps.iosched.appwidget.ScheduleWidgetProvider;
import com.google.samples.apps.iosched.archframework.Model;
import com.google.samples.apps.iosched.archframework.ModelWithLoaderManager;
import com.google.samples.apps.iosched.archframework.QueryEnum;
import com.google.samples.apps.iosched.archframework.UserActionEnum;
import com.google.samples.apps.iosched.model.TagMetadata;
import com.google.samples.apps.iosched.provider.ScheduleContract;
import com.google.samples.apps.iosched.sync.SyncHelper;
import com.google.samples.apps.iosched.util.AccountUtils;
import com.google.samples.apps.iosched.util.ParserUtils;
import com.google.samples.apps.iosched.videolibrary.VideoLibraryModel.VideoLibraryQueryEnum;
import com.google.samples.apps.iosched.videolibrary.VideoLibraryModel.VideoLibraryUserActionEnum;
import com.google.samples.apps.iosched.videolibrary.data.Video;
import com.google.samples.apps.iosched.videolibrary.data.VideoTrack;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
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.makeLogTag;
/**
* This is an implementation of a {@link Model} that queries data for the Video library feature.
* <p/>
* Two types of Data can be queried: A list of Videos which can be filtered by year and Topic and a
* list of available years and topics which can be used for filtering.
* <p/>
* The data can be loaded using two queries: {@link VideoLibraryModel.VideoLibraryQueryEnum#VIDEOS}
* and {@link VideoLibraryModel.VideoLibraryQueryEnum#FILTERS}. The query for videos can be filtered
* by year and topic. These filters values needs to be set in a {@code Bundle} passed to the {@link
* #createCursorLoader(VideoLibraryQueryEnum, Bundle)} like this:
* <p/>
* {@code Bundle args = new Bundle(); args.putInt(VideoLibraryModel.KEY_YEAR, selectedYear);
* args.putString(VideoLibraryModel.KEY_TOPIC, selectedTopic);}
* <p/>
* Once the data has been loaded it can be retrieved using {@link #getVideos()}, {@link #getYears()}
* and {@link #getTopics()}.
* <p/>
* The process of loading and reading video library data is typically done in the lifecycle of a
* {@link com.google.samples.apps.iosched.archframework.PresenterImpl}.
*/
public class VideoLibraryModel
extends ModelWithLoaderManager<VideoLibraryQueryEnum, VideoLibraryUserActionEnum> {
public static final int TRACK_ID_NEW = 0;
public static final int TRACK_ID_KEYNOTES = 1;
protected static final String KEY_YEAR = "com.google.samples.apps.iosched.KEY_YEAR";
protected static final String KEY_TOPIC = "com.google.samples.apps.iosched.KEY_TOPIC";
protected static final String KEY_VIDEO_ID = "com.google.samples.apps.iosched.KEY_VIDEO_ID";
protected static final int ALL_YEARS = 0;
protected static final String ALL_TOPICS = "__";
protected static final String KEYNOTES_TOPIC = "Keynote";
private static final String TAG = makeLogTag(VideoLibraryModel.class);
private List<Integer> mYears;
private List<String> mTopics;
private VideoTrack mKeynoteVideos;
private VideoTrack mCurrentYearVideos;
private List<VideoTrack> mVideos;
private Set<String> mViewedVideosIds = new HashSet<>();
private int mSelectedYear = ALL_YEARS;
private String mSelectedTopic = ALL_TOPICS;
private Context mContext;
private Uri mVideoUri;
private Uri mMyVideosUri;
private Uri mFilterUri;
private TagMetadata mTagMetadata;
public VideoLibraryModel(Context context, LoaderManager loaderManager, Uri videoUri,
Uri myVideosUri, Uri filterUri) {
super(VideoLibraryQueryEnum.values(), VideoLibraryUserActionEnum.values(), loaderManager);
mContext = context;
mVideoUri = videoUri;
mMyVideosUri = myVideosUri;
mFilterUri = filterUri;
}
public String getSelectedTopic() {
return mSelectedTopic;
}
public void setSelectedTopic(String selectedTopic) {
mSelectedTopic = selectedTopic;
}
public int getSelectedYear() {
return mSelectedYear;
}
public void setSelectedYear(int selectedYear) {
mSelectedYear = selectedYear;
}
public @Nullable String getSelectedTopicImageUrl() {
if (mSelectedTopic != null && mTagMetadata != null) {
final TagMetadata.Tag tag = mTagMetadata.getTag(mSelectedTopic);
if (tag != null) {
return tag.getPhotoUrl();
}
}
return null;
}
public @ColorInt int getSelectedTopicColor() {
if (mSelectedTopic != null && mTagMetadata != null) {
final TagMetadata.Tag tag = mTagMetadata.getTag(mSelectedTopic);
if (tag != null) {
return tag.getColor();
}
}
return Color.TRANSPARENT;
}
/**
* Returns the keynote {@link VideoTrack} as retrieved by the last run of a {@link
* VideoLibraryModel.VideoLibraryQueryEnum#VIDEOS} query or {@code null} if no VIDEOS queries
* have been ran before.
*/
public VideoTrack getKeynoteVideos() {
return mKeynoteVideos;
}
/**
* Returns the {@link VideoTrack} listing any videos released this year, as retrieved by the
* last run of a {@link VideoLibraryModel.VideoLibraryQueryEnum#VIDEOS} query or {@code null} if
* no VIDEOS queries have been ran before.
*/
public VideoTrack getCurrentYearVideos() {
return mCurrentYearVideos;
}
/**
* Returns the list of {@link VideoTrack}s retrieved by the last run of a {@link
* VideoLibraryModel.VideoLibraryQueryEnum#VIDEOS} query or {@code null} if no VIDEOS queries
* have been ran before.
*/
public List<VideoTrack> getVideos() {
return mVideos;
}
/**
* Convenience method for retrieving a flat list of <b>all</b> videos retrieved by the last run
* of a {@link VideoLibraryModel.VideoLibraryQueryEnum#VIDEOS} query or an empty {@code List} if
* no VIDEOS queries have been ran before.
*
* @return
*/
public List<Video> getAllVideos() {
List<Video> allVideos = new ArrayList<>();
if (mKeynoteVideos != null && mKeynoteVideos.hasVideos()) {
allVideos.addAll(mKeynoteVideos.getVideos());
}
if (mCurrentYearVideos != null && mCurrentYearVideos.hasVideos()) {
allVideos.addAll(mCurrentYearVideos.getVideos());
}
if (mVideos != null && !mVideos.isEmpty()) {
for (final VideoTrack videoTrack : mVideos) {
if (videoTrack.hasVideos()) {
allVideos.addAll(videoTrack.getVideos());
}
}
}
return allVideos;
}
public boolean hasVideos() {
return (mVideos != null && !mVideos.isEmpty()) ||
(mKeynoteVideos != null && mKeynoteVideos.hasVideos()) ||
(mCurrentYearVideos != null && mCurrentYearVideos.hasVideos());
}
/**
* Returns the alphabetically ordered list of topics for all videos or {@code null} if the
* {@link VideoLibraryModel.VideoLibraryQueryEnum#FILTERS} query have never been ran.
*/
public List<String> getTopics() {
return mTopics;
}
/**
* Returns the alphabetically ordered list of years for all videos or {@code null} if the {@link
* VideoLibraryModel.VideoLibraryQueryEnum#FILTERS} query have never been ran.
*/
public List<Integer> getYears() {
return mYears;
}
@Override
public void cleanUp() {
}
@Override
public void processUserAction(final VideoLibraryUserActionEnum action,
@Nullable final Bundle args, final UserActionCallback callback) {
switch (action) {
case VIDEO_PLAYED:
// If the action is a VIDEO_VIEWED we save the information that the video has
// been viewed by the user in AppData.
if (args != null && args.containsKey(KEY_VIDEO_ID)) {
String playedVideoId = args.getString(KEY_VIDEO_ID);
LOGD(TAG, "setVideoViewed id=" + playedVideoId);
Uri myPlayedVideoUri = ScheduleContract.MyViewedVideos.buildMyViewedVideosUri(
AccountUtils.getActiveAccountName(mContext));
AsyncQueryHandler handler =
new AsyncQueryHandler(mContext.getContentResolver()) {};
final ContentValues values = new ContentValues();
values.put(ScheduleContract.MyViewedVideos.VIDEO_ID, playedVideoId);
handler.startInsert(-1, null, myPlayedVideoUri, values);
// Because change listener is set to null during initialization, these
// won't fire on pageview.
mContext.sendBroadcast(
ScheduleWidgetProvider.getRefreshBroadcastIntent(mContext,
false));
// Request an immediate user data sync to reflect the viewed video in the cloud.
SyncHelper.requestManualSync(true);
} else {
LOGE(TAG,
"The VideoLibraryUserActionEnum.VIDEO_VIEWED action was called " +
"without a "
+ "proper Bundle.");
}
break;
}
}
@Override
public Loader<Cursor> createCursorLoader(final VideoLibraryQueryEnum query,
final Bundle args) {
CursorLoader loader = null;
switch (query) {
case VIDEOS:
ArrayList<String> selectionArgs = new ArrayList<>();
ArrayList<String> selectionClauses = new ArrayList<>();
// Extract possible filter values from the Bundle.
if (args != null && args.containsKey(KEY_YEAR)) {
mSelectedYear = args.getInt(KEY_YEAR);
}
if (args != null && args.containsKey(KEY_TOPIC)) {
mSelectedTopic = args.getString(KEY_TOPIC);
}
// If filter values have been set we add the filter clause to the Loader.
if (mSelectedYear > ALL_YEARS) {
selectionClauses.add(ScheduleContract.Videos.VIDEO_YEAR + "=?");
selectionArgs.add(Integer.toString(mSelectedYear));
}
if (mSelectedTopic != null && !mSelectedTopic.equals(ALL_TOPICS)) {
selectionClauses.add(ScheduleContract.Videos.VIDEO_TOPIC + "=?");
selectionArgs.add(mSelectedTopic);
}
String selection = selectionClauses.isEmpty() ? null :
ParserUtils.joinStrings(" AND ", selectionClauses, null);
String[] selectionArgsArray =
selectionArgs.isEmpty() ? null : selectionArgs.toArray(
new String[selectionArgs.size()]);
LOGD(TAG,
"Starting videos query, selection=" + selection + " (year=" + mSelectedYear
+ ", topic=" + mSelectedTopic);
// Create and return the Loader.
loader = getCursorLoaderInstance(mContext, mVideoUri,
VideoLibraryQueryEnum.VIDEOS.getProjection(), selection, selectionArgsArray,
ScheduleContract.Videos.DEFAULT_SORT);
break;
case FILTERS:
LOGD(TAG, "Starting Video Filters query");
loader = getCursorLoaderInstance(mContext, mFilterUri,
VideoLibraryQueryEnum.FILTERS.getProjection(), null, null, null);
break;
case MY_VIEWED_VIDEOS:
LOGD(TAG, "Starting My Viewed Videos query");
loader = getCursorLoaderInstance(mContext, mMyVideosUri,
VideoLibraryQueryEnum.MY_VIEWED_VIDEOS.getProjection(), null, null, null);
break;
case TAGS:
loader = TagMetadata.createCursorLoader(mContext);
}
return loader;
}
@Override
public boolean readDataFromCursor(final Cursor cursor, final VideoLibraryQueryEnum query) {
LOGD(TAG, "readDataFromCursor");
switch (query) {
case VIDEOS:
LOGD(TAG, "Reading video library collection Data from cursor.");
if (cursor.moveToFirst()) {
processVideos(cursor);
markVideosAsViewed();
}
return true;
case MY_VIEWED_VIDEOS:
LOGD(TAG, "Reading my viewed videos Data from cursor.");
mViewedVideosIds.clear();
if (cursor.moveToFirst()) {
do {
mViewedVideosIds.add(cursor.getString(cursor.getColumnIndex(
ScheduleContract.MyViewedVideos.VIDEO_ID)));
} while (cursor.moveToNext());
markVideosAsViewed();
return true;
}
return true;
case FILTERS:
// Read all the Years and Topics from the Cursor.
LOGD(TAG, "Reading video library collection Data from cursor.");
mYears = new ArrayList<>();
mTopics = new ArrayList<>();
if (cursor != null && cursor.moveToFirst()) {
do {
int year = cursor.getInt(
cursor.getColumnIndex(ScheduleContract.Videos.VIDEO_YEAR));
String topic = cursor.getString(
cursor.getColumnIndex(ScheduleContract.Videos.VIDEO_TOPIC));
// Build a list of unique Years and Topics.
if (!mYears.contains(year)) {
mYears.add(year);
}
if (!TextUtils.isEmpty(topic) && !mTopics.contains(topic)) {
mTopics.add(topic);
}
} while (cursor.moveToNext());
}
// Sort years in decreasing order (start with most recent).
Collections.sort(mYears, new Comparator<Integer>() {
@Override
public int compare(Integer a, Integer b) {
return b.compareTo(a);
}
});
Collections.sort(mTopics);
return true;
case TAGS:
mTagMetadata = new TagMetadata(cursor);
addImageUrlToVideoTracksIfAvailable();
return true;
default:
return false;
}
}
private void addImageUrlToVideoTracksIfAvailable() {
if (mTagMetadata != null) {
if (mKeynoteVideos != null) {
mKeynoteVideos.setTrackImageUrlIfAvailable(mTagMetadata);
}
if (mCurrentYearVideos != null) {
mCurrentYearVideos.setTrackImageUrlIfAvailable(mTagMetadata);
}
if (mVideos != null) {
for (VideoTrack track : mVideos) {
track.setTrackImageUrlIfAvailable(mTagMetadata);
}
}
}
}
/**
* Populate the model objects (mKeynoteVideos, mCurrentYearVideos & mVideos) from the given
* cursor. Note we assume that the data is already sorted by track (per {@link
* ScheduleContract.Videos#DEFAULT_SORT}).
*
* @param cursor The cursor to read data from.
*/
private void processVideos(final Cursor cursor) {
final int currentYear = Calendar.getInstance().get(Calendar.YEAR);
String currentTrack = null;
List<Video> keynoteVideos = new ArrayList<>();
List<Video> twentySixteenVideos = new ArrayList<>();
List<Video> currentTrackVideos = new ArrayList<>();
List<VideoTrack> videoTracks = new ArrayList<>();
do {
final Video video = readVideo(cursor);
final String track = video.getTopic();
if (track == null) {
continue;
}
// Special handling for keynotes & videos from this year
if (KEYNOTES_TOPIC.equals(track)) {
keynoteVideos.add(video);
} else if (video.getYear() == currentYear) {
twentySixteenVideos.add(video);
} else {
// Otherwise group by track
if (!track.equals(currentTrack)) {
// New track reached, store current track and update working vars
if (!currentTrackVideos.isEmpty()) {
videoTracks.add(new VideoTrack(currentTrack,
currentTrack.hashCode(), currentTrackVideos));
}
currentTrack = track;
currentTrackVideos = new ArrayList<>();
}
currentTrackVideos.add(video);
}
} while (cursor.moveToNext());
// After looping there should be one populated track not added to the list
if (!currentTrackVideos.isEmpty()) {
videoTracks.add(
new VideoTrack(currentTrack, currentTrack.hashCode(), currentTrackVideos));
}
// Store the (non keynote or current year) video tracks
mVideos = videoTracks;
// Store any videos from this year
if (!twentySixteenVideos.isEmpty()) {
final String newThisYear = mContext.getString(R.string.new_videos_title, currentYear);
mCurrentYearVideos = new VideoTrack(newThisYear, TRACK_ID_NEW, twentySixteenVideos);
}
// Store any keynote videos
if (!keynoteVideos.isEmpty()) {
mKeynoteVideos = new VideoTrack(KEYNOTES_TOPIC, TRACK_ID_KEYNOTES, keynoteVideos);
}
addImageUrlToVideoTracksIfAvailable();
}
/**
* Create a single {@link Video} object form the given cursor.
*/
private
@NonNull
Video readVideo(final Cursor cursor) {
return new Video(
cursor.getString(cursor.getColumnIndex(
ScheduleContract.Videos.VIDEO_ID)),
cursor.getInt(cursor.getColumnIndex(
ScheduleContract.Videos.VIDEO_YEAR)),
cursor.getString(cursor.getColumnIndex(
ScheduleContract.Videos.VIDEO_TOPIC)),
cursor.getString(cursor.getColumnIndex(
ScheduleContract.Videos.VIDEO_TITLE)),
cursor.getString(cursor.getColumnIndex(
ScheduleContract.Videos.VIDEO_DESC)),
cursor.getString(cursor.getColumnIndex(
ScheduleContract.Videos.VIDEO_VID)),
cursor.getString(cursor.getColumnIndex(
ScheduleContract.Videos.VIDEO_SPEAKERS)),
cursor.getString(cursor.getColumnIndex(
ScheduleContract.Videos.VIDEO_THUMBNAIL_URL)));
}
/**
* Mark videos as viewed if they are listed in {@code mViewedVideosIds}.
*/
private void markVideosAsViewed() {
if (mViewedVideosIds == null) {
return;
}
if (mKeynoteVideos != null && mKeynoteVideos.getVideos() != null) {
for (Video video : mKeynoteVideos.getVideos()) {
video.setAlreadyPlayed(mViewedVideosIds.contains(video.getId()));
}
}
if (mCurrentYearVideos != null && mCurrentYearVideos.getVideos() != null) {
for (Video video : mCurrentYearVideos.getVideos()) {
video.setAlreadyPlayed(mViewedVideosIds.contains(video.getId()));
}
}
if (mVideos != null && !mVideos.isEmpty()) {
for (final VideoTrack videoTrack : mVideos) {
if (videoTrack.getVideos() != null) {
for (final Video video : videoTrack.getVideos()) {
video.setAlreadyPlayed(mViewedVideosIds.contains(video.getId()));
}
}
}
}
}
@VisibleForTesting
public CursorLoader getCursorLoaderInstance(Context context, Uri uri, String[] projection,
String selection, String[] selectionArgs, String sortOrder) {
return new CursorLoader(context, uri, projection, selection, selectionArgs, sortOrder);
}
/**
* Enumeration of the possible queries that can be done by this Model to retrieve data.
*/
public enum VideoLibraryQueryEnum implements QueryEnum {
/**
* Query that retrieves a list of available videos.
* <p/>
* The query for videos can be filtered by year and topic. These filters values needs to be
* set in a {@code Bundle} passed to the {@link #createCursorLoader(VideoLibraryQueryEnum,
* Bundle)} like this:
* <p/>
* {@code Bundle args = new Bundle(); args.putInt(VideoLibraryModel.KEY_YEAR, selectedYear);
* args.putString(VideoLibraryModel.KEY_TOPIC, selectedTopic);}
* <p/>
* Once the data has been loaded it can be retrieved using {@link #getVideos()}.
*/
VIDEOS(0x1, new String[]{
ScheduleContract.Videos.VIDEO_ID,
ScheduleContract.Videos.VIDEO_YEAR,
ScheduleContract.Videos.VIDEO_TITLE,
ScheduleContract.Videos.VIDEO_DESC,
ScheduleContract.Videos.VIDEO_VID,
ScheduleContract.Videos.VIDEO_TOPIC,
ScheduleContract.Videos.VIDEO_SPEAKERS,
ScheduleContract.Videos.VIDEO_THUMBNAIL_URL,
}),
/**
* Query that retrieves a list of already viewed videos.
* <p/>
* Once the data has been loaded it can be retrieved using {@link #getVideos()}.
*/
MY_VIEWED_VIDEOS(0x2, new String[]{
ScheduleContract.MyViewedVideos.VIDEO_ID
}),
/**
* Query that retrieves the list of possible filter values such as all Years and Topics of
* existing videos.
* <p/>
* Once the data has been loaded it can be retrieved using {@link #getYears()} and {@link
* #getTopics()}.
*/
FILTERS(0x3, new String[]{
ScheduleContract.Videos.VIDEO_YEAR,
ScheduleContract.Videos.VIDEO_TOPIC
}),
/**
* Query that retrieves all the possible tags
*/
TAGS(0x4, null);
private int id;
private String[] projection;
VideoLibraryQueryEnum(int id, String[] projection) {
this.id = id;
this.projection = projection;
}
@Override
public int getId() {
return id;
}
@Override
public String[] getProjection() {
return projection;
}
}
/**
* Enumeration of the possible events that a user can trigger that would affect the state of the
* date of this Model.
*/
public enum VideoLibraryUserActionEnum implements UserActionEnum {
/**
* Event that is triggered when a user changes the filters of the Video Library. For
* instance when the year or topic filters are changed.
*/
CHANGE_FILTER(1),
/**
* Event that is triggered when a user clicks on a video to play it. We save that
* information because we grey out videos that have been played already.
*/
VIDEO_PLAYED(2),
/**
* Event that is triggered when a user changes the account.
*/
RELOAD_USER_VIDEOS(3);
private int id;
VideoLibraryUserActionEnum(int id) {
this.id = id;
}
@Override
public int getId() {
return id;
}
}
}