package de.westnordost.streetcomplete.data.osm.upload; import android.content.SharedPreferences; import android.util.Log; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; import javax.inject.Inject; import de.westnordost.osmapi.changesets.ChangesetInfo; import de.westnordost.osmapi.changesets.ChangesetsDao; import de.westnordost.osmapi.map.data.Node; import de.westnordost.osmapi.map.data.OsmNode; import de.westnordost.osmapi.map.data.OsmRelation; import de.westnordost.osmapi.map.data.OsmWay; import de.westnordost.osmapi.map.data.Relation; import de.westnordost.osmapi.map.data.Way; import de.westnordost.streetcomplete.ApplicationConstants; import de.westnordost.streetcomplete.Prefs; import de.westnordost.streetcomplete.data.QuestStatus; import de.westnordost.streetcomplete.data.changesets.OpenChangesetInfo; import de.westnordost.streetcomplete.data.changesets.OpenChangesetsDao; import de.westnordost.streetcomplete.data.osm.OsmElementQuestType; import de.westnordost.streetcomplete.data.osm.OsmQuest; import de.westnordost.streetcomplete.data.osm.changes.StringMapChanges; import de.westnordost.streetcomplete.data.osm.persist.ElementGeometryDao; import de.westnordost.streetcomplete.data.osm.persist.MergedElementDao; import de.westnordost.streetcomplete.data.osm.persist.OsmQuestDao; import de.westnordost.streetcomplete.data.statistics.QuestStatisticsDao; import de.westnordost.osmapi.common.errors.OsmConflictException; import de.westnordost.osmapi.map.MapDataDao; import de.westnordost.osmapi.map.data.Element; public class OsmQuestChangesUpload { private static String TAG = "QuestUpload"; private final MapDataDao osmDao; private final OsmQuestDao questDB; private final MergedElementDao elementDB; private final ElementGeometryDao elementGeometryDB; private final QuestStatisticsDao statisticsDB; private final OpenChangesetsDao openChangesetsDB; private final ChangesetsDao changesetsDao; private final SharedPreferences prefs; // The cache is just here so that uploading 500 quests of same quest type does not result in 500 DB requests. private Map<String, Long> changesetIdsCache = new HashMap<>(); @Inject public OsmQuestChangesUpload( MapDataDao osmDao, OsmQuestDao questDB, MergedElementDao elementDB, ElementGeometryDao elementGeometryDB, QuestStatisticsDao statisticsDB, OpenChangesetsDao openChangesetsDB, ChangesetsDao changesetsDao, SharedPreferences prefs) { this.osmDao = osmDao; this.questDB = questDB; this.elementDB = elementDB; this.statisticsDB = statisticsDB; this.elementGeometryDB = elementGeometryDB; this.openChangesetsDB = openChangesetsDB; this.changesetsDao = changesetsDao; this.prefs = prefs; } public synchronized void upload(AtomicBoolean cancelState) { int commits = 0, obsolete = 0; changesetIdsCache = new HashMap<>(); for(OsmQuest quest : questDB.getAll(null, QuestStatus.ANSWERED)) { if(cancelState.get()) break; // break so that the unreferenced stuff is deleted still Element element = elementDB.get(quest.getElementType(), quest.getElementId()); long changesetId = getChangesetIdOrCreate(quest.getOsmElementQuestType()); if (uploadQuestChange(changesetId, quest, element, false, false)) { commits++; } else { obsolete++; } } elementGeometryDB.deleteUnreferenced(); elementDB.deleteUnreferenced(); String logMsg = "Committed " + commits + " changes"; if(obsolete > 0) { logMsg += " but dropped " + obsolete + " changes because there were conflicts"; } Log.i(TAG, logMsg); closeOpenChangesets(); } public synchronized void closeOpenChangesets() { long timePassed = System.currentTimeMillis() - openChangesetsDB.getLastQuestSolvedTime(); if(timePassed < OpenChangesetsDao.CLOSE_CHANGESETS_AFTER_INACTIVITY_OF) return; for (OpenChangesetInfo info : openChangesetsDB.getAll()) { try { osmDao.closeChangeset(info.changesetId); Log.d(TAG, "Closed changeset #" + info.changesetId + "."); } catch (OsmConflictException e) { Log.i(TAG, "Couldn't close changeset #" + info.changesetId + " because it has already been closed."); } finally { // done! openChangesetsDB.delete(info.questType); } } } private long getChangesetIdOrCreate(OsmElementQuestType questType) { String questTypeName = questType.getClass().getSimpleName(); Long cachedChangesetId = changesetIdsCache.get(questTypeName); if(cachedChangesetId != null) return cachedChangesetId; OpenChangesetInfo changesetInfo = openChangesetsDB.get(questTypeName); long result; if (changesetInfo != null && changesetInfo.changesetId != null) { result = changesetInfo.changesetId; } else { result = createChangeset(questType); } changesetIdsCache.put(questTypeName, result); return result; } private long createChangeset(OsmElementQuestType questType) { String questTypeName = questType.getClass().getSimpleName(); long changesetId = osmDao.openChangeset(createChangesetTags(questType)); openChangesetsDB.replace(questTypeName, changesetId); return changesetId; } boolean uploadQuestChange(long changesetId, OsmQuest quest, Element element, boolean alreadyHandlingElementConflict, boolean alreadyHandlingChangesetConflict) { Element elementWithChangesApplied = changesApplied(element, quest); if(elementWithChangesApplied == null) { questDB.delete(quest.getId()); return false; } try { osmDao.uploadChanges( changesetId, Collections.singleton(elementWithChangesApplied), null); /* A diff handler is not (yet) necessary: The local copy of an OSM element is updated * automatically on conflict. A diff handler would be necessary if elements could be * created or deleted through quests because IDs of elements would then change. */ } catch(OsmConflictException e) { return handleConflict(changesetId, quest, element, alreadyHandlingElementConflict, alreadyHandlingChangesetConflict, e); } questDB.delete(quest.getId()); statisticsDB.addOne(quest.getType().getClass().getSimpleName()); return true; } private Element changesApplied(Element element, OsmQuest quest) { // The element can be null if it has been deleted in the meantime (outside this app usually) if(element == null) { Log.v(TAG, "Dropping quest " + getQuestStringForLog(quest) + " because the associated element has already been deleted"); return null; } Element copy = copyElement(element); StringMapChanges changes = quest.getChanges(); if(changes.hasConflictsTo(copy.getTags())) { Log.v(TAG, "Dropping quest " + getQuestStringForLog(quest) + " because there has been a conflict while applying the changes"); return null; } changes.applyTo(copy.getTags()); return copy; } private boolean handleConflict(long changesetId, OsmQuest quest, Element element, boolean alreadyHandlingElementConflict, boolean alreadyHandlingChangesetConflict, OsmConflictException e) { /* Conflict can either happen because of the changeset or because of the element(s) uploaded. Let's find out. */ ChangesetInfo changesetInfo = changesetsDao.get(changesetId); Long myUserId = prefs.getLong(Prefs.OSM_USER_ID, -1); // can happen if the user changes his OAuth identity in the settings while having an open changeset boolean changesetWasOpenedByDifferentUser = myUserId == -1 || changesetInfo.user == null || changesetInfo.user.id != myUserId; if(!changesetInfo.isOpen || changesetWasOpenedByDifferentUser) { // safeguard against stack overflow in case of programming error if(alreadyHandlingChangesetConflict) { throw new RuntimeException("OSM server continues to report a changeset " + "conflict for changeset id " + changesetId, e); } return handleChangesetConflict(quest, element, alreadyHandlingElementConflict); } else { // safeguard against stack overflow in case of programming error if(alreadyHandlingElementConflict) { throw new RuntimeException("OSM server continues to report an element " + "conflict on uploading the changes for the quest " + getQuestStringForLog(quest) + ". The local version is " + element.getVersion(), e); } return handleElementConflict(changesetId, quest, alreadyHandlingChangesetConflict); } } private boolean handleChangesetConflict(OsmQuest quest, Element element, boolean alreadyHandlingElementConflict) { OsmElementQuestType questType = quest.getOsmElementQuestType(); long changesetId = createChangeset(questType); String questTypeName = questType.getClass().getSimpleName(); changesetIdsCache.put(questTypeName, changesetId); return uploadQuestChange(changesetId, quest, element, alreadyHandlingElementConflict, true); } private boolean handleElementConflict(long changesetId, OsmQuest quest, boolean alreadyHandlingChangesetConflict) { Element element = updateElementFromServer(quest.getElementType(), quest.getElementId()); return uploadQuestChange(changesetId, quest, element, true, alreadyHandlingChangesetConflict); } private static String getQuestStringForLog(OsmQuest quest) { return quest.getType().getClass().getSimpleName() + " for " + quest.getElementType().name().toLowerCase() + " #" + quest.getElementId(); } private static Element copyElement(Element e) { if(e == null) return null; Map<String,String> tagsCopy = new HashMap<>(); if(e.getTags() != null) tagsCopy.putAll(e.getTags()); if(e instanceof Node) { return new OsmNode(e.getId(), e.getVersion(), ((Node)e).getPosition(), tagsCopy); } if(e instanceof Way) { return new OsmWay(e.getId(), e.getVersion(), new ArrayList<>(((Way)e).getNodeIds()), tagsCopy); } if(e instanceof Relation) { return new OsmRelation(e.getId(), e.getVersion(), new ArrayList<>(((Relation)e).getMembers()), tagsCopy); } return null; } private Element updateElementFromServer(Element.Type elementType, long id) { Element element = null; switch(elementType) { case NODE: element = osmDao.getNode(id); break; case WAY: element = osmDao.getWay(id); break; case RELATION: element = osmDao.getRelation(id); break; } if(element != null) { elementDB.put(element); } else { elementDB.delete(elementType, id); } return element; } private Map<String,String> createChangesetTags(OsmElementQuestType questType) { Map<String,String> changesetTags = new HashMap<>(); String commitMessage = questType.getCommitMessage(); if(commitMessage != null) { changesetTags.put("comment", commitMessage); } changesetTags.put("created_by", ApplicationConstants.USER_AGENT); String questTypeName = questType.getClass().getSimpleName(); changesetTags.put(ApplicationConstants.QUESTTYPE_TAG_KEY, questTypeName); changesetTags.put("source", "survey"); return changesetTags; } }