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.Play; import com.boardgamegeek.model.Play.Subtype; import com.boardgamegeek.model.Player; import com.boardgamegeek.provider.BggContract; import com.boardgamegeek.provider.BggContract.Buddies; import com.boardgamegeek.provider.BggContract.GameColors; import com.boardgamegeek.provider.BggContract.Games; import com.boardgamegeek.provider.BggContract.PlayPlayers; import com.boardgamegeek.provider.BggContract.Plays; import com.boardgamegeek.util.CursorUtils; import com.boardgamegeek.util.PreferencesUtils; import com.boardgamegeek.util.ResolverUtils; import com.boardgamegeek.util.StringUtils; import java.util.ArrayList; import java.util.List; import timber.log.Timber; public class PlayPersister { private final Context context; private final ContentResolver resolver; private final ArrayList<ContentProviderOperation> batch; public PlayPersister(Context context) { this.context = context; resolver = context.getContentResolver(); batch = new ArrayList<>(); } /* * Delete the play from the content provider. */ public boolean delete(long internalId) { return resolver.delete(Plays.buildPlayUri(internalId), null, null) > 0; } public void save(List<Play> plays, long startTime) { int updateCount = 0; int insertCount = 0; int unchangedCount = 0; int dirtyCount = 0; int errorCount = 0; if (plays != null) { for (Play play : plays) { if (play.playId <= 0) { Timber.i("Can't sync a play without a play ID."); errorCount++; } else { play.syncTimestamp = startTime; PlaySyncCandidate candidate = PlaySyncCandidate.find(resolver, play.playId); if (candidate.getInternalId() == BggContract.INVALID_ID) { save(play, BggContract.INVALID_ID, true); insertCount++; } else { if (candidate.getDirtyTimestamp() > 0) { Timber.i("Not saving during the sync; modification in progress."); dirtyCount++; } else if (candidate.getDeleteTimestamp() > 0) { Timber.i("Not saving during the sync; set to delete."); dirtyCount++; } else if (candidate.getUpdateTimestamp() > 0) { Timber.i("Not saving during the sync; set to update."); dirtyCount++; } else if (candidate.getSyncHashCode() == generateSyncHashCode(play)) { updateSyncTimestamp(candidate.getInternalId(), startTime); unchangedCount++; } else { save(play, candidate.getInternalId(), true); updateCount++; } } } } } Timber.i("Updated %1$,d, inserted %2$,d, %3$,d unchanged, %4$,d dirty, %5$,d", updateCount, insertCount, unchangedCount, dirtyCount, errorCount); } public long save(Play play, long internalId, boolean includePlayers) { if (play == null) return BggContract.INVALID_ID; if (!isBoardgameSubtype(play)) return BggContract.INVALID_ID; batch.clear(); ContentValues values = createContentValues(play); String debugMessage; if (internalId != BggContract.INVALID_ID) { debugMessage = "Updating play _ID " + internalId; batch.add(ContentProviderOperation .newUpdate(Plays.buildPlayUri(internalId)) .withValues(values) .build()); } else if (play.deleteTimestamp > 0) { Timber.i("Skipping inserting a deleted play"); return BggContract.INVALID_ID; } else { debugMessage = "Inserting new play"; if (PreferencesUtils.getAvoidBatching(context)) { Uri uri = resolver.insert(Plays.CONTENT_URI, values); if (uri == null) { Timber.w("Unable to insert new play."); return BggContract.INVALID_ID; } internalId = StringUtils.parseInt(uri.getLastPathSegment(), BggContract.INVALID_ID); } else { batch.add(ContentProviderOperation .newInsert(Plays.CONTENT_URI) .withValues(values) .build()); } } if (includePlayers) { deletePlayerWithEmptyUserNameInBatch(internalId); List<String> existingPlayerIds = removeDuplicateUserNamesFromBatch(internalId); addPlayersToBatch(play, existingPlayerIds, internalId); removeUnusedPlayersFromBatch(internalId, existingPlayerIds); if (play.playId > 0 || play.updateTimestamp > 0) { saveGamePlayerSortOrderToBatch(play); updateColorsInBatch(play); saveBuddyNicknamesToBatch(play); } } ContentProviderResult[] results = ResolverUtils.applyBatch(context, batch, debugMessage); long insertedId = internalId; if (insertedId == BggContract.INVALID_ID && results != null && results.length > 0) { insertedId = StringUtils.parseLong(results[0].uri.getLastPathSegment(), BggContract.INVALID_ID); } Timber.i("Saved play ID=%s", insertedId); return insertedId; } private void updateSyncTimestamp(long internalId, long startTime) { batch.clear(); ContentProviderOperation.Builder builder = ContentProviderOperation .newUpdate(Plays.buildPlayUri(internalId)) .withValue(Plays.SYNC_TIMESTAMP, startTime); batch.add(builder.build()); ResolverUtils.applyBatch(context, batch); } private boolean isBoardgameSubtype(Play play) { if (play.subtypes == null || play.subtypes.isEmpty()) { return true; } for (Subtype subtype : play.subtypes) { if (subtype.value.startsWith("boardgame")) { return true; } } return false; } private static int generateSyncHashCode(Play play) { StringBuilder sb = new StringBuilder(); sb.append(play.getDate()).append("\n"); sb.append(play.quantity).append("\n"); sb.append(play.length).append("\n"); sb.append(play.Incomplete()).append("\n"); sb.append(play.NoWinStats()).append("\n"); sb.append(play.location).append("\n"); sb.append(play.comments).append("\n"); for (Player player : play.getPlayers()) { sb.append(player.username).append("\n"); sb.append(player.userid).append("\n"); sb.append(player.name).append("\n"); sb.append(player.startposition).append("\n"); sb.append(player.color).append("\n"); sb.append(player.score).append("\n"); sb.append(player.New()).append("\n"); sb.append(player.rating).append("\n"); sb.append(player.Win()).append("\n"); } return sb.toString().hashCode(); } private static ContentValues createContentValues(Play play) { ContentValues values = new ContentValues(); values.put(Plays.PLAY_ID, play.playId); values.put(Plays.DATE, play.getDate()); values.put(Plays.ITEM_NAME, play.gameName); values.put(Plays.OBJECT_ID, play.gameId); values.put(Plays.QUANTITY, play.quantity); values.put(Plays.LENGTH, play.length); values.put(Plays.INCOMPLETE, play.Incomplete()); values.put(Plays.NO_WIN_STATS, play.NoWinStats()); values.put(Plays.LOCATION, play.location); values.put(Plays.COMMENTS, play.comments); values.put(Plays.PLAYER_COUNT, play.getPlayerCount()); values.put(Plays.SYNC_TIMESTAMP, play.syncTimestamp); values.put(Plays.START_TIME, play.length > 0 ? 0 : play.startTime); // only store start time if there's no length values.put(Plays.SYNC_HASH_CODE, generateSyncHashCode(play)); values.put(Plays.DELETE_TIMESTAMP, play.deleteTimestamp); values.put(Plays.UPDATE_TIMESTAMP, play.updateTimestamp); values.put(Plays.DIRTY_TIMESTAMP, play.dirtyTimestamp); return values; } private void deletePlayerWithEmptyUserNameInBatch(long internalId) { if (internalId == BggContract.INVALID_ID) return; batch.add(ContentProviderOperation .newDelete(Plays.buildPlayerUri(internalId)) .withSelection(String.format("%1$s IS NULL OR %1$s=''", PlayPlayers.USER_NAME), null) .build()); } private List<String> removeDuplicateUserNamesFromBatch(long internalId) { if (internalId == BggContract.INVALID_ID) return new ArrayList<>(0); List<String> userNames = ResolverUtils.queryStrings(resolver, Plays.buildPlayerUri(internalId), PlayPlayers.USER_NAME); if (userNames == null || userNames.size() == 0) { return new ArrayList<>(); } List<String> uniqueUserNames = new ArrayList<>(); List<String> userNamesToDelete = new ArrayList<>(); for (int i = 0; i < userNames.size(); i++) { String userName = userNames.get(i); if (!TextUtils.isEmpty(userName)) { if (uniqueUserNames.contains(userName)) { userNamesToDelete.add(userName); } else { uniqueUserNames.add(userName); } } } for (String userName : userNamesToDelete) { batch.add(ContentProviderOperation .newDelete(Plays.buildPlayerUri(internalId)) .withSelection(PlayPlayers.USER_NAME + "=?", new String[] { userName }) .build()); uniqueUserNames.remove(userName); } return uniqueUserNames; } private void addPlayersToBatch(Play play, List<String> playerUserNames, long internalId) { for (Player player : play.getPlayers()) { String userName = player.username; ContentValues values = new ContentValues(); values.put(PlayPlayers.USER_ID, player.userid); values.put(PlayPlayers.USER_NAME, userName); values.put(PlayPlayers.NAME, player.name); values.put(PlayPlayers.START_POSITION, player.getStartingPosition()); values.put(PlayPlayers.COLOR, player.color); values.put(PlayPlayers.SCORE, player.score); values.put(PlayPlayers.NEW, player.New()); values.put(PlayPlayers.RATING, player.rating); values.put(PlayPlayers.WIN, player.Win()); if (playerUserNames != null && playerUserNames.remove(userName)) { batch.add(ContentProviderOperation .newUpdate(Plays.buildPlayerUri(internalId)) .withSelection(PlayPlayers.USER_NAME + "=?", new String[] { userName }) .withValues(values).build()); } else { values.put(PlayPlayers.USER_NAME, userName); if (internalId == BggContract.INVALID_ID) { batch.add(ContentProviderOperation .newInsert(Plays.buildPlayerUri()) .withValueBackReference(PlayPlayers._PLAY_ID, 0) .withValues(values) .build()); } else { batch.add(ContentProviderOperation .newInsert(Plays.buildPlayerUri(internalId)) .withValues(values) .build()); } } } } private void removeUnusedPlayersFromBatch(long internalId, List<String> playerUserNames) { if (internalId == BggContract.INVALID_ID) return; if (playerUserNames == null) return; for (String playerUserName : playerUserNames) { batch.add(ContentProviderOperation .newDelete(Plays.buildPlayerUri(internalId)) .withSelection(PlayPlayers.USER_NAME + "=?", new String[] { playerUserName }) .build()); } } /** * Determine if the players are customer sorted or not, and save it to the game. */ private void saveGamePlayerSortOrderToBatch(Play play) { // We can't determine the sort order without players if (play.getPlayerCount() == 0) return; // We can't save the sort order if we aren't storing the game Uri gameUri = Games.buildGameUri(play.gameId); if (!ResolverUtils.rowExists(resolver, gameUri)) return; batch.add(ContentProviderOperation .newUpdate(gameUri) .withValue(Games.CUSTOM_PLAYER_SORT, play.arePlayersCustomSorted()) .build()); } /** * Add the current players' team/colors to the permanent list for the game. */ private void updateColorsInBatch(Play play) { // There are no players, so there are no colors to save if (play.getPlayerCount() == 0) return; // We can't save the colors if we aren't storing the game if (!ResolverUtils.rowExists(resolver, Games.buildGameUri(play.gameId))) return; Uri insertUri = Games.buildColorsUri(play.gameId); List<String> insertedColors = new ArrayList<>(); for (Player player : play.getPlayers()) { String color = player.color; if (!TextUtils.isEmpty(color) && !insertedColors.contains(color) && !ResolverUtils.rowExists(resolver, Games.buildColorsUri(play.gameId, color))) { batch.add(ContentProviderOperation .newInsert(insertUri) .withValue(GameColors.COLOR, color) .build()); insertedColors.add(color); } } } /** * Update GeekBuddies' nicknames with the names used here. */ private void saveBuddyNicknamesToBatch(Play play) { // There are no players, so there are no nicknames to save if (play.getPlayerCount() == 0) return; for (Player player : play.getPlayers()) { if (!TextUtils.isEmpty(player.username) && !TextUtils.isEmpty(player.name)) { batch.add(ContentProviderOperation .newUpdate(Buddies.CONTENT_URI) .withSelection(Buddies.BUDDY_NAME + "=?", new String[] { player.username }) .withValue(Buddies.PLAY_NICKNAME, player.name) .build()); } } } static class PlaySyncCandidate { public static final PlaySyncCandidate NULL = new PlaySyncCandidate() { @Override public long getInternalId() { return BggContract.INVALID_ID; } @Override public int getSyncHashCode() { return 0; } @Override public long getDeleteTimestamp() { return 0; } @Override public long getDirtyTimestamp() { return 0; } @Override public long getUpdateTimestamp() { return 0; } }; public static final String[] PROJECTION = { Plays._ID, Plays.SYNC_HASH_CODE, Plays.DELETE_TIMESTAMP, Plays.UPDATE_TIMESTAMP, Plays.DIRTY_TIMESTAMP }; private long internalId; private int syncHashCode; private long deleteTimestamp; private long updateTimestamp; private long dirtyTimestamp; public static PlaySyncCandidate find(ContentResolver resolver, int playId) { Cursor cursor = resolver.query(Plays.CONTENT_URI, PROJECTION, Plays.PLAY_ID + "=?", new String[] { String.valueOf(playId) }, null); try { if (cursor != null && cursor.moveToFirst()) { return fromCursor(cursor); } return NULL; } finally { if (cursor != null) cursor.close(); } } public static PlaySyncCandidate fromCursor(Cursor cursor) { PlaySyncCandidate psc = new PlaySyncCandidate(); psc.internalId = CursorUtils.getLong(cursor, Plays._ID, BggContract.INVALID_ID); psc.syncHashCode = CursorUtils.getInt(cursor, Plays.SYNC_HASH_CODE); psc.deleteTimestamp = CursorUtils.getLong(cursor, Plays.DELETE_TIMESTAMP); psc.updateTimestamp = CursorUtils.getLong(cursor, Plays.UPDATE_TIMESTAMP); psc.dirtyTimestamp = CursorUtils.getLong(cursor, Plays.DIRTY_TIMESTAMP); return psc; } public long getInternalId() { return internalId; } public int getSyncHashCode() { return syncHashCode; } public long getDeleteTimestamp() { return deleteTimestamp; } public long getUpdateTimestamp() { return updateTimestamp; } public long getDirtyTimestamp() { return dirtyTimestamp; } } }