package com.battlelancer.seriesguide.util;
import android.annotation.SuppressLint;
import android.content.ContentProviderOperation;
import android.content.ContentValues;
import android.content.Context;
import android.content.OperationApplicationException;
import android.database.Cursor;
import android.net.Uri;
import android.os.AsyncTask;
import android.preference.PreferenceManager;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.content.ContextCompat;
import android.support.v4.os.AsyncTaskCompat;
import android.support.v4.util.SparseArrayCompat;
import android.text.TextUtils;
import android.widget.TextView;
import android.widget.Toast;
import com.battlelancer.seriesguide.R;
import com.battlelancer.seriesguide.SgApp;
import com.battlelancer.seriesguide.backend.HexagonTools;
import com.battlelancer.seriesguide.backend.settings.HexagonSettings;
import com.battlelancer.seriesguide.enums.NetworkResult;
import com.battlelancer.seriesguide.enums.Result;
import com.battlelancer.seriesguide.items.SearchResult;
import com.battlelancer.seriesguide.provider.SeriesGuideContract;
import com.battlelancer.seriesguide.sync.SgSyncAdapter;
import com.battlelancer.seriesguide.util.tasks.AddShowToWatchlistTask;
import com.battlelancer.seriesguide.util.tasks.RemoveShowFromWatchlistTask;
import com.google.api.client.util.DateTime;
import com.uwetrottmann.androidutils.AndroidUtils;
import com.uwetrottmann.seriesguide.backend.shows.Shows;
import com.uwetrottmann.seriesguide.backend.shows.model.Show;
import com.uwetrottmann.seriesguide.backend.shows.model.ShowList;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import timber.log.Timber;
/**
* Common activities and tools useful when interacting with shows.
*/
public class ShowTools {
public static class ShowChangedEvent {
public int showTvdbId;
public ShowChangedEvent(int showTvdbId) {
this.showTvdbId = showTvdbId;
}
}
/**
* Show status valued as stored in the database in {@link com.battlelancer.seriesguide.provider.SeriesGuideContract.Shows#STATUS}.
*/
public interface Status {
int CONTINUING = 1;
int ENDED = 0;
int UNKNOWN = -1;
}
private final SgApp app;
public ShowTools(SgApp app) {
this.app = app;
}
/**
* Removes a show and its seasons and episodes, including all images. Sends isRemoved flag to
* Hexagon.
*
* @return One of {@link com.battlelancer.seriesguide.enums.NetworkResult}.
*/
public int removeShow(int showTvdbId) {
if (HexagonSettings.isEnabled(app)) {
if (!AndroidUtils.isNetworkConnected(app)) {
return NetworkResult.OFFLINE;
}
// send to cloud
sendIsRemoved(showTvdbId);
}
// remove database entries in stages, so if an earlier stage fails, user can at least try again
// also saves memory by applying batches early
// SEARCH DATABASE ENTRIES
final Cursor episodes = app.getContentResolver().query(
SeriesGuideContract.Episodes.buildEpisodesOfShowUri(showTvdbId), new String[] {
SeriesGuideContract.Episodes._ID
}, null, null, null
);
if (episodes == null) {
// failed
return Result.ERROR;
}
List<String> episodeTvdbIds = new LinkedList<>(); // need those for search entries
while (episodes.moveToNext()) {
episodeTvdbIds.add(episodes.getString(0));
}
episodes.close();
// remove episode search database entries
final ArrayList<ContentProviderOperation> batch = new ArrayList<>();
for (String episodeTvdbId : episodeTvdbIds) {
batch.add(ContentProviderOperation.newDelete(
SeriesGuideContract.EpisodeSearch.buildDocIdUri(episodeTvdbId)).build());
}
try {
DBUtils.applyInSmallBatches(app, batch);
} catch (OperationApplicationException e) {
Timber.e(e, "Removing episode search entries failed");
return Result.ERROR;
}
batch.clear();
// ACTUAL ENTITY ENTRIES
// remove episodes, seasons and show
batch.add(ContentProviderOperation.newDelete(
SeriesGuideContract.Episodes.buildEpisodesOfShowUri(showTvdbId)).build());
batch.add(ContentProviderOperation.newDelete(
SeriesGuideContract.Seasons.buildSeasonsOfShowUri(showTvdbId)).build());
batch.add(ContentProviderOperation.newDelete(
SeriesGuideContract.Shows.buildShowUri(showTvdbId)).build());
try {
DBUtils.applyInSmallBatches(app, batch);
} catch (OperationApplicationException e) {
Timber.e(e, "Removing episodes, seasons and show failed");
return Result.ERROR;
}
// make sure other loaders (activity, overview, details, search) are notified
app.getContentResolver().notifyChange(
SeriesGuideContract.Episodes.CONTENT_URI_WITHSHOW, null);
app.getContentResolver().notifyChange(
SeriesGuideContract.Shows.CONTENT_URI_FILTER, null);
return Result.SUCCESS;
}
/**
* Adds the show on Hexagon. Or if it does already exist, clears the isRemoved flag and updates
* the language, so the show will be auto-added on other connected devices.
*/
public void sendIsAdded(int showTvdbId, @NonNull String language) {
Show show = new Show();
show.setTvdbId(showTvdbId);
show.setLanguage(language);
show.setIsRemoved(false);
uploadShowAsync(show);
}
/**
* Sets the isRemoved flag of the given show on Hexagon, so the show will not be auto-added on
* any device connected to Hexagon.
*/
public void sendIsRemoved(int showTvdbId) {
Show show = new Show();
show.setTvdbId(showTvdbId);
show.setIsRemoved(true);
uploadShowAsync(show);
}
/**
* Saves new favorite flag to the local database and, if signed in, up into the cloud as well.
*/
public void storeIsFavorite(int showTvdbId, boolean isFavorite) {
if (HexagonSettings.isEnabled(app)) {
if (Utils.isNotConnected(app, true)) {
return;
}
// send to cloud
Show show = new Show();
show.setTvdbId(showTvdbId);
show.setIsFavorite(isFavorite);
uploadShowAsync(show);
}
// save to local database
ContentValues values = new ContentValues();
values.put(SeriesGuideContract.Shows.FAVORITE, isFavorite);
app.getContentResolver().update(
SeriesGuideContract.Shows.buildShowUri(showTvdbId), values, null, null);
// also notify URIs used by search and lists
app.getContentResolver()
.notifyChange(SeriesGuideContract.Shows.CONTENT_URI_FILTER, null);
app.getContentResolver()
.notifyChange(SeriesGuideContract.ListItems.CONTENT_WITH_DETAILS_URI, null);
// favorite status may determine eligibility for notifications
Utils.runNotificationService(app);
Toast.makeText(app, app.getString(isFavorite ?
R.string.favorited : R.string.unfavorited), Toast.LENGTH_SHORT).show();
}
/**
* Saves new hidden flag to the local database and, if signed in, up into the cloud as well.
*/
public void storeIsHidden(int showTvdbId, boolean isHidden) {
if (HexagonSettings.isEnabled(app)) {
if (Utils.isNotConnected(app, true)) {
return;
}
// send to cloud
Show show = new Show();
show.setTvdbId(showTvdbId);
show.setIsHidden(isHidden);
uploadShowAsync(show);
}
// save to local database
ContentValues values = new ContentValues();
values.put(SeriesGuideContract.Shows.HIDDEN, isHidden);
app.getContentResolver().update(
SeriesGuideContract.Shows.buildShowUri(showTvdbId), values, null, null);
// also notify filter URI used by search
app.getContentResolver()
.notifyChange(SeriesGuideContract.Shows.CONTENT_URI_FILTER, null);
Toast.makeText(app, app.getString(isHidden ?
R.string.hidden : R.string.unhidden), Toast.LENGTH_SHORT).show();
}
public void storeLanguage(final int showTvdbId, final String languageCode) {
if (HexagonSettings.isEnabled(app)) {
if (Utils.isNotConnected(app, true)) {
return;
}
// send to cloud
Show show = new Show();
show.setTvdbId(showTvdbId);
show.setLanguage(languageCode);
uploadShowAsync(show);
}
// schedule database update and sync
Runnable runnable = new Runnable() {
public void run() {
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND);
// change language
ContentValues values = new ContentValues();
values.put(SeriesGuideContract.Shows.LANGUAGE, languageCode);
app.getContentResolver()
.update(SeriesGuideContract.Shows.buildShowUri(showTvdbId), values, null,
null);
// reset episode last edit time so all get updated
values = new ContentValues();
values.put(SeriesGuideContract.Episodes.LAST_EDITED, 0);
app.getContentResolver()
.update(SeriesGuideContract.Episodes.buildEpisodesOfShowUri(showTvdbId),
values, null, null);
// trigger update
SgSyncAdapter.requestSyncImmediate(app, SgSyncAdapter.SyncType.SINGLE,
showTvdbId, false);
}
};
AsyncTask.THREAD_POOL_EXECUTOR.execute(runnable);
// show immediate feedback, also if offline and sync won't go through
if (AndroidUtils.isNetworkConnected(app)) {
// notify about upcoming sync
Toast.makeText(app, R.string.update_scheduled, Toast.LENGTH_SHORT).show();
} else {
// offline
Toast.makeText(app, R.string.update_no_connection, Toast.LENGTH_LONG).show();
}
}
/**
* Add a show to the users trakt watchlist.
*/
public static void addToWatchlist(SgApp app, int showTvdbId) {
AsyncTaskCompat.executeParallel(new AddShowToWatchlistTask(app, showTvdbId));
}
/**
* Remove a show from the users trakt watchlist.
*/
public static void removeFromWatchlist(SgApp app, int showTvdbId) {
AsyncTaskCompat.executeParallel(new RemoveShowFromWatchlistTask(app, showTvdbId));
}
private void uploadShowAsync(Show show) {
AsyncTaskCompat.executeParallel(
new ShowsUploadTask(app, show)
);
}
private static class ShowsUploadTask extends AsyncTask<Void, Void, Void> {
private final SgApp app;
private final Show mShow;
public ShowsUploadTask(SgApp app, Show show) {
this.app = app;
mShow = show;
}
@Override
protected Void doInBackground(Void... params) {
List<Show> shows = new LinkedList<>();
shows.add(mShow);
Upload.toHexagon(app, shows);
return null;
}
}
public static class Upload {
/**
* Uploads all local shows to Hexagon.
*/
public static boolean toHexagon(SgApp app) {
Timber.d("toHexagon: uploading all shows");
List<Show> shows = buildShowList(app);
if (shows == null) {
Timber.e("toHexagon: show query was null");
return false;
}
if (shows.size() == 0) {
Timber.d("toHexagon: no shows to upload");
// nothing to upload
return true;
}
return toHexagon(app, shows);
}
/**
* Uploads the given list of shows to Hexagon.
*
* @return One of {@link com.battlelancer.seriesguide.enums.Result}.
*/
public static boolean toHexagon(SgApp app, List<Show> shows) {
// wrap into helper object
ShowList showList = new ShowList();
showList.setShows(shows);
// upload shows
try {
Shows showsService = app.getHexagonTools().getShowsService();
if (showsService == null) {
return false;
}
showsService.save(showList).execute();
} catch (IOException e) {
HexagonTools.trackFailedRequest(app, "save shows", e);
return false;
}
return true;
}
private static List<Show> buildShowList(Context context) {
List<Show> shows = new LinkedList<>();
Cursor query = context.getContentResolver()
.query(SeriesGuideContract.Shows.CONTENT_URI, new String[] {
SeriesGuideContract.Shows._ID, // 0
SeriesGuideContract.Shows.FAVORITE,
SeriesGuideContract.Shows.HIDDEN, // 2
SeriesGuideContract.Shows.LANGUAGE
}, null, null, null);
if (query == null) {
return null;
}
while (query.moveToNext()) {
Show show = new Show();
show.setTvdbId(query.getInt(0));
show.setIsFavorite(query.getInt(1) == 1);
show.setIsHidden(query.getInt(2) == 1);
show.setLanguage(query.getString(3));
shows.add(show);
}
query.close();
return shows;
}
}
public static class Download {
/**
* Downloads shows from Hexagon and updates existing shows with new property values. Any
* shows not yet in the local database, determined by the given TVDb id set, will be added
* to the given map.
*/
@SuppressLint("ApplySharedPref")
public static boolean fromHexagon(SgApp app, HashSet<Integer> existingShows,
HashMap<Integer, SearchResult> newShows, boolean hasMergedShows) {
List<Show> shows;
boolean hasMoreShows = true;
String cursor = null;
long currentTime = System.currentTimeMillis();
DateTime lastSyncTime = new DateTime(HexagonSettings.getLastShowsSyncTime(app));
if (hasMergedShows) {
Timber.d("fromHexagon: downloading changed shows since %s", lastSyncTime);
} else {
Timber.d("fromHexagon: downloading all shows");
}
while (hasMoreShows) {
// abort if connection is lost
if (!AndroidUtils.isNetworkConnected(app)) {
Timber.e("fromHexagon: no network connection");
return false;
}
try {
Shows showsService = app.getHexagonTools().getShowsService();
if (showsService == null) {
return false;
}
Shows.Get request = showsService.get(); // use default server limit
if (hasMergedShows) {
// only get changed shows (otherwise returns all)
request.setUpdatedSince(lastSyncTime);
}
if (!TextUtils.isEmpty(cursor)) {
request.setCursor(cursor);
}
ShowList response = request.execute();
if (response == null) {
// we're done
Timber.d("fromHexagon: response was null, done here");
break;
}
shows = response.getShows();
// check for more items
if (response.getCursor() != null) {
cursor = response.getCursor();
} else {
hasMoreShows = false;
}
} catch (IOException e) {
HexagonTools.trackFailedRequest(app, "get shows", e);
return false;
}
if (shows == null || shows.size() == 0) {
// nothing to do here
break;
}
// update all received shows, ContentProvider will ignore those not added locally
ArrayList<ContentProviderOperation> batch = buildShowUpdateOps(shows, existingShows,
newShows, !hasMergedShows);
try {
DBUtils.applyInSmallBatches(app, batch);
} catch (OperationApplicationException e) {
Timber.e(e, "fromHexagon: applying show updates failed");
return false;
}
}
if (hasMergedShows) {
// set new last sync time
PreferenceManager.getDefaultSharedPreferences(app)
.edit()
.putLong(HexagonSettings.KEY_LAST_SYNC_SHOWS, currentTime)
.commit();
}
return true;
}
private static ArrayList<ContentProviderOperation> buildShowUpdateOps(List<Show> shows,
HashSet<Integer> existingShows, HashMap<Integer, SearchResult> newShows,
boolean mergeValues) {
ArrayList<ContentProviderOperation> batch = new ArrayList<>();
ContentValues values = new ContentValues();
for (Show show : shows) {
// schedule to add shows not in local database
if (!existingShows.contains(show.getTvdbId())) {
// ...but do NOT add shows marked as removed
if (show.getIsRemoved() != null && show.getIsRemoved()) {
continue;
}
if (!newShows.containsKey(show.getTvdbId())) {
SearchResult item = new SearchResult();
item.tvdbid = show.getTvdbId();
item.language = show.getLanguage();
item.title = "";
newShows.put(show.getTvdbId(), item);
}
continue;
}
buildShowPropertyValues(show, values, mergeValues);
// build update op
if (values.size() > 0) {
ContentProviderOperation op = ContentProviderOperation
.newUpdate(SeriesGuideContract.Shows.buildShowUri(show.getTvdbId()))
.withValues(values).build();
batch.add(op);
// clean up for re-use
values.clear();
}
}
return batch;
}
/**
* @param mergeValues If set, only overwrites property if remote show property has a certain
* value.
*/
private static void buildShowPropertyValues(Show show, ContentValues values,
boolean mergeValues) {
if (show.getIsFavorite() != null) {
// when merging, favorite shows, but never unfavorite them
if (!mergeValues || show.getIsFavorite()) {
values.put(SeriesGuideContract.Shows.FAVORITE, show.getIsFavorite());
}
}
if (show.getIsHidden() != null) {
// when merging, un-hide shows, but never hide them
if (!mergeValues || !show.getIsHidden()) {
values.put(SeriesGuideContract.Shows.HIDDEN, show.getIsHidden());
}
}
if (!TextUtils.isEmpty(show.getLanguage())) {
// always overwrite with hexagon language value
values.put(SeriesGuideContract.Shows.LANGUAGE, show.getLanguage());
}
}
}
public static boolean addLastWatchedUpdateOpIfNewer(Context context,
ArrayList<ContentProviderOperation> batch, int showTvdbId, long lastWatchedMsNew) {
Uri uri = SeriesGuideContract.Shows.buildShowUri(showTvdbId);
Cursor query = context.getContentResolver().query(uri, new String[] {
SeriesGuideContract.Shows.LASTWATCHED_MS }, null, null, null);
if (query == null) {
Timber.e("addLastWatchedTimeUpdateOpIfNewer: query was null.");
return false;
}
if (!query.moveToFirst()) {
Timber.e("addLastWatchedTimeUpdateOpIfNewer: query has no results.");
query.close();
return false;
}
long lastWatchedMs = query.getLong(0);
query.close();
if (lastWatchedMs < lastWatchedMsNew) {
batch.add(ContentProviderOperation.newUpdate(uri)
.withValue(SeriesGuideContract.Shows.LASTWATCHED_MS, lastWatchedMsNew)
.build());
}
return true;
}
/**
* Returns the trakt id of a show. Returns {@code null} if the query failed, there is no trakt
* id or if it is invalid.
*/
@Nullable
public static Integer getShowTraktId(@NonNull Context context, int showTvdbId) {
Cursor traktIdQuery = context.getContentResolver()
.query(SeriesGuideContract.Shows.buildShowUri(showTvdbId),
new String[] { SeriesGuideContract.Shows.TRAKT_ID }, null, null, null);
if (traktIdQuery == null) {
return null;
}
Integer traktId = null;
if (traktIdQuery.moveToFirst()) {
traktId = traktIdQuery.getInt(0);
if (traktId <= 0) {
traktId = null;
}
}
traktIdQuery.close();
return traktId;
}
/**
* Returns a set of the TVDb ids of all shows in the local database.
*
* @return null if there was an error, empty list if there are no shows.
*/
@Nullable
public static HashSet<Integer> getShowTvdbIdsAsSet(Context context) {
HashSet<Integer> existingShows = new HashSet<>();
Cursor shows = context.getContentResolver().query(SeriesGuideContract.Shows.CONTENT_URI,
new String[] { SeriesGuideContract.Shows._ID }, null, null, null);
if (shows == null) {
return null;
}
while (shows.moveToNext()) {
existingShows.add(shows.getInt(0));
}
shows.close();
return existingShows;
}
/**
* Returns a set of the TVDb ids of all shows in the local database mapped to their poster path
* (null if there is no poster).
*
* @return null if there was an error, empty list if there are no shows.
*/
@Nullable
public static SparseArrayCompat<String> getShowTvdbIdsAndPosters(Context context) {
SparseArrayCompat<String> existingShows = new SparseArrayCompat<>();
Cursor shows = context.getContentResolver().query(SeriesGuideContract.Shows.CONTENT_URI,
new String[] { SeriesGuideContract.Shows._ID, SeriesGuideContract.Shows.POSTER },
null, null, null);
if (shows == null) {
return null;
}
while (shows.moveToNext()) {
existingShows.put(shows.getInt(0), shows.getString(1));
}
shows.close();
return existingShows;
}
/**
* Decodes the show status and returns the localized text representation. May be {@code null} if
* status is unknown.
*
* @param encodedStatus Detection based on {@link com.battlelancer.seriesguide.util.ShowTools.Status}.
*/
@Nullable
public static String getStatus(@NonNull Context context, int encodedStatus) {
if (encodedStatus == Status.CONTINUING) {
return context.getString(R.string.show_isalive);
} else if (encodedStatus == Status.ENDED) {
return context.getString(R.string.show_isnotalive);
} else {
// status unknown, display nothing
return null;
}
}
/**
* Gets the show status from {@link #getStatus} and sets a status dependant text color on the
* given view.
*
* @param encodedStatus Detection based on {@link com.battlelancer.seriesguide.util.ShowTools.Status}.
*/
public static void setStatusAndColor(@NonNull TextView view, int encodedStatus) {
view.setText(getStatus(view.getContext(), encodedStatus));
if (encodedStatus == Status.CONTINUING) {
view.setTextColor(
ContextCompat.getColor(view.getContext(), Utils.resolveAttributeToResourceId(
view.getContext().getTheme(), R.attr.sgTextColorGreen)));
} else {
view.setTextColor(
ContextCompat.getColor(view.getContext(), Utils.resolveAttributeToResourceId(
view.getContext().getTheme(), android.R.attr.textColorSecondary)));
}
}
}