/* * Copyright (c) 2016 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except * in compliance with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under the License * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express * or implied. See the License for the specific language governing permissions and limitations under * the License. */ package com.google.samples.apps.iosched.sync.userdata.firebase; import android.support.annotation.NonNull; import android.support.annotation.VisibleForTesting; import android.text.TextUtils; import com.google.samples.apps.iosched.sync.userdata.UserAction; import com.google.samples.apps.iosched.sync.userdata.util.UserData; import com.google.samples.apps.iosched.util.FirebaseUtils; import java.util.HashMap; import java.util.List; import java.util.Map; /** * Helper class for managing the merge of local and remote user data. Processes the {@link * com.firebase.client.DataSnapshot} from Firebase and creates a {@link * com.google.samples.apps.iosched.sync.userdata.util.UserData} object. Also creates * a {@code UserData} object from data stored in the local DB. Creates a merged {@code UserData} * object representing the consensus user data, and uses that to update both Firebase and the local * DB. */ public class MergeHelper { /** * Holds user data retrieved from the local ContentProvider. */ private final UserData mLocalUserData; /** * Holds user data retrieved from Firebase. */ private final UserData mRemoteUserData; /** * Holds data generated by merging local and remote user data. */ private final UserData mMergedUserData; /** * The Firebase user ID associated with the currently chosen account. */ private String mUid; public MergeHelper(@NonNull UserData localUserData, @NonNull UserData remoteUserData, @NonNull UserData mergedUserData, @NonNull String uid) { mLocalUserData = checkNotNull(localUserData); mRemoteUserData = checkNotNull(remoteUserData); mMergedUserData = checkNotNull(mergedUserData); mUid = uid; } @VisibleForTesting UserData getLocalUserData() { return mLocalUserData; } @VisibleForTesting UserData getRemoteUserData() { return mRemoteUserData; } @VisibleForTesting UserData getMergedUserData() { return mMergedUserData; } /** * Sets the GCM key for merged user data. Picks the remote GCM key if it exists; otherwise, * picks the local GCM key. */ public void mergeGCMKeys() { String remoteGcmKey = mRemoteUserData.getGcmKey(); String localGcmKey = mLocalUserData.getGcmKey(); mMergedUserData.setGcmKey(remoteGcmKey == null || remoteGcmKey.isEmpty() ? localGcmKey : remoteGcmKey); } /** * Processes changes in local user data which were triggered by a user action and which may * require a remote Firebase sync. We maintain a flag per data item (see {@link * com.google.samples.apps.iosched.provider.ScheduleContract}), and when an item changes, we * attempt to sync it. * * @param actions The user actions that require a remote sync. */ public void mergeUnsyncedActions(final List<UserAction> actions) { mMergedUserData.updateVideoIds(mRemoteUserData); for (Map.Entry<String, UserData.StarredSession> entry : mRemoteUserData.getStarredSessions().entrySet()) { mMergedUserData.getStarredSessions().put(entry.getKey(), entry.getValue()); } mMergedUserData.updateFeedbackSubmittedSessionIds(mRemoteUserData); // Update merged data with local data. for (final UserAction action : actions) { if (action.requiresSync) { if (UserAction.TYPE.ADD_STAR.equals(action.type) || UserAction.TYPE.REMOVE_STAR.equals(action.type)) { // The merged user data so far reflects remote. Override remote wherever local // data is more recent. UserData.StarredSession session = mMergedUserData.getStarredSessions().get( action.sessionId); // Either remote doesn't have this session, or local is more recent than // remote. if (session == null || session.getTimestamp() < action.timestamp) { mMergedUserData.getStarredSessions().put(action.sessionId, new UserData.StarredSession( UserAction.TYPE.ADD_STAR.equals(action.type), action.timestamp)); } } else if (UserAction.TYPE.VIEW_VIDEO.equals(action.type)) { mMergedUserData.addVideoId(action.videoId); } else if (UserAction.TYPE.SUBMIT_FEEDBACK.equals(action.type)) { mMergedUserData.addFeedbackSubmittedSessionId(action.sessionId); } } } } /** * Builds and returns a Map that can be used when calling {@link com.firebase.client * .Firebase#updateChildren(Map)} to update Firebase with a single write. * * @return A Map where the keys are String paths relative to Firebase root, and the values are * the data that is written to those paths. */ public Map<String, Object> getPendingFirebaseUpdatesMap() { Map<String, Object> pendingFirebaseUpdatesMap = new HashMap<>(); pendingFirebaseUpdatesMap.put(FirebaseUtils.getGcmKeyChildPath(mUid), mMergedUserData.getGcmKey()); for (String videoID : mMergedUserData.getViewedVideoIds()) { pendingFirebaseUpdatesMap.put(FirebaseUtils.getViewedVideoChildPath(mUid, videoID), true); } for (final Map.Entry<String, UserData.StarredSession> entry : getPendingFirebaseSessionUpdates().entrySet()) { updateSessionInSchedule(pendingFirebaseUpdatesMap, mUid, entry.getKey(), entry.getValue().isInSchedule()); updateSessionTimestamp(pendingFirebaseUpdatesMap, mUid, entry.getKey(), entry.getValue().getTimestamp()); } for (String sessionID : mMergedUserData.getFeedbackSubmittedSessionIds()) { pendingFirebaseUpdatesMap .put(FirebaseUtils.getFeedbackSubmittedSessionChildPath(mUid, sessionID), true); } pendingFirebaseUpdatesMap.put(FirebaseUtils.getLastActivityTimestampChildPath(mUid), System.currentTimeMillis()); return pendingFirebaseUpdatesMap; } /** * Returns sessions whose data must be written to Firebase. */ private Map<String, UserData.StarredSession> getPendingFirebaseSessionUpdates() { Map<String, UserData.StarredSession> sessions = new HashMap<>(); for (final Map.Entry<String, UserData.StarredSession> entry : mMergedUserData.getStarredSessions().entrySet()) { String sessionId = entry.getKey(); UserData.StarredSession starredSession = entry.getValue(); if (writeSessionDataToFirebase(sessionId, starredSession)) { sessions.put(sessionId, starredSession); } } return sessions; } /** * Returns whether a session's data must be written to Firebase or not. * * @param sessionId The ID of the session that has been added to or removed from a user's * schedule. * @param starredSession The data associated with {@code sessionID}. */ private boolean writeSessionDataToFirebase(String sessionId, UserData.StarredSession starredSession) { Map<String, UserData.StarredSession> remoteStarredSessions = mRemoteUserData.getStarredSessions(); return (!remoteStarredSessions.containsKey(sessionId) || remoteStarredSessions.get(sessionId) != starredSession); } /** * Updates {@code map} with the sessions that have been added or removed from a user's schedule. * * @param map Used when calling {@link com.firebase.client.Firebase#updateChildren(Map)} * to update Firebase with a single write. * @param uid The Firebase user id associated with the currently chosen account. * @param sessionId The ID of the session that was added or removed from a user's schedule. * @param inSchedule Whether session is in schedule (true), or removed from schedule (false). * */ private void updateSessionInSchedule(Map<String, Object> map, String uid, String sessionId, boolean inSchedule) { map.put(FirebaseUtils.getStarredSessionInScheduleChildPath(uid, sessionId), inSchedule); } /** * Updates the timestamp for a session that was added or removed from a user's schedule. * * @param map Used when calling {@link com.firebase.client.Firebase#updateChildren(Map)} * to update Firebase with a single write. * @param uid The Firebase user id associated with the currently chosen account. * @param sessionId The ID of the session that was added or removed from a user's schedule. * @param timestamp The time when the session was starred or unstarred. In milliseconds since * epoch. */ private void updateSessionTimestamp(Map<String, Object> map, String uid, String sessionId, Long timestamp) { map.put(FirebaseUtils.getStarredSessionTimestampChildPath(uid, sessionId), timestamp); } /** * Tracks whether the local gcm key should be updated. * * @return True if the local gcm key should be updated, otherwise false. */ protected boolean isLocalGcmKeyUpdateNeeded() { return !TextUtils.equals(mMergedUserData.getGcmKey(), mLocalUserData.getGcmKey()); } /** * Throws an exception if {@code userData} is null. Otherwise returns {@code userData}. * * @param userData The {@link UserData} object that holds the user data. */ private UserData checkNotNull(UserData userData) { if (userData != null) { return userData; } else { throw new IllegalArgumentException("userData must not be null"); } } }