/* * Copyright 2017 Google Inc. All Rights Reserved. * * 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.android.apps.forscience.whistlepunk.filemetadata; import android.content.Context; import androidx.annotation.VisibleForTesting; import android.text.TextUtils; import android.util.Log; import com.google.android.apps.forscience.whistlepunk.PictureUtils; import com.google.android.apps.forscience.whistlepunk.R; import com.google.android.apps.forscience.whistlepunk.accounts.AppAccount; import com.google.android.apps.forscience.whistlepunk.data.GoosciGadgetInfo; import com.google.android.apps.forscience.whistlepunk.data.GoosciSensorLayout.SensorLayout; import com.google.android.apps.forscience.whistlepunk.metadata.GoosciCaption.Caption; import com.google.android.apps.forscience.whistlepunk.metadata.GoosciExperiment; import com.google.android.apps.forscience.whistlepunk.metadata.GoosciExperiment.ChangedElement.ElementType; import com.google.android.apps.forscience.whistlepunk.metadata.GoosciExperiment.ExperimentSensor; import com.google.android.apps.forscience.whistlepunk.metadata.GoosciLabel; import com.google.android.apps.forscience.whistlepunk.metadata.GoosciLabel.Label.ValueType; import com.google.android.apps.forscience.whistlepunk.metadata.GoosciSensorTrigger; import com.google.android.apps.forscience.whistlepunk.metadata.GoosciTrial; import com.google.android.apps.forscience.whistlepunk.metadata.GoosciTrial.Range; import com.google.android.apps.forscience.whistlepunk.metadata.Version; import com.google.android.apps.forscience.whistlepunk.metadata.Version.FileVersion; import com.google.common.base.Preconditions; import com.google.common.base.Splitter; import com.google.common.base.Strings; import io.reactivex.functions.Consumer; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; /** * Represents a Science Journal experiment. All changes should be made using the getters and setters * provided, rather than by getting the underlying protocol buffer and making changes to that * directly. Changes to the underlying proto outside this class may be overwritten and may not be * saved. */ // TODO: Get the ExperimentOverview photo path from labels and trials at load and change. public class Experiment extends LabelListHolder { private static final String TAG = "Experiment"; public static final String EXPERIMENTS = "experiments/"; private ExperimentOverviewPojo experimentOverview; private List<SensorLayoutPojo> sensorLayouts; private List<ExperimentSensor> experimentSensors; private List<SensorTrigger> sensorTriggers; private List<Trial> trials; private final List<Change> changes; private String title; private String description; private FileVersion.Builder fileVersion; // Relative to the experiment, not the account root. private String imagePath; private int trialCount; private int totalTrials; private boolean isArchived; private long lastUsedTimeMs; private final long creationTimeMs; public static Experiment newExperiment(long creationTime, String experimentId, int colorIndex) { GoosciExperiment.Experiment.Builder proto = GoosciExperiment.Experiment.newBuilder(); ExperimentOverviewPojo experimentOverview = new ExperimentOverviewPojo(); experimentOverview.setLastUsedTimeMs(creationTime); experimentOverview.setArchived(false); experimentOverview.setExperimentId(experimentId); experimentOverview.setColorIndex(colorIndex); proto.setCreationTimeMs(creationTime); proto.setTotalTrials(0); // This experiment is being created with the latest VERSION available. proto.setFileVersion( Version.FileVersion.newBuilder() .setVersion(ExperimentCache.VERSION) .setMinorVersion(ExperimentCache.MINOR_VERSION) .setPlatformVersion(ExperimentCache.PLATFORM_VERSION) .setPlatform(GoosciGadgetInfo.GadgetInfo.Platform.ANDROID) .build()); return new Experiment(proto.build(), experimentOverview); } public static Experiment newExperiment( Context context, AppAccount appAccount, ExperimentLibraryManager elm, long creationTime, String experimentId, int colorIndex, long lastUsedTime) { Experiment newExperiment = Experiment.newExperiment(creationTime, experimentId, colorIndex); newExperiment.setLastUsedTime(elm.getModified(experimentId)); newExperiment.setArchived(context, appAccount, elm.isArchived(experimentId)); return newExperiment; } /** Populates the Experiment from an existing proto. */ public static Experiment fromExperiment( GoosciExperiment.Experiment experiment, ExperimentOverviewPojo experimentOverview) { return new Experiment(experiment, experimentOverview); } // Archived state is set per account, so if you archive something on one device and share it // it will not show up as archived on another account. Therefore it is stored outside of the // experiment proto. private Experiment( GoosciExperiment.Experiment experimentProto, ExperimentOverviewPojo experimentOverview) { labels = new ArrayList<>(); for (GoosciLabel.Label labelProto : experimentProto.getLabelsList()) { labels.add(Label.fromLabel(labelProto)); } trials = new ArrayList<>(); for (GoosciTrial.Trial trial : experimentProto.getTrialsList()) { trials.add(Trial.fromTrial(trial)); } sensorTriggers = new ArrayList<>(); for (GoosciSensorTrigger.SensorTrigger proto : experimentProto.getSensorTriggersList()) { sensorTriggers.add(SensorTrigger.fromProto(proto)); } changes = new ArrayList<>(); for (com.google.android.apps.forscience.whistlepunk.metadata.GoosciExperiment.Change proto : experimentProto.getChangesList()) { addChange(Change.fromProto(proto)); } sensorLayouts = new ArrayList<>(); for (SensorLayout layout : experimentProto.getSensorLayoutsList()) { sensorLayouts.add(SensorLayoutPojo.fromProto(layout)); } experimentSensors = new ArrayList<>(); experimentSensors.addAll(experimentProto.getExperimentSensorsList()); title = experimentProto.getTitle(); description = experimentProto.getDescription(); lastUsedTimeMs = experimentOverview.getLastUsedTimeMs(); if (!experimentProto.getImagePath().isEmpty()) { // Relative to the experiment, not the account root. imagePath = getPathRelativeToExperiment(experimentProto.getImagePath()); } else { // Overview is relative to the account root. Be sure to trim 2 levels imagePath = getPathRelativeToExperiment(experimentOverview.getImagePath()); } isArchived = experimentOverview.isArchived(); totalTrials = experimentProto.getTotalTrials(); trialCount = experimentOverview.getTrialCount(); creationTimeMs = experimentProto.getCreationTimeMs(); if (experimentProto.hasFileVersion()) { fileVersion = experimentProto.getFileVersion().toBuilder(); } else { fileVersion = FileVersion.newBuilder(); } this.experimentOverview = experimentOverview; } public GoosciExperiment.Experiment toProto() { return getExperimentProto(); } /** * Gets the proto underlying this experiment. The resulting proto should *not* be modified outside * of this class because changes to it will not be saved. * * @return The experiment's underlying protocolbuffer. */ public GoosciExperiment.Experiment getExperimentProto() { // All local fields that represent experiment state must be merged back into the proto here. GoosciExperiment.Experiment.Builder proto = GoosciExperiment.Experiment.newBuilder(); if (sensorLayouts != null) { for (SensorLayoutPojo pojo : sensorLayouts) { proto.addSensorLayouts(pojo.toProto()); } } if (experimentSensors != null) { proto.addAllExperimentSensors(experimentSensors); } if (sensorTriggers != null) { for (SensorTrigger trigger : sensorTriggers) { proto.addSensorTriggers(trigger.getTriggerProto()); } } if (trials != null) { for (Trial trial : trials) { proto.addTrials(trial.getTrialProto()); } } if (labels != null) { for (Label label : labels) { proto.addLabels(label.getLabelProto()); } } if (changes != null) { for (Change change : changes) { proto.addChanges(change.getChangeProto()); } } // Relative to the experiment. proto.setImagePath(getPathRelativeToExperiment(imagePath)); if (title != null) { proto.setTitle(title); } if (description != null) { proto.setDescription(description); } proto.setTotalTrials(totalTrials); proto.setFileVersion(fileVersion.build()); proto.setCreationTimeMs(creationTimeMs); proto.setVersion(fileVersion.getVersion()); proto.setMinorVersion(fileVersion.getMinorVersion()); return proto.build(); } public ExperimentOverviewPojo getExperimentOverview() { experimentOverview.setTitle(title); experimentOverview.setArchived(isArchived); // Relative to the account root. Be sure to add 2 levels. experimentOverview.setImagePath(getPathRelativeToAccountRoot(imagePath)); experimentOverview.setLastUsedTimeMs(lastUsedTimeMs); experimentOverview.setTrialCount(trialCount); return experimentOverview; } public String getExperimentId() { return experimentOverview.getExperimentId(); } public static String getExperimentId(Experiment experiment) { if (experiment != null) { return experiment.getExperimentOverview().getExperimentId(); } return ""; } public long getCreationTimeMs() { return creationTimeMs; } public void setArchived(Context context, AppAccount appAccount, boolean archived) { isArchived = archived; } public boolean isArchived() { return isArchived; } public String getTitle() { return title; } public void setTitle(String title) { setTitle(title, Change.newModifyTypeChange(ElementType.EXPERIMENT, getExperimentId())); } public void setTitle(String title, Change change) { this.title = title; addChange(change); } private void setTitleWithoutRecordingChange(String title) { this.title = title; } public String getDisplayTitle(Context context) { return getDisplayTitle(context, getTitle()); } public static String getDisplayTitle(Context context, String title) { return !TextUtils.isEmpty(title) ? title : context.getString(R.string.default_experiment_name); } public FileVersion getFileVersion() { return fileVersion.build(); } public void setFileVersion(FileVersion fileVersion) { this.fileVersion = fileVersion.toBuilder(); } public void setPlatformVersion(int version) { this.fileVersion.setPlatformVersion(version); } /** * Sets the total trials in the experiment. This should generally not be used, except when reading * older protos from disk. * * @param totalTrials The number of trials in the experiment. */ public void setTotalTrials(int totalTrials) { this.totalTrials = totalTrials; } @Deprecated public String getDescription() { return description; } public void setDescription(String description) { this.description = description; } public long getLastUsedTime() { return lastUsedTimeMs; } public void setLastUsedTime(long lastUsedTime) { lastUsedTimeMs = lastUsedTime; } // Relative to the experiment. Check for account root. public void setImagePath(String imagePath) { setImagePathWithoutRecordingChange(imagePath); addChange(Change.newModifyTypeChange(ElementType.EXPERIMENT, getExperimentId())); } /** * Sets the image path in the Experiment without adding a change to the history. Should only be * used for merging experiments. */ public void setImagePathWithoutRecordingChange(String imagePath) { // Relative to the experiment. this.imagePath = getPathRelativeToExperiment(imagePath); } // Relative to the experiment. public String getImagePath() { return getPathRelativeToExperiment(imagePath); } /** * Gets the labels which fall during a certain time range. Objects in this list should not be * modified and expect that state to be saved, instead editing of labels should happen using * updateLabel, addTrialLabel, removeLabel. * * @param range The time range in which to search for labels * @return A list of labels in that range, or an empty list if none are found. */ public List<Label> getLabelsForRange(Range range) { List<Label> result = new ArrayList<>(); for (Label label : getLabels()) { if (range.getStartMs() <= label.getTimeStamp() && range.getEndMs() >= label.getTimeStamp()) { result.add(label); } else if (range.getEndMs() < label.getTimeStamp()) { // These are sorted, so we can stop looking once we've found labels that are too // recent. break; } } return result; } /** * Temporary method used to populate labels from the database. TODO: Deprecate this after moving * to a file-based system where labels are stored as part of the proto and don't need a separate * set. * * @param labels */ public void populateLabels(List<Label> labels) { setLabels(labels); } /** * Gets the current list of trials in this experiment. Objects in this list should not be modified * and expect that state to be saved, instead editing of trials should happen using updateTrial, * addTrial, deleteTrial. */ public List<Trial> getTrials() { return trials; } /** * Gets the current list of trials in this experiment. Objects in this list should not be modified * and expect that state to be saved, instead editing of trials should happen using updateTrial, * addTrial, deleteTrial. */ public List<Trial> getTrials(boolean includeArchived, boolean includeInvalid) { if (includeArchived && includeInvalid) { return getTrials(); } List<Trial> result = new ArrayList<>(); for (Trial trial : trials) { if (!includeInvalid && !trial.isValid()) { // Invalid trial, don't add it. } else if (!includeArchived && trial.isArchived()) { // Don't add it. } else { result.add(trial); } } return result; } /** * Deletes all invalid trials. * * Only call this method from a background thread, for example, when syncing or exporting. */ public void cleanTrials(Context context, AppAccount appAccount) { List<Trial> allTrials = new ArrayList<>(trials); for (Trial trial : allTrials) { if (!trial.isValid()) { deleteTrialWithoutRecordingChange(trial, context, appAccount); } } } @VisibleForTesting public void cleanTrialsOnlyForTesting() { List<Trial> allTrials = new ArrayList<>(trials); for (Trial trial : allTrials) { if (!trial.isValid()) { deleteTrialOnlyForTesting(trial); } } } /** * This wipes the current trial list and should be used only when populating an experiment from * the database. */ public void setTrials(List<Trial> trials) { this.trials = Preconditions.checkNotNull(trials); experimentOverview.setTrialCount(this.trials.size()); totalTrials = this.trials.size(); // Make sure it isn't any larger than this. That's possible if runs were deleted. for (int i = 0; i < trials.size(); i++) { if (trials.get(i).getTrialNumberInExperiment() > totalTrials) { totalTrials = trials.get(i).getTrialNumberInExperiment(); } } } /** * Get the count separately from getting all the trials avoids unnecessary processing if the * caller just has to know how many there are. * * @return The number of trials in this experiment. */ public int getTrialCount() { return trials.size(); } /** * Gets a trial by its unique ID. Note that Trial IDs are only guaranteed to be unique in an * experiment. */ public Trial getTrial(String trialId) { for (Trial trial : trials) { if (TextUtils.equals(trial.getTrialId(), trialId)) { return trial; } } return null; } /** Updates a trial without writing a change. Used for merging. */ private void updateTrialWithoutRecordingChange(Trial trial) { for (int i = 0; i < trials.size(); i++) { Trial next = trials.get(i); if (TextUtils.equals(trial.getTrialId(), next.getTrialId())) { trials.set(i, trial); break; } } // Update may involve crop, so re-sort just in case! sortTrials(); } /** * Updates a trial. * * @param trial */ public void updateTrial(Trial trial) { for (int i = 0; i < trials.size(); i++) { Trial next = trials.get(i); if (TextUtils.equals(trial.getTrialId(), next.getTrialId())) { trials.set(i, trial); break; } } // Update may involve crop, so re-sort just in case! sortTrials(); addChange(Change.newModifyTypeChange(ElementType.TRIAL, trial.getTrialId())); } /** * Adds a new trial to the experiment. * * @param trial * @param change */ public void addTrial(Trial trial, Change change) { trials.add(trial); trialCount = trials.size(); trial.setTrialNumberInExperiment(++totalTrials); sortTrials(); addChange(change); } public void addTrial(Trial trial) { addTrial(trial, Change.newAddTypeChange(ElementType.TRIAL, trial.getTrialId())); } /** Adds a new trial to the experiment without recording the change. Used for merges. */ private void addTrialwithoutRecordingChange(Trial trial) { trials.add(trial); trialCount = trials.size(); trial.setTrialNumberInExperiment(++totalTrials); sortTrials(); } /** Removes a trial from the experiment, without recording a change. Used for merging */ // TODO(b/79353972) Test this public void deleteTrialWithoutRecordingChange( Trial trial, Context context, AppAccount appAccount) { trial.deleteContents(context, appAccount, getExperimentId()); trials.remove(trial); trialCount = trials.size(); } /** Removes a trial from the experiment. */ // TODO(b/79353972) Test this public void deleteTrial(Trial trial, Context context, AppAccount appAccount) { deleteTrial( trial, Change.newDeleteTypeChange(ElementType.TRIAL, trial.getTrialId()), context, appAccount); } public void deleteTrial(Trial trial, Change change, Context context, AppAccount appAccount) { trial.deleteContents(context, appAccount, getExperimentId()); trials.remove(trial); trialCount = trials.size(); addChange(change); } /** Removes the assets from this experiment. */ public void deleteContents(Context context, AppAccount appAccount) { for (Label label : getLabels()) { deleteLabelAssets(label, context, appAccount, getExperimentId()); } for (Trial trial : getTrials()) { trial.deleteContents(context, appAccount, getExperimentId()); } } @VisibleForTesting public void deleteTrialOnlyForTesting(Trial trial) { trials.remove(trial); trialCount = trials.size(); addChange(Change.newDeleteTypeChange(ElementType.TRIAL, trial.getTrialId())); } private void sortTrials() { Collections.sort(trials, Trial.COMPARATOR_BY_TIMESTAMP); } public List<SensorLayoutPojo> getSensorLayouts() { return sensorLayouts; } public void setSensorLayouts(List<SensorLayoutPojo> layouts) { sensorLayouts = Preconditions.checkNotNull(layouts); } public void updateSensorLayout(int layoutPosition, SensorLayoutPojo layout) { if (layoutPosition == 0 && sensorLayouts.size() == 0) { // First one! SensorFragment calls this function when first observing; // make sure to handle the empty state correctly by doing this. sensorLayouts.add(layout); } if (layoutPosition < sensorLayouts.size()) { sensorLayouts.set(layoutPosition, layout); } } public List<ExperimentSensor> getExperimentSensors() { return experimentSensors; } public void setExperimentSensors(List<ExperimentSensor> experimentSensors) { this.experimentSensors = Preconditions.checkNotNull(experimentSensors); } /** * Gets the current list of sensor triggers in this experiment for all sensors. Objects in this * list should not be modified and expect that state to be saved, instead editing of triggers * should happen using update/add/remove functions. */ public List<SensorTrigger> getSensorTriggers() { return sensorTriggers; } public List<SensorTrigger> getActiveSensorTriggers(SensorLayoutPojo layout) { List<SensorTrigger> result = new ArrayList<>(layout.getActiveSensorTriggerIds().size()); for (String triggerId : layout.getActiveSensorTriggerIds()) { SensorTrigger trigger = getSensorTrigger(triggerId); if (trigger != null) { result.add(trigger); } } return result; } /** * Gets the current list of sensor triggers in this experiment for a particular sensor. Objects in * this list should not be modified and expect that state to be saved, instead editing of triggers * should happen using update/add/remove functions. */ public List<SensorTrigger> getSensorTriggersForSensor(String sensorId) { List<SensorTrigger> result = new ArrayList<>(); for (SensorTrigger trigger : sensorTriggers) { if (TextUtils.equals(trigger.getSensorId(), sensorId)) { result.add(trigger); } } return result; } /** Gets a sensor trigger by trigger ID. */ public SensorTrigger getSensorTrigger(String triggerId) { for (SensorTrigger trigger : sensorTriggers) { if (TextUtils.equals(trigger.getTriggerId(), triggerId)) { return trigger; } } return null; } /** * Sets the whole list of SensorTriggers on the experiment. * * @param sensorTriggers */ public void setSensorTriggers(List<SensorTrigger> sensorTriggers) { this.sensorTriggers = Preconditions.checkNotNull(sensorTriggers); } /** Adds a sensor trigger. */ public void addSensorTrigger(SensorTrigger trigger) { sensorTriggers.add(trigger); } /** Updates a sensor trigger. */ public void updateSensorTrigger(SensorTrigger triggerToUpdate) { for (int i = 0; i < sensorTriggers.size(); i++) { SensorTrigger next = sensorTriggers.get(i); if (TextUtils.equals(triggerToUpdate.getTriggerId(), next.getTriggerId())) { sensorTriggers.set(i, triggerToUpdate); break; } } } /** * Removes a sensor trigger using its ID to find the matching trigger. * * @param triggerToRemove */ public void removeSensorTrigger(SensorTrigger triggerToRemove) { for (SensorTrigger trigger : sensorTriggers) { if (TextUtils.equals(trigger.getTriggerId(), triggerToRemove.getTriggerId())) { sensorTriggers.remove(trigger); return; } } } public List<String> getSensorIds() { List<SensorLayoutPojo> sensorLayoutList = getSensorLayouts(); List<String> sensorIds = new ArrayList<>(); for (SensorLayoutPojo layout : sensorLayoutList) { sensorIds.add(layout.getSensorId()); } return sensorIds; } @Override protected void onPictureLabelAdded(Label label) { // Relative to Experiment. if (TextUtils.isEmpty(imagePath)) { imagePath = getPathRelativeToExperiment(label.getPictureLabelValue().getFilePath()); } } @Override protected void beforeDeletingPictureLabel(Label label) { // Both relative to Experiment if (TextUtils.equals( imagePath, getPathRelativeToExperiment(label.getPictureLabelValue().getFilePath()))) { // This is the picture label which is used as the cover photo for this experiment. // Try to find another, oldest first. for (int i = labels.size() - 1; i >= 0; i--) { Label other = labels.get(i); if (!TextUtils.equals(other.getLabelId(), label.getLabelId()) && other.getType() == ValueType.PICTURE) { // Should be relative to Experiment. imagePath = getPathRelativeToExperiment(other.getPictureLabelValue().getFilePath()); return; } } // Couldn't find another, so just set it to nothing. imagePath = ""; } } public boolean isEmpty() { return getLabelCount() == 0 && getTrialCount() == 0 && TextUtils.isEmpty(getExperimentOverview().getImagePath()) && TextUtils.isEmpty(getTitle()) && !isArchived(); } public void addChange(Change change) { changes.add(change); } public List<Change> getChanges() { return changes; } /** * Finds a label with a given id within an experiment, whether in the experiment itself, or nested * under a trial. * * @param labelId the id of the label to find. * @return the label that corresponds to the Id, or null. */ public Label getLabel(String labelId) { for (Label label : labels) { if (label.getLabelId().equals(labelId)) { return label; } } for (Trial trial : trials) { for (Label label : trial.getLabels()) { if (label.getLabelId().equals(labelId)) { return label; } } } return null; } /** * Finds a the id of the trial that contains a given labelId, or null if the id isn't found, or if * it is found in the root experiment. * * @param labelId the id of the label to find. * @return the id of the trial that contains the label, or null. */ public String getTrialIdForLabel(String labelId) { for (Label label : labels) { if (label.getLabelId().equals(labelId)) { return null; } } for (Trial trial : trials) { for (Label label : trial.getLabels()) { if (label.getLabelId().equals(labelId)) { return trial.getTrialId(); } } } return null; } public static String getChangeMapKey(Change change) { return change.getChangedElementId() + change.getChangedElementType().getNumber(); } /** * Merges the supplied externalExperiment into this experiment. The externalExperiment is not * modified by this operation, but should no longer be used, as it is outdated. However, merging * an experiment twice should not cause any problems, as the mergeFrom is deterministic and * records all changes to the newly merged experiment. * * <p>Based on sj-merge documentation. * * @param externalExperiment The Experiment to mergeFrom into this one. * @param context The current Context. */ public FileSyncCollection mergeFrom( Experiment externalExperiment, Context context, AppAccount appAccount, boolean overwrite) { if (overwrite) { changes.clear(); changes.addAll(externalExperiment.changes); trials.clear(); trials.addAll(externalExperiment.trials); labels.clear(); labels.addAll(externalExperiment.labels); title = externalExperiment.title; description = externalExperiment.description; // Relative to Experiment. imagePath = getPathRelativeToExperiment(externalExperiment.imagePath); trialCount = externalExperiment.trialCount; totalTrials = externalExperiment.totalTrials; return new FileSyncCollection(); } else { // First, we have to calculate the changes made in the local and external experiment. List<Change> localChanges = changes; List<Change> externalChanges = externalExperiment.getChanges(); Set<Change> localOnly = new LinkedHashSet<>(); Set<Change> externalOnly = new LinkedHashSet<>(); FileSyncCollection filesToSync = new FileSyncCollection(); localOnly.addAll(localChanges); localOnly.removeAll(externalChanges); externalOnly.addAll(externalChanges); externalOnly.removeAll(localChanges); // Next, we have to add all of the external-only change records to the local change log. for (Change c : externalOnly) { addChange(c); } // Now, build a set of every element that changed externally and locally. This way, // we can intersect those sets to find conflicts. HashMap<String, Change> changedExternalElements = new HashMap<>(); for (Change external : externalOnly) { changedExternalElements.put(getChangeMapKey(external), external); } HashMap<String, Change> changedLocalElements = new HashMap<>(); for (Change local : localOnly) { changedLocalElements.put(getChangeMapKey(local), local); } // For each external changed element, see if that element was also changed locally. If it was, // Solve the conflict. If it wasn't, copy the element to the local experiment. // N.B., this deals with changed ELEMENTS, not changes. So if there are 2 edits made to a // note, // we only have to deal with it once, as the final state is already recorded. We have copied // the change record above, so future merges will be aware of the full history. FileMetadataUtil fileMetadataUtil = FileMetadataUtil.getInstance(); for (Change external : changedExternalElements.values()) { if (changedLocalElements.containsKey(getChangeMapKey(external))) { handleConflictMerge( externalExperiment, context, appAccount, fileMetadataUtil, external, filesToSync); changedLocalElements.remove(getChangeMapKey(external)); } else { handleNoConflictMerge( externalExperiment, context, appAccount, fileMetadataUtil, external, filesToSync); } } for (Change local : changedLocalElements.values()) { handleLocalOnlyMerge(appAccount, fileMetadataUtil, local, filesToSync); } return filesToSync; } } private void handleLocalOnlyMerge( AppAccount appAccount, FileMetadataUtil fileMetadataUtil, Change local, FileSyncCollection filesToSync) { // If there is no conflict, we can just copy the change switch (local.getChangedElementType()) { case NOTE: Label label = getLabel(local.getChangedElementId()); if (label != null && label.getType() == ValueType.PICTURE) { filesToSync.addImageUpload(label.getPictureLabelValue().getFilePath()); } break; case TRIAL: if (getTrial(local.getChangedElementId()) != null) { filesToSync.addTrialUpload(local.getChangedElementId()); } break; case EXPERIMENT: if (!Strings.isNullOrEmpty(getImagePath())) { // Will be relative to Experiment. java.io.File overviewImage = new java.io.File( PictureUtils.getExperimentOverviewFullImagePath( appAccount, getPathRelativeToAccountRoot(getImagePath()))); filesToSync.addImageUpload( fileMetadataUtil.getRelativePathInExperiment(getExperimentId(), overviewImage)); } break; case CAPTION: //Nothing to do with a local-only caption merge. default: break; } } private void handleNoConflictMerge( Experiment externalExperiment, Context context, AppAccount appAccount, FileMetadataUtil fileMetadataUtil, Change external, FileSyncCollection filesToSync) { // If there is no conflict, we can just copy the change switch (external.getChangedElementType()) { case NOTE: copyNoteChange(externalExperiment, context, external, appAccount, filesToSync); break; case EXPERIMENT: copyExperimentChange(fileMetadataUtil, appAccount, externalExperiment, filesToSync); break; case TRIAL: copyTrialChange(externalExperiment, context, external, appAccount, filesToSync); break; case CAPTION: copyCaptionChange(externalExperiment, external); break; default: break; } } private void copyCaptionChange( Experiment externalExperiment, Change external) { String trialId = external.getChangedElementId(); Trial externalTrial = externalExperiment.getTrial(trialId); Trial localTrial = getTrial(trialId); if (localTrial != null) { // Since the local trial exists, set the caption. Caption caption = Caption.newBuilder().setText(externalTrial.getCaptionText()).build(); localTrial.setCaption(caption); } else { String labelId = external.getChangedElementId(); Label externalLabel = externalExperiment.getLabel(labelId); Label localLabel = getLabel(labelId); if (localLabel != null) { // Since the local trial exists, set the caption. Caption caption = Caption.newBuilder().setText(externalLabel.getCaptionText()).build(); localLabel.setCaption(caption); } } } private void copyTrialChange( Experiment externalExperiment, Context context, Change external, AppAccount appAccount, FileSyncCollection filesToSync) { String trialId = external.getChangedElementId(); Trial externalTrial = externalExperiment.getTrial(trialId); Trial localTrial = getTrial(trialId); if (externalTrial != null) { filesToSync.addTrialDownload(trialId); // If the external trial exists, we have to copy it to the local experiment. if (localTrial != null) { // If the local trial exists, this is an update, not an add. In either case, we don't want // to add our change to the changelog, as it has already been copied. updateTrialWithoutRecordingChange(externalTrial); } else { addTrialwithoutRecordingChange(externalTrial); } } else { // If the external trial does not exist, it was deleted, so we should delete from the local // experiment as well. Don't record the change to the changelog. if (localTrial != null) { deleteTrialWithoutRecordingChange(localTrial, context, appAccount); } } } private void copyExperimentChange( FileMetadataUtil fileMetadataUtil, AppAccount appAccount, Experiment externalExperiment, FileSyncCollection filesToSync) { // This copies changes to the overall experiment: the title and the image path. These can't // be added or deleted, really. They are just updated to/from blank strings. // Don't record the change to the changelog. setTitleWithoutRecordingChange(externalExperiment.getTitle()); setImagePathWithoutRecordingChange(externalExperiment.getImagePath()); if (!Strings.isNullOrEmpty(externalExperiment.getImagePath())) { java.io.File overviewImage = // will be relative to Experiment. new java.io.File( PictureUtils.getExperimentOverviewFullImagePath( appAccount, getPathRelativeToAccountRoot(externalExperiment.getImagePath()))); filesToSync.addImageDownload( fileMetadataUtil.getRelativePathInExperiment( externalExperiment.getExperimentId(), overviewImage)); } } private void copyNoteChange( Experiment externalExperiment, Context context, Change external, AppAccount appAccount, FileSyncCollection filesToSync) { // Copying notes is a little complicated, because they can be in either the root experiment, or // attached to a trial, so we have to hunt around for them. Label externalLabel = externalExperiment.getLabel(external.getChangedElementId()); if (externalLabel == null) { // This is a delete, so let's make sure the label gets deleted locally. // First, find out if this label already exists in a local trial. String trialId = getTrialIdForLabel(external.getChangedElementId()); if (trialId != null) { // Yep, it is in a local trial. So, get the trial, as well as the Local (not-deleted) label. Trial trial = getTrial(trialId); Label label = trial.getLabel(external.getChangedElementId()); // Delete the local label, without writing a change. Consumer<Context> assetDeleter = trial.deleteLabelAndReturnAssetDeleterWithoutRecordingChange(this, label, appAccount); try { assetDeleter.accept(context); } catch (Exception e) { if (Log.isLoggable(TAG, Log.ERROR)) { Log.e(TAG, "Asset Deletion Failed", e); } } } else { // Nope, it's not in a local trial. It must be in the local experiment, or this label was // created and deleted between merges from the external source. Label label = getLabel(external.getChangedElementId()); if (label != null) { // If the label does exist, delete it without writing the change to the changelog. If it // doesn't exist, that's fine, we wanted to delete it anyway. Consumer<Context> assetDeleter = deleteLabelAndReturnAssetDeleterWithoutRecordingChange(this, label, appAccount); try { assetDeleter.accept(context); } catch (Exception e) { if (Log.isLoggable(TAG, Log.ERROR)) { Log.e(TAG, "Asset Deletion Failed", e); } } } } } else { if (externalLabel.getType() == ValueType.PICTURE) { filesToSync.addImageDownload(externalLabel.getPictureLabelValue().getFilePath()); } // This is not a delete. The label still exists in the external experiment. // Find if the label is associated with a trial, externally. String trialId = externalExperiment.getTrialIdForLabel(external.getChangedElementId()); if (trialId != null) { // It's a trial label! So get the trial from the local experiment. Trial trial = getTrial(trialId); if (trial != null) { // If the trial exists in the local experiment, let's copy the label from external to // local, without copying the change. Label localLabel = getLabel(external.getChangedElementId()); // If the label exists locally, update it. if (localLabel != null) { trial.updateLabel(externalLabel); } else { // Otherwise, add it. trial.addLabel(externalLabel); } } else { // If the trial doesn't exist locally, let's copy the whole external trial. There's a // chance this will be reduntant, but it's unlikely that there will be many such copies, // so let's not worry about optimizing too much. Copying the trial brings al of the // associated labels with it. Trial externalTrial = externalExperiment.getTrial(trialId); addTrialwithoutRecordingChange(externalTrial); } } else { // This label is an experiment label, not trial. That's simpler. Label label = getLabel(external.getChangedElementId()); if (label != null) { // The label exists locally already, so let's update it. Don't record the change. updateLabel(externalLabel); } else { // The label does not exist locally. We have to add it, without recording a change. addLabel(externalLabel); } } } } // Handles merges where there are potential conflicts between local and external. private void handleConflictMerge( Experiment externalExperiment, Context context, AppAccount appAccount, FileMetadataUtil fileMetadataUtil, Change external, FileSyncCollection filesToSync) { switch (external.getChangedElementType()) { case NOTE: handleNoteConflict(externalExperiment, context, appAccount, external, filesToSync); break; case EXPERIMENT: handleExperimentConflict( fileMetadataUtil, appAccount, context, externalExperiment, filesToSync); break; case TRIAL: handleTrialConflict(externalExperiment, context, appAccount, external); break; case CAPTION: handleCaptionConflict(externalExperiment, external); break; default: break; } } private void handleTrialConflict( Experiment externalExperiment, Context context, AppAccount appAccount, Change external) { Trial externalTrial = externalExperiment.getTrial(external.getChangedElementId()); Trial localTrial = getTrial(external.getChangedElementId()); if (localTrial != null) { if (externalTrial == null) { // Delete the local trial deleteTrialWithoutRecordingChange(localTrial, context, appAccount); } else { if (localTrial != null) { // Both local and external have been edited. This is a title change. if (!localTrial.getTitle(context).equals(externalTrial.getTitle(context))) { localTrial.setTitle(context .getResources() .getString( R.string.experiment_title_concatenator, localTrial.getTitle(context), externalTrial.getTitle(context))); updateTrial(localTrial); } } } } } private void handleExperimentConflict( FileMetadataUtil fileMetadataUtil, AppAccount appAccount, Context context, Experiment externalExperiment, FileSyncCollection filesToSync) { // If the experiment is edited in both experiments, we will take the external imagepath, and // combine the titles (if the titles are different). // We won't get experiment delete changes, because those are reflected in the experimentlibrary, // and we won't get experiment add conflicts, because the IDs are UUIDs and won't conflict. if (!getTitle().equals(externalExperiment.getTitle()) && !getTitle().equals(getExperimentId())) { setTitleWithoutRecordingChange( context .getResources() .getString( R.string.experiment_title_concatenator, getTitle(), externalExperiment.getTitle())); } else if (!getTitle().equals(externalExperiment.getTitle())) { setTitleWithoutRecordingChange(externalExperiment.getTitle()); } setImagePathWithoutRecordingChange(externalExperiment.getImagePath()); if (!Strings.isNullOrEmpty(externalExperiment.getImagePath())) { // will be relative to Experiment. java.io.File overviewImage = new java.io.File( PictureUtils.getExperimentOverviewFullImagePath( appAccount, getPathRelativeToAccountRoot(externalExperiment.getImagePath()))); filesToSync.addImageDownload( fileMetadataUtil.getRelativePathInExperiment( externalExperiment.getExperimentId(), overviewImage)); } } private void handleNoteConflict( Experiment externalExperiment, Context context, AppAccount appAccount, Change external, FileSyncCollection filesToSync) { Label externalLabel = externalExperiment.getLabel(external.getChangedElementId()); Label localLabel = getLabel(external.getChangedElementId()); if (localLabel == null) { // This is a delete. When there has been a local delete and a remote change, keep the local // delete. Alternatively, the remote was also deleted, so we can keep that delete, too. } else { // This is a local edit. // Determine if it's a trial note or an experiment one. String trialId = getTrialIdForLabel(external.getChangedElementId()); if (trialId != null) { // It's a trial note Trial trial = getTrial(trialId); // Get the local trial. if (trial != null) { // The local trial exists. That means either a) the trial contains a different version // of the label, or b) the label has been deleted. If edited, we have to create a new // label and add it to the trial. If deleted, we have to delete the local trial. // This is a change that is not known to the change log so we DO have to write a change, // here. If the trial doesn't exist, it has been deleted itself, and we can move on. if (externalLabel != null) { trial.addLabel(this, Label.copyOf(externalLabel)); if (externalLabel.getType() == ValueType.PICTURE) { filesToSync.addImageUpload(externalLabel.getPictureLabelValue().getFilePath()); } if (localLabel != null) { if (localLabel.getType() == ValueType.PICTURE) { filesToSync.addImageDownload(localLabel.getPictureLabelValue().getFilePath()); } } } else { // The label was deleted externally Consumer<Context> assetDeleter = trial.deleteLabelAndReturnAssetDeleterWithoutRecordingChange( this, localLabel, appAccount); try { assetDeleter.accept(context); } catch (Exception e) { if (Log.isLoggable(TAG, Log.ERROR)) { Log.e(TAG, "Asset Deletion Failed", e); } } } } } else { // This is an experiment label. Either the experiment label has been deleted remotely, or // it has been edited. If it was deleted, we have to delete it locally, and if it's been // edited, we need to add a new label to the experiment. Once again, that ID is NOT known // to the change log, so we have to add this to the log. if (externalLabel != null) { addLabel(this, Label.copyOf(externalLabel)); if (externalLabel.getType() == ValueType.PICTURE) { filesToSync.addImageDownload(externalLabel.getPictureLabelValue().getFilePath()); } if (localLabel != null) { if (localLabel.getType() == ValueType.PICTURE) { filesToSync.addImageUpload(localLabel.getPictureLabelValue().getFilePath()); } } } else { // The label was deleted externally. Consumer<Context> assetDeleter = deleteLabelAndReturnAssetDeleterWithoutRecordingChange(this, localLabel, appAccount); try { assetDeleter.accept(context); } catch (Exception e) { if (Log.isLoggable(TAG, Log.ERROR)) { Log.e(TAG, "Asset Deletion Failed", e); } } } } } } private void handleCaptionConflict( Experiment externalExperiment, Change external) { Label externalLabel = externalExperiment.getLabel(external.getChangedElementId()); Label localLabel = getLabel(external.getChangedElementId()); if (localLabel != null) { Caption newCaption = Caption.newBuilder() .setText(localLabel.getCaptionText() + " " + externalLabel.getCaptionText()) .build(); localLabel.setCaption(newCaption); addChange(Change.newModifyTypeChange(ElementType.CAPTION, localLabel.getLabelId())); return; } Trial externalTrial = externalExperiment.getTrial(external.getChangedElementId()); Trial localTrial = getTrial(external.getChangedElementId()); if (localTrial != null) { Caption newCaption = Caption.newBuilder() .setText(localTrial.getCaptionText() + " " + externalTrial.getCaptionText()) .build(); localTrial.setCaption(newCaption); addChange(Change.newModifyTypeChange(ElementType.CAPTION, localTrial.getTrialId())); } } /** Returns a path that starts after the experiment id. Probably starts with "assets". */ private String getPathRelativeToExperiment(String path) { if (Strings.isNullOrEmpty(path)) { return path; } if (path.startsWith(EXPERIMENTS)) { List<String> splitList = Splitter.on('/').splitToList(path); StringBuilder experimentPath = new StringBuilder(); String delimiter = ""; for (int i = 2; i < splitList.size(); i++) { experimentPath.append(delimiter).append(splitList.get(i)); delimiter = "/"; } return experimentPath.toString(); } return path; } /** Returns a path that starts after the account id. Starts with "experiments/". */ public String getPathRelativeToAccountRoot(String path) { if (Strings.isNullOrEmpty(path)) { return path; } else if (path.startsWith(EXPERIMENTS)) { return path; } return PictureUtils.getExperimentOverviewRelativeImagePath(getExperimentId(), path); } }