package com.boardgamegeek.model.persister; import android.content.ContentProviderOperation; import android.content.ContentProviderOperation.Builder; import android.content.ContentProviderResult; import android.content.ContentResolver; import android.content.ContentValues; import android.content.Context; import android.net.Uri; import android.support.v4.app.NotificationCompat; import android.text.TextUtils; import com.boardgamegeek.R; import com.boardgamegeek.model.Game; import com.boardgamegeek.model.Game.Poll; import com.boardgamegeek.model.Game.Rank; import com.boardgamegeek.model.Game.Result; import com.boardgamegeek.model.Game.Results; import com.boardgamegeek.provider.BggContract; import com.boardgamegeek.provider.BggContract.Artists; import com.boardgamegeek.provider.BggContract.Categories; import com.boardgamegeek.provider.BggContract.Designers; import com.boardgamegeek.provider.BggContract.GamePollResults; import com.boardgamegeek.provider.BggContract.GamePollResultsResult; import com.boardgamegeek.provider.BggContract.GamePolls; import com.boardgamegeek.provider.BggContract.GameRanks; import com.boardgamegeek.provider.BggContract.Games; import com.boardgamegeek.provider.BggContract.GamesExpansions; import com.boardgamegeek.provider.BggContract.Mechanics; import com.boardgamegeek.provider.BggContract.Publishers; import com.boardgamegeek.provider.BggDatabase.GamesArtists; import com.boardgamegeek.provider.BggDatabase.GamesCategories; import com.boardgamegeek.provider.BggDatabase.GamesDesigners; import com.boardgamegeek.provider.BggDatabase.GamesMechanics; import com.boardgamegeek.provider.BggDatabase.GamesPublishers; import com.boardgamegeek.util.DataUtils; import com.boardgamegeek.util.NotificationUtils; import com.boardgamegeek.util.PreferencesUtils; import com.boardgamegeek.util.ResolverUtils; import java.util.ArrayList; import java.util.List; import timber.log.Timber; public class GamePersister { private final Context context; private final ContentResolver resolver; private final long updateTime; private final List<Integer> gameIds; public GamePersister(Context context) { this.context = context; resolver = context.getContentResolver(); updateTime = System.currentTimeMillis(); gameIds = new ArrayList<>(); } public int save(List<Game> games, String debugMessage) { boolean debug = PreferencesUtils.getAvoidBatching(context); int length = 0; ArrayList<ContentProviderOperation> batch = new ArrayList<>(); if (games != null) { DesignerPersister designerPersister = new DesignerPersister(); ArtistPersister artistPersister = new ArtistPersister(); PublisherPersister publisherPersister = new PublisherPersister(); CategoryPersister categoryPersister = new CategoryPersister(); MechanicPersister mechanicPersister = new MechanicPersister(); ExpansionPersister expansionPersister = new ExpansionPersister(); for (Game game : games) { if (gameIds.contains(game.id)) { continue; } gameIds.add(game.id); Builder cpo; ContentValues values = toValues(game, updateTime); if (ResolverUtils.rowExists(resolver, Games.buildGameUri(game.id))) { values.remove(Games.GAME_ID); cpo = ContentProviderOperation.newUpdate(Games.buildGameUri(game.id)); } else { cpo = ContentProviderOperation.newInsert(Games.CONTENT_URI); } batch.add(cpo.withValues(values).build()); batch.addAll(ranks(game)); batch.addAll(polls(game)); batch.addAll(designerPersister.insertAndCreateAssociations(game.id, resolver, game.getDesigners())); batch.addAll(artistPersister.insertAndCreateAssociations(game.id, resolver, game.getArtists())); batch.addAll(publisherPersister.insertAndCreateAssociations(game.id, resolver, game.getPublishers())); batch.addAll(categoryPersister.insertAndCreateAssociations(game.id, resolver, game.getCategories())); batch.addAll(mechanicPersister.insertAndCreateAssociations(game.id, resolver, game.getMechanics())); batch.addAll(expansionPersister.insertAndCreateAssociations(game.id, resolver, game.getExpansions())); // make sure the last operation has a yield allowed batch.add(ContentProviderOperation.newUpdate(Games.buildGameUri(game.id)) .withValue(Games.UPDATED, updateTime).withYieldAllowed(true).build()); if (debug) { try { length += ResolverUtils.applyBatch(context, batch, debugMessage).length; Timber.i("Saved game ID=%s", game.id); } catch (Exception e) { NotificationCompat.Builder builder = NotificationUtils .createNotificationBuilder(context, R.string.sync_notification_title) .setContentText(e.getMessage()) .setCategory(NotificationCompat.CATEGORY_ERROR) .setStyle( new NotificationCompat.BigTextStyle().bigText(e.toString()).setSummaryText( e.getMessage())); NotificationUtils.notify(context, NotificationUtils.TAG_PERSIST_ERROR, 0, builder); } finally { batch.clear(); } } } if (debug) { return length; } else { ContentProviderResult[] result = ResolverUtils.applyBatch(context, batch, debugMessage); return result.length; } } return 0; } private static ContentValues toValues(Game game, long updateTime) { ContentValues values = new ContentValues(); values.put(Games.UPDATED, updateTime); values.put(Games.UPDATED_LIST, updateTime); values.put(Games.GAME_ID, game.id); values.put(Games.GAME_NAME, game.getName()); values.put(Games.GAME_SORT_NAME, game.getSortName()); values.put(Games.THUMBNAIL_URL, game.thumbnail); values.put(Games.IMAGE_URL, game.image); values.put(Games.DESCRIPTION, game.getDescription()); values.put(Games.SUBTYPE, game.subtype()); values.put(Games.YEAR_PUBLISHED, game.getYearPublished()); values.put(Games.MIN_PLAYERS, game.getMinPlayers()); values.put(Games.MAX_PLAYERS, game.getMaxPlayers()); values.put(Games.PLAYING_TIME, game.getPlayingTime()); values.put(Games.MINIMUM_AGE, game.getMinAge()); values.put(Games.STATS_USERS_RATED, game.statistics.usersRated()); values.put(Games.STATS_AVERAGE, game.statistics.average()); values.put(Games.STATS_BAYES_AVERAGE, game.statistics.bayesAverage()); values.put(Games.STATS_STANDARD_DEVIATION, game.statistics.standardDeviation()); values.put(Games.STATS_MEDIAN, game.statistics.median()); values.put(Games.STATS_NUMBER_OWNED, game.statistics.owned()); values.put(Games.STATS_NUMBER_TRADING, game.statistics.trading()); values.put(Games.STATS_NUMBER_WANTING, game.statistics.wanting()); values.put(Games.STATS_NUMBER_WISHING, game.statistics.wishing()); values.put(Games.STATS_NUMBER_COMMENTS, game.statistics.commenting()); values.put(Games.STATS_NUMBER_WEIGHTS, game.statistics.weighting()); values.put(Games.STATS_AVERAGE_WEIGHT, game.statistics.averageWeight()); values.put(Games.GAME_RANK, game.getRank()); return values; } private ArrayList<ContentProviderOperation> polls(Game game) { ArrayList<ContentProviderOperation> batch = new ArrayList<>(); List<String> existingPollNames = ResolverUtils.queryStrings(resolver, Games.buildPollsUri(game.id), GamePolls.POLL_NAME); if (game.polls != null) { for (Poll poll : game.polls) { ContentValues values = new ContentValues(); values.put(GamePolls.POLL_TITLE, poll.title); values.put(GamePolls.POLL_TOTAL_VOTES, poll.totalvotes); List<String> existingResultKeys = new ArrayList<>(); if (existingPollNames.remove(poll.name)) { batch.add(ContentProviderOperation.newUpdate(Games.buildPollsUri(game.id, poll.name)) .withValues(values).build()); existingResultKeys = ResolverUtils.queryStrings(resolver, Games.buildPollResultsUri(game.id, poll.name), GamePollResults.POLL_RESULTS_PLAYERS); } else { values.put(GamePolls.POLL_NAME, poll.name); batch.add(ContentProviderOperation.newInsert(Games.buildPollsUri(game.id)).withValues(values) .build()); } int resultsIndex = 0; for (Results results : poll.results) { values.clear(); values.put(GamePollResults.POLL_RESULTS_SORT_INDEX, ++resultsIndex); List<String> existingValues = new ArrayList<>(); if (existingResultKeys.remove(results.getKey())) { batch.add(ContentProviderOperation .newUpdate(Games.buildPollResultsUri(game.id, poll.name, results.getKey())) .withValues(values).build()); existingValues = ResolverUtils.queryStrings(resolver, Games.buildPollResultsResultUri(game.id, poll.name, results.getKey()), GamePollResultsResult.POLL_RESULTS_RESULT_KEY); } else { values.put(GamePollResults.POLL_RESULTS_PLAYERS, results.getKey()); batch.add(ContentProviderOperation.newInsert(Games.buildPollResultsUri(game.id, poll.name)) .withValues(values).build()); } int resultSortIndex = 0; for (Result result : results.result) { values.clear(); int level = result.level; if (level > 0) { values.put(GamePollResultsResult.POLL_RESULTS_RESULT_LEVEL, level); } values.put(GamePollResultsResult.POLL_RESULTS_RESULT_VALUE, result.value); values.put(GamePollResultsResult.POLL_RESULTS_RESULT_VOTES, result.numvotes); values.put(GamePollResultsResult.POLL_RESULTS_RESULT_SORT_INDEX, ++resultSortIndex); String key = DataUtils.generatePollResultsKey(level, result.value); if (existingValues.remove(key)) { batch.add(ContentProviderOperation.newUpdate( Games.buildPollResultsResultUri(game.id, poll.name, results.getKey(), key)).withValues(values).build()); } else { batch.add(ContentProviderOperation .newInsert(Games.buildPollResultsResultUri(game.id, poll.name, results.getKey())) .withValues(values).build()); } } for (String value : existingValues) { batch.add(ContentProviderOperation.newDelete( Games.buildPollResultsResultUri(game.id, poll.name, results.getKey(), value)).build()); } } for (String player : existingResultKeys) { batch.add(ContentProviderOperation.newDelete(Games.buildPollResultsUri(game.id, poll.name, player)) .build()); } } } for (String pollName : existingPollNames) { batch.add(ContentProviderOperation.newDelete(Games.buildPollsUri(game.id, pollName)).build()); } return batch; } private ArrayList<ContentProviderOperation> ranks(Game game) { ArrayList<ContentProviderOperation> batch = new ArrayList<>(); List<Integer> rankIds = ResolverUtils.queryInts(resolver, GameRanks.CONTENT_URI, GameRanks.GAME_RANK_ID, GameRanks.GAME_ID + "=?", new String[] { String.valueOf(game.id) }); ContentValues values = new ContentValues(); for (Rank rank : game.statistics.ranks) { values.clear(); values.put(GameRanks.GAME_RANK_TYPE, rank.type); values.put(GameRanks.GAME_RANK_NAME, rank.name); values.put(GameRanks.GAME_RANK_FRIENDLY_NAME, rank.friendlyName); values.put(GameRanks.GAME_RANK_VALUE, rank.getValue()); values.put(GameRanks.GAME_RANK_BAYES_AVERAGE, rank.getBayesAverage()); Integer rankId = rank.id; if (rankIds.remove(rankId)) { batch.add(ContentProviderOperation.newUpdate(Games.buildRanksUri(game.id, rankId)).withValues(values) .build()); } else { values.put(GameRanks.GAME_RANK_ID, rank.id); batch.add(ContentProviderOperation.newInsert(Games.buildRanksUri(game.id)).withValues(values).build()); } } for (Integer rankId : rankIds) { batch.add(ContentProviderOperation.newDelete(GameRanks.buildGameRankUri(rankId)).build()); } return batch; } static class DesignerPersister extends LinkPersister { @Override protected Uri getContentUri() { return Designers.CONTENT_URI; } @Override protected String getUriPath() { return BggContract.PATH_DESIGNERS; } @Override protected String getReferenceIdColumnName() { return Designers.DESIGNER_ID; } @Override protected String getReferenceNameColumnName() { return Designers.DESIGNER_NAME; } @Override protected String getAssociationIdColumnName() { return GamesDesigners.DESIGNER_ID; } @Override protected String getAssociationNameColumnName() { return null; } @Override protected String getInboundColumnName() { return null; } } static class ArtistPersister extends LinkPersister { @Override protected Uri getContentUri() { return Artists.CONTENT_URI; } @Override protected String getUriPath() { return BggContract.PATH_ARTISTS; } @Override protected String getReferenceIdColumnName() { return Artists.ARTIST_ID; } @Override protected String getReferenceNameColumnName() { return Artists.ARTIST_NAME; } @Override protected String getAssociationIdColumnName() { return GamesArtists.ARTIST_ID; } @Override protected String getAssociationNameColumnName() { return null; } @Override protected String getInboundColumnName() { return null; } } static class PublisherPersister extends LinkPersister { @Override protected Uri getContentUri() { return Publishers.CONTENT_URI; } @Override protected String getUriPath() { return BggContract.PATH_PUBLISHERS; } @Override protected String getReferenceIdColumnName() { return Publishers.PUBLISHER_ID; } @Override protected String getReferenceNameColumnName() { return Publishers.PUBLISHER_NAME; } @Override protected String getAssociationIdColumnName() { return GamesPublishers.PUBLISHER_ID; } @Override protected String getAssociationNameColumnName() { return null; } @Override protected String getInboundColumnName() { return null; } } static class CategoryPersister extends LinkPersister { @Override protected Uri getContentUri() { return Categories.CONTENT_URI; } @Override protected String getUriPath() { return BggContract.PATH_CATEGORIES; } @Override protected String getReferenceIdColumnName() { return Categories.CATEGORY_ID; } @Override protected String getReferenceNameColumnName() { return Categories.CATEGORY_NAME; } @Override protected String getAssociationIdColumnName() { return GamesCategories.CATEGORY_ID; } @Override protected String getAssociationNameColumnName() { return null; } @Override protected String getInboundColumnName() { return null; } } static class MechanicPersister extends LinkPersister { @Override protected Uri getContentUri() { return Mechanics.CONTENT_URI; } @Override protected String getUriPath() { return BggContract.PATH_MECHANICS; } @Override protected String getReferenceIdColumnName() { return Mechanics.MECHANIC_ID; } @Override protected String getReferenceNameColumnName() { return Mechanics.MECHANIC_NAME; } @Override protected String getAssociationIdColumnName() { return GamesMechanics.MECHANIC_ID; } @Override protected String getAssociationNameColumnName() { return null; } @Override protected String getInboundColumnName() { return null; } } static class ExpansionPersister extends LinkPersister { @Override protected Uri getContentUri() { return Games.CONTENT_URI; } @Override protected String getUriPath() { return BggContract.PATH_EXPANSIONS; } @Override protected String getReferenceIdColumnName() { return null; } @Override protected String getReferenceNameColumnName() { return null; } @Override protected String getAssociationIdColumnName() { return GamesExpansions.EXPANSION_ID; } @Override protected String getAssociationNameColumnName() { return GamesExpansions.EXPANSION_NAME; } @Override protected String getInboundColumnName() { return GamesExpansions.INBOUND; } } static abstract class LinkPersister { protected abstract Uri getContentUri(); protected abstract String getUriPath(); protected abstract String getReferenceIdColumnName(); protected abstract String getReferenceNameColumnName(); protected abstract String getAssociationIdColumnName(); protected abstract String getAssociationNameColumnName(); protected abstract String getInboundColumnName(); ArrayList<ContentProviderOperation> insertAndCreateAssociations(int gameId, ContentResolver resolver, List<Game.Link> newLinks) { ArrayList<ContentProviderOperation> batch = new ArrayList<>(); Uri gameUri = Games.buildPathUri(gameId, getUriPath()); List<Integer> existingIds = ResolverUtils.queryInts(resolver, gameUri, getAssociationIdColumnName()); for (Game.Link newLink : newLinks) { if (!existingIds.remove(Integer.valueOf(newLink.id))) { // insert reference row, if missing if (!TextUtils.isEmpty(getReferenceIdColumnName()) && !ResolverUtils.rowExists(resolver, getContentUri().buildUpon().appendPath(String.valueOf(newLink.id)).build())) { // XXX think about delaying inserts in a separate batch ContentValues cv = new ContentValues(); cv.put(getReferenceIdColumnName(), newLink.id); cv.put(getReferenceNameColumnName(), newLink.value); resolver.insert(getContentUri(), cv); // TODO else update? } // insert association row Builder cpo = ContentProviderOperation.newInsert(gameUri).withValue(getAssociationIdColumnName(), newLink.id); if (!TextUtils.isEmpty(getAssociationNameColumnName())) { cpo.withValue(getAssociationNameColumnName(), newLink.value); } if (!TextUtils.isEmpty(getInboundColumnName())) { cpo.withValue(getInboundColumnName(), newLink.getInbound()); } batch.add(cpo.build()); } } // remove unused associations for (Integer existingId : existingIds) { Uri uri = Games.buildPathUri(gameId, getUriPath(), existingId); batch.add(ContentProviderOperation.newDelete(uri).build()); } return batch; } } }