package com.boardgamegeek.model.persister;
import android.content.ContentProviderOperation;
import android.content.ContentProviderResult;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.text.TextUtils;
import com.boardgamegeek.model.CollectionItem;
import com.boardgamegeek.provider.BggContract;
import com.boardgamegeek.provider.BggContract.Collection;
import com.boardgamegeek.provider.BggContract.Games;
import com.boardgamegeek.provider.BggContract.Thumbnails;
import com.boardgamegeek.util.CursorUtils;
import com.boardgamegeek.util.FileUtils;
import com.boardgamegeek.util.PreferencesUtils;
import com.boardgamegeek.util.ResolverUtils;
import com.boardgamegeek.util.SelectionBuilder;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import hugo.weaving.DebugLog;
import timber.log.Timber;
public class CollectionPersister {
private static final int NOT_DIRTY = 0;
private final Context context;
private final ContentResolver resolver;
private final long updateTime;
private final boolean isBriefSync;
private final boolean includePrivateInfo;
private final boolean includeStats;
private final List<String> statusesToSync;
public static class Builder {
private final Context context;
private boolean isBriefSync;
private boolean includePrivateInfo;
private boolean includeStats;
private boolean validStatusesOnly;
@DebugLog
public Builder(Context context) {
this.context = context;
}
@DebugLog
public Builder brief() {
isBriefSync = true;
validStatusesOnly = false; // requires non-brief sync to fetch number of plays
return this;
}
@DebugLog
public Builder includePrivateInfo() {
includePrivateInfo = true;
return this;
}
@DebugLog
public Builder includeStats() {
includeStats = true;
return this;
}
@DebugLog
public Builder validStatusesOnly() {
validStatusesOnly = true;
isBriefSync = false; // we need to fetch the number of plays
return this;
}
@DebugLog
public CollectionPersister build() {
List<String> statuses = null;
if (validStatusesOnly) {
String[] syncStatuses = PreferencesUtils.getSyncStatuses(context);
statuses = syncStatuses != null ? Arrays.asList(syncStatuses) : new ArrayList<String>(0);
}
return new CollectionPersister(context, isBriefSync, includePrivateInfo, includeStats, statuses);
}
}
public static class SaveResults {
private int recordCount;
private final List<Integer> savedCollectionIds;
private final List<Integer> savedGameIds;
private final List<Integer> dirtyCollectionIds;
public SaveResults() {
recordCount = 0;
savedCollectionIds = new ArrayList<>();
savedGameIds = new ArrayList<>();
dirtyCollectionIds = new ArrayList<>();
}
public void increaseRecordCount(int count) {
recordCount += count;
}
public void addSavedCollectionId(int id) {
savedCollectionIds.add(id);
}
public void addSavedGameId(int id) {
savedGameIds.add(id);
}
public void addDirtyCollectionId(int id) {
dirtyCollectionIds.add(id);
}
public boolean hasGameBeenSaved(int gameId) {
return savedGameIds.contains(gameId);
}
public int getRecordCount() {
return recordCount;
}
public List<Integer> getSavedCollectionIds() {
return savedCollectionIds;
}
}
@DebugLog
private CollectionPersister(Context context, boolean isBriefSync, boolean includePrivateInfo, boolean includeStats, List<String> statusesToSync) {
this.context = context;
this.isBriefSync = isBriefSync;
this.includePrivateInfo = includePrivateInfo;
this.includeStats = includeStats;
this.statusesToSync = statusesToSync;
resolver = this.context.getContentResolver();
updateTime = System.currentTimeMillis();
}
@DebugLog
public long getInitialTimestamp() {
return updateTime;
}
/**
* Remove all collection items belonging to a game, except the ones in the specified list.
*
* @param gameId delete collection items with this game ID.
* @param protectedCollectionIds list of collection IDs not to delete.
* @return the number or rows deleted.
*/
@DebugLog
public int delete(int gameId, List<Integer> protectedCollectionIds) {
// determine the collection IDs that are no longer in the collection
List<Integer> collectionIdsToDelete = ResolverUtils.queryInts(resolver,
Collection.CONTENT_URI,
Collection.COLLECTION_ID,
String.format("collection.%s=?", Collection.GAME_ID),
new String[] { String.valueOf(gameId) });
if (protectedCollectionIds != null) {
for (Integer id : protectedCollectionIds) {
collectionIdsToDelete.remove(id);
}
}
// remove them
if (collectionIdsToDelete.size() > 0) {
ArrayList<ContentProviderOperation> batch = new ArrayList<>();
for (Integer collectionId : collectionIdsToDelete) {
batch.add(ContentProviderOperation.newDelete(Collection.CONTENT_URI)
.withSelection(Collection.COLLECTION_ID + "=?", new String[] { String.valueOf(collectionId) })
.build());
}
ResolverUtils.applyBatch(context, batch);
}
return collectionIdsToDelete.size();
}
@DebugLog
public SaveResults save(List<CollectionItem> items) {
SaveResults saveResults = new SaveResults();
if (items != null && items.size() > 0) {
ArrayList<ContentProviderOperation> batch = new ArrayList<>();
for (CollectionItem item : items) {
batch.clear();
if (isItemStatusSetToSync(item)) {
SyncCandidate candidate = SyncCandidate.find(resolver, item.collectionId(), item.gameId);
if (candidate.getDirtyTimestamp() != NOT_DIRTY) {
Timber.i("Local play is dirty, skipping sync.");
saveResults.addDirtyCollectionId(item.collectionId());
} else {
if (saveResults.hasGameBeenSaved(item.gameId)) {
Timber.i("Already saved game '%s' [ID=%s] during this sync; skipping save", item.gameName(), item.gameId);
} else {
addGameToBatch(item, batch);
saveResults.addSavedGameId(item.gameId);
}
addItemToBatch(item, batch, candidate);
ContentProviderResult[] results = ResolverUtils.applyBatch(context, batch);
Timber.d("Saved a batch of %,d record(s)", results.length);
saveResults.increaseRecordCount(results.length);
saveResults.addSavedCollectionId(item.collectionId());
Timber.i("Saved collection item '%s' [ID=%s, collection ID=%s]", item.gameName(), item.gameId, item.collectionId());
}
} else {
Timber.i("Skipped collection item '%s' [ID=%s, collection ID=%s] - collection status not synced", item.gameName(), item.gameId, item.collectionId());
}
}
Timber.i("Processed %,d collection item(s)", items.size());
}
return saveResults;
}
@DebugLog
private boolean isItemStatusSetToSync(CollectionItem item) {
if (statusesToSync == null) return true; // null means we should always sync
if (isStatusSetToSync(item.own, "own")) return true;
if (isStatusSetToSync(item.prevowned, "prevowned")) return true;
if (isStatusSetToSync(item.fortrade, "fortrade")) return true;
if (isStatusSetToSync(item.want, "want")) return true;
if (isStatusSetToSync(item.wanttoplay, "wanttoplay")) return true;
if (isStatusSetToSync(item.wanttobuy, "wanttobuy")) return true;
if (isStatusSetToSync(item.wishlist, "wishlist")) return true;
if (isStatusSetToSync(item.preordered, "preordered")) return true;
//noinspection RedundantIfStatement
if (item.numplays > 0 && statusesToSync.contains("played")) return true;
return false;
}
private boolean isStatusSetToSync(String status, String setting) {
return status.equals("1") && statusesToSync.contains(setting);
}
@DebugLog
private ContentValues toGameValues(CollectionItem item) {
ContentValues values = new ContentValues();
values.put(Games.UPDATED_LIST, updateTime);
values.put(Games.GAME_ID, item.gameId);
values.put(Games.GAME_NAME, item.gameName());
values.put(Games.GAME_SORT_NAME, item.gameSortName());
if (!isBriefSync) {
values.put(Games.NUM_PLAYS, item.numplays);
}
if (includeStats) {
values.put(Games.MIN_PLAYERS, item.statistics.minplayers);
values.put(Games.MAX_PLAYERS, item.statistics.maxplayers);
values.put(Games.PLAYING_TIME, item.statistics.playingtime);
values.put(Games.STATS_NUMBER_OWNED, item.statistics.numberOwned());
}
return values;
}
@DebugLog
private ContentValues toCollectionValues(CollectionItem item, SyncCandidate candidate) {
ContentValues values = new ContentValues();
if (!isBriefSync && includePrivateInfo && includeStats) {
values.put(Collection.UPDATED, updateTime);
}
values.put(Collection.UPDATED_LIST, updateTime);
values.put(Collection.GAME_ID, item.gameId);
if (item.collectionId() != BggContract.INVALID_ID) {
values.put(Collection.COLLECTION_ID, item.collectionId());
}
values.put(Collection.COLLECTION_NAME, item.collectionName());
values.put(Collection.COLLECTION_SORT_NAME, item.collectionSortName());
values.put(Collection.STATUS_OWN, item.own);
values.put(Collection.STATUS_PREVIOUSLY_OWNED, item.prevowned);
values.put(Collection.STATUS_FOR_TRADE, item.fortrade);
values.put(Collection.STATUS_WANT, item.want);
values.put(Collection.STATUS_WANT_TO_PLAY, item.wanttoplay);
values.put(Collection.STATUS_WANT_TO_BUY, item.wanttobuy);
values.put(Collection.STATUS_WISHLIST, item.wishlist);
values.put(Collection.STATUS_WISHLIST_PRIORITY, item.wishlistpriority);
values.put(Collection.STATUS_PREORDERED, item.preordered);
values.put(Collection.LAST_MODIFIED, item.lastModifiedDate());
if (!isBriefSync) {
values.put(Collection.COLLECTION_YEAR_PUBLISHED, item.yearpublished);
values.put(Collection.COLLECTION_IMAGE_URL, item.image);
values.put(Collection.COLLECTION_THUMBNAIL_URL, item.thumbnail);
values.put(Collection.COMMENT, item.comment);
values.put(Collection.WANTPARTS_LIST, item.wantpartslist);
values.put(Collection.CONDITION, item.conditiontext);
values.put(Collection.HASPARTS_LIST, item.haspartslist);
values.put(Collection.WISHLIST_COMMENT, item.wishlistcomment);
}
if (includePrivateInfo) {
values.put(Collection.PRIVATE_INFO_PRICE_PAID_CURRENCY, item.pricePaidCurrency);
values.put(Collection.PRIVATE_INFO_PRICE_PAID, item.pricePaid());
values.put(Collection.PRIVATE_INFO_CURRENT_VALUE_CURRENCY, item.currentValueCurrency);
values.put(Collection.PRIVATE_INFO_CURRENT_VALUE, item.currentValue());
values.put(Collection.PRIVATE_INFO_QUANTITY, item.getQuantity());
values.put(Collection.PRIVATE_INFO_ACQUISITION_DATE, item.acquisitionDate);
values.put(Collection.PRIVATE_INFO_ACQUIRED_FROM, item.acquiredFrom);
values.put(Collection.PRIVATE_INFO_COMMENT, item.privatecomment);
}
if (includeStats) {
values.put(Collection.RATING, item.statistics.getRating());
}
return values;
}
@DebugLog
private void addGameToBatch(CollectionItem item, ArrayList<ContentProviderOperation> batch) {
ContentProviderOperation.Builder cpo;
Uri uri = Games.buildGameUri(item.gameId);
ContentValues values = toGameValues(item);
if (ResolverUtils.rowExists(resolver, uri)) {
values.remove(Games.GAME_ID);
cpo = ContentProviderOperation.newUpdate(uri);
} else {
cpo = ContentProviderOperation.newInsert(Games.CONTENT_URI);
}
batch.add(cpo.withValues(values).build());
}
@DebugLog
private void addItemToBatch(CollectionItem item, ArrayList<ContentProviderOperation> batch, SyncCandidate candidate) {
ContentValues values = toCollectionValues(item, candidate);
ContentProviderOperation.Builder cpo;
if (candidate.getInternalId() != BggContract.INVALID_ID) {
cpo = createUpdateOperation(values, batch, candidate);
} else {
cpo = ContentProviderOperation.newInsert(Collection.CONTENT_URI);
}
batch.add(cpo.withValues(values).build());
}
@DebugLog
private ContentProviderOperation.Builder createUpdateOperation(ContentValues values, ArrayList<ContentProviderOperation> batch, SyncCandidate candidate) {
removeDirtyValues(values, candidate);
Uri uri = Collection.buildUri(candidate.getInternalId());
ContentProviderOperation.Builder operation = ContentProviderOperation.newUpdate(uri);
maybeDeleteThumbnail(values, uri, batch);
return operation;
}
@DebugLog
private void removeDirtyValues(ContentValues values, SyncCandidate candidate) {
removeValuesIfDirty(values, candidate.getStatusDirtyTimestamp(),
Collection.STATUS_OWN,
Collection.STATUS_PREVIOUSLY_OWNED,
Collection.STATUS_FOR_TRADE,
Collection.STATUS_WANT,
Collection.STATUS_WANT_TO_BUY,
Collection.STATUS_WISHLIST,
Collection.STATUS_WANT_TO_PLAY,
Collection.STATUS_PREORDERED,
Collection.STATUS_WISHLIST_PRIORITY);
removeValuesIfDirty(values, candidate.getRatingDirtyTimestamp(), Collection.RATING);
removeValuesIfDirty(values, candidate.getCommentDirtyTimestamp(), Collection.COMMENT);
removeValuesIfDirty(values, candidate.getPrivateInfoDirtyTimestamp(),
Collection.PRIVATE_INFO_ACQUIRED_FROM,
Collection.PRIVATE_INFO_ACQUISITION_DATE,
Collection.PRIVATE_INFO_COMMENT,
Collection.PRIVATE_INFO_CURRENT_VALUE,
Collection.PRIVATE_INFO_CURRENT_VALUE_CURRENCY,
Collection.PRIVATE_INFO_PRICE_PAID,
Collection.PRIVATE_INFO_PRICE_PAID_CURRENCY,
Collection.PRIVATE_INFO_QUANTITY);
removeValuesIfDirty(values, candidate.getWishlistCommentDirtyTimestamp(), Collection.WISHLIST_COMMENT);
removeValuesIfDirty(values, candidate.getTradeConditionDirtyTimestamp(), Collection.CONDITION);
removeValuesIfDirty(values, candidate.getWantPartsDirtyTimestamp(), Collection.WANTPARTS_LIST);
removeValuesIfDirty(values, candidate.getHasPartsDirtyTimestamp(), Collection.HASPARTS_LIST);
}
@DebugLog
private void maybeDeleteThumbnail(ContentValues values, Uri uri, ArrayList<ContentProviderOperation> batch) {
if (isBriefSync) {
// thumbnail not returned in brief mode
return;
}
String newThumbnailUrl = values.getAsString(Collection.COLLECTION_THUMBNAIL_URL);
if (newThumbnailUrl == null) {
newThumbnailUrl = "";
}
String oldThumbnailUrl = ResolverUtils.queryString(resolver, uri, Collection.COLLECTION_THUMBNAIL_URL);
if (newThumbnailUrl.equals(oldThumbnailUrl)) {
// nothing to do - thumbnail hasn't changed
return;
}
String thumbnailFileName = FileUtils.getFileNameFromUrl(oldThumbnailUrl);
if (!TextUtils.isEmpty(thumbnailFileName)) {
batch.add(ContentProviderOperation.newDelete(Thumbnails.buildUri(thumbnailFileName)).build());
}
}
@DebugLog
private void removeValuesIfDirty(ContentValues values, long dirtyFlag, String... columns) {
if (dirtyFlag != NOT_DIRTY) {
for (String column : columns) {
values.remove(column);
}
}
}
static class SyncCandidate {
public static final SyncCandidate NULL = new SyncCandidate() {
@Override
public long getInternalId() {
return BggContract.INVALID_ID;
}
@Override
public long getDirtyTimestamp() {
return 0;
}
@Override
public long getStatusDirtyTimestamp() {
return 0;
}
@Override
public long getRatingDirtyTimestamp() {
return 0;
}
@Override
public long getWishlistCommentDirtyTimestamp() {
return 0;
}
@Override
public long getTradeConditionDirtyTimestamp() {
return 0;
}
@Override
public long getWantPartsDirtyTimestamp() {
return 0;
}
@Override
public long getHasPartsDirtyTimestamp() {
return 0;
}
};
public static final String[] PROJECTION = {
Collection._ID,
Collection.COLLECTION_DIRTY_TIMESTAMP,
Collection.STATUS_DIRTY_TIMESTAMP,
Collection.RATING_DIRTY_TIMESTAMP,
Collection.COMMENT_DIRTY_TIMESTAMP,
Collection.PRIVATE_INFO_DIRTY_TIMESTAMP,
Collection.WISHLIST_COMMENT_DIRTY_TIMESTAMP,
Collection.TRADE_CONDITION_DIRTY_TIMESTAMP,
Collection.WANT_PARTS_DIRTY_TIMESTAMP,
Collection.HAS_PARTS_DIRTY_TIMESTAMP
};
private long internalId;
private long dirtyTimestamp;
private long statusDirtyTimestamp;
private long ratingDirtyTimestamp;
private long commentDirtyTimestamp;
private long privateInfoDirtyTimestamp;
private long wishlistCommentDirtyTimestamp;
private long tradeConditionDirtyTimestamp;
private long wantPartsDirtyTimestamp;
private long hasPartsDirtyTimestamp;
public static SyncCandidate find(ContentResolver resolver, int collectionId, int gameId) {
Cursor cursor = null;
try {
if (collectionId == BggContract.INVALID_ID) {
cursor = getCursorFromGameId(resolver, gameId);
if (cursor != null && cursor.moveToFirst()) {
return fromCursor(cursor);
}
} else {
cursor = resolver.query(Collection.CONTENT_URI,
PROJECTION,
Collection.COLLECTION_ID + "=?",
new String[] { String.valueOf(collectionId) },
null);
if (cursor != null && cursor.moveToFirst()) {
return fromCursor(cursor);
}
if (cursor != null) cursor.close();
cursor = getCursorFromGameId(resolver, gameId);
if (cursor != null && cursor.moveToFirst()) {
return fromCursor(cursor);
}
}
} finally {
if (cursor != null) cursor.close();
}
return NULL;
}
private static Cursor getCursorFromGameId(ContentResolver resolver, int gameId) {
Cursor cursor;
cursor = resolver.query(Collection.CONTENT_URI,
PROJECTION,
"collection." + Collection.GAME_ID + "=? AND " +
SelectionBuilder.whereNullOrEmpty(Collection.COLLECTION_ID),
new String[] { String.valueOf(gameId) },
null);
return cursor;
}
public static SyncCandidate fromCursor(Cursor cursor) {
SyncCandidate candidate = new SyncCandidate();
candidate.internalId = CursorUtils.getLong(cursor, Collection._ID, BggContract.INVALID_ID);
candidate.dirtyTimestamp = CursorUtils.getLong(cursor, Collection.COLLECTION_DIRTY_TIMESTAMP);
candidate.statusDirtyTimestamp = CursorUtils.getLong(cursor, Collection.STATUS_DIRTY_TIMESTAMP);
candidate.ratingDirtyTimestamp = CursorUtils.getLong(cursor, Collection.RATING_DIRTY_TIMESTAMP);
candidate.commentDirtyTimestamp = CursorUtils.getLong(cursor, Collection.COMMENT_DIRTY_TIMESTAMP);
candidate.privateInfoDirtyTimestamp = CursorUtils.getLong(cursor, Collection.PRIVATE_INFO_DIRTY_TIMESTAMP);
candidate.wishlistCommentDirtyTimestamp = CursorUtils.getLong(cursor, Collection.WISHLIST_COMMENT_DIRTY_TIMESTAMP);
candidate.tradeConditionDirtyTimestamp = CursorUtils.getLong(cursor, Collection.TRADE_CONDITION_DIRTY_TIMESTAMP);
candidate.wantPartsDirtyTimestamp = CursorUtils.getLong(cursor, Collection.WANT_PARTS_DIRTY_TIMESTAMP);
candidate.hasPartsDirtyTimestamp = CursorUtils.getLong(cursor, Collection.HAS_PARTS_DIRTY_TIMESTAMP);
return candidate;
}
public long getInternalId() {
return internalId;
}
public long getDirtyTimestamp() {
return dirtyTimestamp;
}
public long getStatusDirtyTimestamp() {
return statusDirtyTimestamp;
}
public long getRatingDirtyTimestamp() {
return ratingDirtyTimestamp;
}
public long getCommentDirtyTimestamp() {
return commentDirtyTimestamp;
}
public long getPrivateInfoDirtyTimestamp() {
return privateInfoDirtyTimestamp;
}
public long getWishlistCommentDirtyTimestamp() {
return wishlistCommentDirtyTimestamp;
}
public long getTradeConditionDirtyTimestamp() {
return tradeConditionDirtyTimestamp;
}
public long getWantPartsDirtyTimestamp() {
return wantPartsDirtyTimestamp;
}
public long getHasPartsDirtyTimestamp() {
return hasPartsDirtyTimestamp;
}
}
}