/******************************************************************************* * This file is part of RedReader. * * RedReader is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * RedReader is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with RedReader. If not, see <http://www.gnu.org/licenses/>. ******************************************************************************/ package org.quantumbadger.redreader.reddit.prepared; import android.content.Context; import android.util.Log; import org.quantumbadger.redreader.account.RedditAccount; import org.quantumbadger.redreader.account.RedditAccountManager; import org.quantumbadger.redreader.common.collections.WeakReferenceListHashMapManager; import org.quantumbadger.redreader.common.collections.WeakReferenceListManager; import org.quantumbadger.redreader.io.ExtendedDataInputStream; import org.quantumbadger.redreader.io.ExtendedDataOutputStream; import org.quantumbadger.redreader.io.RedditChangeDataIO; import org.quantumbadger.redreader.reddit.things.RedditComment; import org.quantumbadger.redreader.reddit.things.RedditPost; import org.quantumbadger.redreader.reddit.things.RedditThingWithIdAndType; import java.io.IOException; import java.util.HashMap; import java.util.Iterator; import java.util.Locale; import java.util.Map; import java.util.Set; public final class RedditChangeDataManager { private static final String TAG = "RedditChangeDataManager"; private static final long PRUNE_AGE_MS = 7L * 24L * 60L * 60L * 1000L; private static final HashMap<RedditAccount, RedditChangeDataManager> INSTANCE_MAP = new HashMap<>(); public static RedditChangeDataManager getInstance(final RedditAccount user) { synchronized(INSTANCE_MAP) { RedditChangeDataManager result = INSTANCE_MAP.get(user); if(result == null) { result = new RedditChangeDataManager(); INSTANCE_MAP.put(user, result); } return result; } } private static HashMap<RedditAccount, HashMap<String, Entry>> snapshotAllUsers() { final HashMap<RedditAccount, HashMap<String, Entry>> result = new HashMap<>(); synchronized(INSTANCE_MAP) { for(final RedditAccount account : INSTANCE_MAP.keySet()) { result.put(account, getInstance(account).snapshot()); } } return result; } public static void writeAllUsers(final ExtendedDataOutputStream dos) throws IOException { Log.i(TAG, "Taking snapshot..."); final HashMap<RedditAccount, HashMap<String, Entry>> data = snapshotAllUsers(); Log.i(TAG, "Writing to stream..."); final Set<Map.Entry<RedditAccount, HashMap<String, Entry>>> userDataSet = data.entrySet(); dos.writeInt(userDataSet.size()); for(final Map.Entry<RedditAccount, HashMap<String, Entry>> userData : userDataSet) { final String username = userData.getKey().getCanonicalUsername(); dos.writeUTF(username); final Set<Map.Entry<String, Entry>> entrySet = userData.getValue().entrySet(); dos.writeInt(entrySet.size()); for(final Map.Entry<String, Entry> entry : entrySet) { dos.writeUTF(entry.getKey()); entry.getValue().writeTo(dos); } Log.i(TAG, String.format(Locale.US, "Wrote %d entries for user '%s'", entrySet.size(), username)); } Log.i(TAG, "All entries written to stream."); } public static void readAllUsers( final ExtendedDataInputStream dis, final Context context) throws IOException { Log.i(TAG, "Reading from stream..."); final int userCount = dis.readInt(); Log.i(TAG, userCount + " users to read."); for(int i = 0; i < userCount; i++) { final String username = dis.readUTF(); final int entryCount = dis.readInt(); Log.i(TAG, String.format(Locale.US, "Reading %d entries for user '%s'", entryCount, username)); final HashMap<String, Entry> entries = new HashMap<>(entryCount); for(int j = 0; j < entryCount; j++) { final String thingId = dis.readUTF(); final Entry entry = new Entry(dis); entries.put(thingId, entry); } Log.i(TAG, "Getting account..."); final RedditAccount account = RedditAccountManager.getInstance(context).getAccount(username); if(account == null) { Log.i(TAG, String.format(Locale.US, "Skipping user '%s' as the account no longer exists", username)); } else { getInstance(account).insertAll(entries); Log.i(TAG, String.format(Locale.US, "Finished inserting entries for user '%s'", username)); } } Log.i(TAG, "All entries read from stream."); } public static void pruneAllUsers() { Log.i(TAG, "Pruning for all users..."); final Set<RedditAccount> users; synchronized(INSTANCE_MAP) { users = INSTANCE_MAP.keySet(); } for(final RedditAccount user : users) { final RedditChangeDataManager managerForUser = getInstance(user); managerForUser.prune(); } Log.i(TAG, "Pruning complete."); } public interface Listener { void onRedditDataChange(final String thingIdAndType); } private static final class Entry { private final long mTimestamp; private final boolean mIsUpvoted; private final boolean mIsDownvoted; private final boolean mIsRead; private final boolean mIsSaved; private final Boolean mIsHidden; // For posts, this means "hidden". For comments, this means "collapsed". static final Entry CLEAR_ENTRY = new Entry(); private Entry() { mTimestamp = Long.MIN_VALUE; mIsUpvoted = false; mIsDownvoted = false; mIsRead = false; mIsSaved = false; mIsHidden = null; } private Entry( final long timestamp, final boolean isUpvoted, final boolean isDownvoted, final boolean isRead, final boolean isSaved, final Boolean isHidden) { mTimestamp = timestamp; mIsUpvoted = isUpvoted; mIsDownvoted = isDownvoted; mIsRead = isRead; mIsSaved = isSaved; mIsHidden = isHidden; } private Entry(final ExtendedDataInputStream dis) throws IOException { mTimestamp = dis.readLong(); mIsUpvoted = dis.readBoolean(); mIsDownvoted = dis.readBoolean(); mIsRead = dis.readBoolean(); mIsSaved = dis.readBoolean(); mIsHidden = dis.readNullableBoolean(); } private void writeTo(final ExtendedDataOutputStream dos) throws IOException { dos.writeLong(mTimestamp); dos.writeBoolean(mIsUpvoted); dos.writeBoolean(mIsDownvoted); dos.writeBoolean(mIsRead); dos.writeBoolean(mIsSaved); dos.writeNullableBoolean(mIsHidden); } boolean isClear() { return !mIsUpvoted && !mIsDownvoted && !mIsRead && !mIsSaved && mIsHidden == null; } public boolean isUpvoted() { return mIsUpvoted; } public boolean isSaved() { return mIsSaved; } public boolean isRead() { return mIsRead; } public Boolean isHidden() { return mIsHidden; } public boolean isDownvoted() { return mIsDownvoted; } Entry update( final long timestamp, final RedditComment comment) { if(timestamp < mTimestamp) { return this; } return new Entry( timestamp, Boolean.TRUE.equals(comment.likes), Boolean.FALSE.equals(comment.likes), false, Boolean.TRUE.equals(comment.saved), mIsHidden); // Use existing value for "collapsed" } Entry update( final long timestamp, final RedditPost post) { if(timestamp < mTimestamp) { return this; } return new Entry( timestamp, Boolean.TRUE.equals(post.likes), Boolean.FALSE.equals(post.likes), post.clicked || mIsRead, post.saved, post.hidden ? true : null); } Entry markUpvoted(final long timestamp) { return new Entry( timestamp, true, false, mIsRead, mIsSaved, mIsHidden); } Entry markDownvoted(final long timestamp) { return new Entry( timestamp, false, true, mIsRead, mIsSaved, mIsHidden); } Entry markUnvoted(final long timestamp) { return new Entry( timestamp, false, false, mIsRead, mIsSaved, mIsHidden); } Entry markRead(final long timestamp) { return new Entry( timestamp, mIsUpvoted, mIsDownvoted, true, mIsSaved, mIsHidden); } Entry markSaved(final long timestamp, final boolean isSaved) { return new Entry( timestamp, mIsUpvoted, mIsDownvoted, mIsRead, isSaved, mIsHidden); } Entry markHidden(final long timestamp, final Boolean isHidden) { return new Entry( timestamp, mIsUpvoted, mIsDownvoted, mIsRead, mIsSaved, isHidden); } } private static final class ListenerNotifyOperator implements WeakReferenceListManager.ArgOperator<Listener, String> { public static final ListenerNotifyOperator INSTANCE = new ListenerNotifyOperator(); private ListenerNotifyOperator() {} @Override public void operate(final Listener listener, final String arg) { listener.onRedditDataChange(arg); } } private final HashMap<String, Entry> mEntries = new HashMap<>(); private final Object mLock = new Object(); private final WeakReferenceListHashMapManager<String, Listener> mListeners = new WeakReferenceListHashMapManager<>(); public void addListener( final RedditThingWithIdAndType thing, final Listener listener) { mListeners.add(thing.getIdAndType(), listener); } public void removeListener( final RedditThingWithIdAndType thing, final Listener listener) { mListeners.remove(thing.getIdAndType(), listener); } private Entry get(final RedditThingWithIdAndType thing) { final Entry entry = mEntries.get(thing.getIdAndType()); if(entry == null) { return Entry.CLEAR_ENTRY; } else { return entry; } } private void set( final RedditThingWithIdAndType thing, final Entry existingValue, final Entry newValue) { if(newValue.isClear()) { if(!existingValue.isClear()) { mEntries.remove(thing.getIdAndType()); RedditChangeDataIO.notifyUpdateStatic(); } } else { mEntries.put(thing.getIdAndType(), newValue); RedditChangeDataIO.notifyUpdateStatic(); } mListeners.map(thing.getIdAndType(), ListenerNotifyOperator.INSTANCE, thing.getIdAndType()); } private void insertAll(final HashMap<String, Entry> entries) { synchronized(mLock) { for(final Map.Entry<String, Entry> entry : entries.entrySet()) { final Entry newEntry = entry.getValue(); final Entry existingEntry = mEntries.get(entry.getKey()); if(existingEntry == null || existingEntry.mTimestamp < newEntry.mTimestamp) { mEntries.put(entry.getKey(), newEntry); } } } for(final String idAndType : entries.keySet()) { mListeners.map(idAndType, ListenerNotifyOperator.INSTANCE, idAndType); } } public void update(final long timestamp, final RedditComment comment) { synchronized(mLock) { final Entry existingEntry = get(comment); final Entry updatedEntry = existingEntry.update(timestamp, comment); set(comment, existingEntry, updatedEntry); } } public void update(final long timestamp, final RedditPost post) { synchronized(mLock) { final Entry existingEntry = get(post); final Entry updatedEntry = existingEntry.update(timestamp, post); set(post, existingEntry, updatedEntry); } } public void markUpvoted(final long timestamp, final RedditThingWithIdAndType thing) { synchronized(mLock) { final Entry existingEntry = get(thing); final Entry updatedEntry = existingEntry.markUpvoted(timestamp); set(thing, existingEntry, updatedEntry); } } public void markDownvoted(final long timestamp, final RedditThingWithIdAndType thing) { synchronized(mLock) { final Entry existingEntry = get(thing); final Entry updatedEntry = existingEntry.markDownvoted(timestamp); set(thing, existingEntry, updatedEntry); } } public void markUnvoted(final long timestamp, final RedditThingWithIdAndType thing) { synchronized(mLock) { final Entry existingEntry = get(thing); final Entry updatedEntry = existingEntry.markUnvoted(timestamp); set(thing, existingEntry, updatedEntry); } } public void markSaved(final long timestamp, final RedditThingWithIdAndType thing, final boolean saved) { synchronized(mLock) { final Entry existingEntry = get(thing); final Entry updatedEntry = existingEntry.markSaved(timestamp, saved); set(thing, existingEntry, updatedEntry); } } public void markHidden(final long timestamp, final RedditThingWithIdAndType thing, final Boolean hidden) { synchronized(mLock) { final Entry existingEntry = get(thing); final Entry updatedEntry = existingEntry.markHidden(timestamp, hidden); set(thing, existingEntry, updatedEntry); } } public void markRead(final long timestamp, final RedditThingWithIdAndType thing) { synchronized(mLock) { final Entry existingEntry = get(thing); final Entry updatedEntry = existingEntry.markRead(timestamp); set(thing, existingEntry, updatedEntry); } } public boolean isUpvoted(final RedditThingWithIdAndType thing) { synchronized(mLock) { return get(thing).isUpvoted(); } } public boolean isDownvoted(final RedditThingWithIdAndType thing) { synchronized(mLock) { return get(thing).isDownvoted(); } } public boolean isRead(final RedditThingWithIdAndType thing) { synchronized(mLock) { return get(thing).isRead(); } } public boolean isSaved(final RedditThingWithIdAndType thing) { synchronized(mLock) { return get(thing).isSaved(); } } public Boolean isHidden(final RedditThingWithIdAndType thing) { synchronized(mLock) { return get(thing).isHidden(); } } private HashMap<String, Entry> snapshot() { synchronized(mLock) { return new HashMap<>(mEntries); } } private void prune() { final long now = System.currentTimeMillis(); final long timestampBoundary = now - PRUNE_AGE_MS; synchronized(mLock) { final Iterator<Map.Entry<String, Entry>> iterator = mEntries.entrySet().iterator(); while(iterator.hasNext()) { final Map.Entry<String, Entry> entry = iterator.next(); final long timestamp = entry.getValue().mTimestamp; if(timestamp < timestampBoundary) { Log.i(TAG, String.format( "Pruning '%s' (%d hours old)", entry.getKey(), (now - timestamp) / (60L * 60L * 1000L))); iterator.remove(); } } } } }