/*
* 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");
}
}
}