<NME> CGeoMap.java
<BEF> package cgeo.geocaching.maps;

import cgeo.geocaching.CacheListActivity;
import cgeo.geocaching.CachePopup;
import cgeo.geocaching.CompassActivity;
import cgeo.geocaching.R;
import cgeo.geocaching.SearchResult;
import cgeo.geocaching.WaypointPopup;
import cgeo.geocaching.activity.ActivityMixin;
import cgeo.geocaching.connector.ConnectorFactory;
import cgeo.geocaching.databinding.MapGoogleBinding;
import cgeo.geocaching.downloader.DownloaderUtils;
import cgeo.geocaching.enumerations.CoordinatesType;
import cgeo.geocaching.enumerations.LoadFlags;
import cgeo.geocaching.enumerations.LoadFlags.RemoveFlag;
import cgeo.geocaching.enumerations.WaypointType;
import cgeo.geocaching.filters.core.GeocacheFilterContext;
import cgeo.geocaching.location.Geopoint;
import cgeo.geocaching.location.ProximityNotification;
import cgeo.geocaching.location.Viewport;
import cgeo.geocaching.location.WaypointDistanceInfo;
import cgeo.geocaching.log.LoggingUI;
import cgeo.geocaching.maps.google.v2.GoogleGeoPoint;
import cgeo.geocaching.maps.google.v2.GoogleMapProvider;
import cgeo.geocaching.maps.google.v2.GooglePositionAndHistory;
import cgeo.geocaching.maps.interfaces.CachesOverlayItemImpl;
import cgeo.geocaching.maps.interfaces.GeneralOverlay;
import cgeo.geocaching.maps.interfaces.GeoPointImpl;
import cgeo.geocaching.maps.interfaces.MapActivityImpl;
import cgeo.geocaching.maps.interfaces.MapControllerImpl;
import cgeo.geocaching.maps.interfaces.MapItemFactory;
import cgeo.geocaching.maps.interfaces.MapProvider;
import cgeo.geocaching.maps.interfaces.MapSource;
import cgeo.geocaching.maps.interfaces.MapViewImpl;
import cgeo.geocaching.maps.interfaces.OnCacheTapListener;
import cgeo.geocaching.maps.interfaces.OnMapDragListener;
import cgeo.geocaching.maps.mapsforge.MapsforgeMapProvider;
import cgeo.geocaching.maps.mapsforge.v6.NewMap;
import cgeo.geocaching.maps.routing.RoutingMode;
import cgeo.geocaching.models.Geocache;
import cgeo.geocaching.models.IWaypoint;
import cgeo.geocaching.models.IndividualRoute;
import cgeo.geocaching.models.Route;
import cgeo.geocaching.models.RouteItem;
import cgeo.geocaching.models.TrailHistoryElement;
import cgeo.geocaching.models.Waypoint;
import cgeo.geocaching.network.AndroidBeam;
import cgeo.geocaching.permission.PermissionHandler;
import cgeo.geocaching.permission.PermissionRequestContext;
import cgeo.geocaching.permission.RestartLocationPermissionGrantedCallback;
import cgeo.geocaching.sensors.GeoData;
import cgeo.geocaching.sensors.GeoDirHandler;
import cgeo.geocaching.sensors.Sensors;
import cgeo.geocaching.service.CacheDownloaderService;
import cgeo.geocaching.service.GeocacheChangedBroadcastReceiver;
import cgeo.geocaching.settings.Settings;
import cgeo.geocaching.storage.DataStore;
import cgeo.geocaching.ui.ViewUtils;
import cgeo.geocaching.ui.WeakReferenceHandler;
import cgeo.geocaching.utils.AndroidRxUtils;
import cgeo.geocaching.utils.AngleUtils;
import cgeo.geocaching.utils.ApplicationSettings;
import cgeo.geocaching.utils.CompactIconModeUtils;
import cgeo.geocaching.utils.FilterUtils;
import cgeo.geocaching.utils.Formatter;
import cgeo.geocaching.utils.HistoryTrackUtils;
import cgeo.geocaching.utils.LeastRecentlyUsedSet;
import cgeo.geocaching.utils.Log;
import cgeo.geocaching.utils.MapMarkerUtils;
import static cgeo.geocaching.location.Viewport.containingGCliveCaches;

import android.content.Context;
import android.content.res.Resources;
import android.graphics.Point;
import android.location.Location;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup.LayoutParams;
import android.widget.CheckBox;
import android.widget.ImageSwitcher;
import android.widget.ImageView;
import android.widget.ImageView.ScaleType;
import android.widget.ProgressBar;
import android.widget.Toast;
import android.widget.ViewSwitcher.ViewFactory;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;

import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

import com.google.android.gms.maps.model.LatLng;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;

/**
 * Class representing the Map in c:geo
 */
public class CGeoMap extends AbstractMap implements ViewFactory, OnCacheTapListener {

    /**
     * max. number of caches displayed in the Live Map
     */
    public static final int MAX_CACHES = 500;
    /**
     * initialization with an empty subscription to make static code analysis tools more happy
     */
    private final CompositeDisposable resumeDisposables = new CompositeDisposable();

    /**
     * Handler Messages
     */
    private static final int HIDE_PROGRESS = 0;
    private static final int SHOW_PROGRESS = 1;
    private static final int UPDATE_TITLE = 0;
    private static final int INVALIDATE_MAP = 1;

    private static final String BUNDLE_MAP_SOURCE = "mapSource";
    private static final String BUNDLE_MAP_STATE = "mapState";
    private static final String BUNDLE_LIVE_ENABLED = "liveEnabled";
    private static final String BUNDLE_PROXIMITY_NOTIFICATION = "proximityNotification";
    private static final String BUNDLE_ROUTE = "route";
    private static final String BUNDLE_FILTERCONTEXT = "filterContext";

    // Those are initialized in onCreate() and will never be null afterwards
    private Resources res;
    private AppCompatActivity activity;
    private MapItemFactory mapItemFactory;
    private final LeastRecentlyUsedSet<Geocache> caches = new LeastRecentlyUsedSet<>(MAX_CACHES + DataStore.getAllCachesCount());
    private MapSource mapSource;


    private final GeoDirHandler geoDirUpdate = new UpdateLoc(this);
    private ProximityNotification proximityNotification;
    private IndividualRoute individualRoute = null;

    // status data
    /**
     * Last search result used for displaying header
     */
    private SearchResult lastSearchResult = null;
    private Viewport lastViewport = null;
    // map status data
    private static boolean followMyLocation = true;
    // threads
    private Disposable loadTimer;
    /**
     * Time of last {@link LoadRunnable} run
     */
    private volatile long loadThreadRun = 0L;
    //Interthread communication flag
    private volatile boolean downloaded = false;

    /**
     * Count of caches currently visible
     */
    private int cachesCnt = 0;
    /**
     * List of waypoints in the viewport
     */
    private final LeastRecentlyUsedSet<Waypoint> waypoints = new LeastRecentlyUsedSet<>(MAX_CACHES);
    private ProgressBar spinner;

    // views
    private CheckBox myLocSwitch = null;
    // other things
    private boolean markersInvalidated = false; // previous state for loadTimer
    private boolean centered = false; // if map is already centered
    private boolean alreadyCentered = false; // -""- for setting my location
    private static final Set<String> dirtyCaches = new HashSet<>();

    /**
     * if live map is enabled, this is the minimum zoom level, independent of the stored setting
     */
    private static final int MIN_LIVEMAP_ZOOM = 12;
    // Thread pooling
    private static final BlockingQueue<Runnable> displayQueue = new ArrayBlockingQueue<>(1);
    private static final ThreadPoolExecutor displayExecutor = new ThreadPoolExecutor(1, 1, 60, TimeUnit.SECONDS, displayQueue, new ThreadPoolExecutor.DiscardOldestPolicy());
    private static final BlockingQueue<Runnable> downloadQueue = new ArrayBlockingQueue<>(1);
    private static final ThreadPoolExecutor downloadExecutor = new ThreadPoolExecutor(1, 1, 60, TimeUnit.SECONDS, downloadQueue, new ThreadPoolExecutor.DiscardOldestPolicy());
    private static final BlockingQueue<Runnable> loadQueue = new ArrayBlockingQueue<>(1);
    private static final ThreadPoolExecutor loadExecutor = new ThreadPoolExecutor(1, 1, 60, TimeUnit.SECONDS, loadQueue, new ThreadPoolExecutor.DiscardOldestPolicy());
    private MapOptions mapOptions;

    // handlers

    /**
     * Updates the titles
     */

    private static final class DisplayHandler extends WeakReferenceHandler<CGeoMap> {

        DisplayHandler(@NonNull final CGeoMap map) {
            super(map);
        }

        @Override
        public void handleMessage(final Message msg) {
            final CGeoMap map = getReference();
            if (map == null) {
                return;
            }

            final int what = msg.what;
            switch (what) {
                case UPDATE_TITLE:
                    map.setTitle();
                    map.setSubtitle();
                    break;

                case INVALIDATE_MAP:
                    map.mapView.repaintRequired(null);
                    break;

                default:
                    break;
            }
        }

    }

    private final Handler displayHandler = new DisplayHandler(this);

    private void setTitle() {
        getActionBar().setTitle(MapUtils.getColoredValue(calculateTitle()));
    }

    private String calculateTitle() {
        if (mapOptions.isLiveEnabled) {
            return res.getString(R.string.map_live);
        }
        final Geocache cache = getSingleModeCache();
        if (cache != null) {
            return cache.getName();
        }
        return StringUtils.defaultIfEmpty(mapOptions.title, res.getString(R.string.map_offline));
    }

    @Nullable
    public Geocache getSingleModeCache() {
        // use a copy of the caches list to avoid concurrent modification
        if (mapOptions.mapMode == MapMode.SINGLE) {
            for (final Geocache geocache : caches.getAsList()) {
                if (geocache.getGeocode().equals(mapOptions.geocode)) {
                    return geocache;
                }
            }
        }
        return null;
    }

    private void setSubtitle() {
        final String subtitle = calculateSubtitle();
        if (StringUtils.isEmpty(subtitle)) {
            return;
        }
        getActionBar().setSubtitle(MapUtils.getColoredValue(subtitle));
    }

    private String calculateSubtitle() {
        // count caches in the sub title
        countVisibleCaches();
        if (!mapOptions.isLiveEnabled && mapOptions.mapMode == MapMode.SINGLE) {
            final Geocache cache = getSingleModeCache();
            if (cache != null) {
                return Formatter.formatMapSubtitle(cache);
            }
            return "";
        }
        final StringBuilder subtitle = new StringBuilder();
        final int totalCount = caches.size();

        if (cachesCnt != totalCount && Settings.isDebug()) {
            subtitle.append(cachesCnt).append('/').append(res.getQuantityString(R.plurals.cache_counts, totalCount, totalCount));
        } else {
            subtitle.append(res.getQuantityString(R.plurals.cache_counts, cachesCnt, cachesCnt));
        }

        if (Settings.isDebug() && lastSearchResult != null && StringUtils.isNotBlank(lastSearchResult.getUrl())) {
            subtitle.append(" [").append(lastSearchResult.getUrl()).append(']');
        }

        return subtitle.toString();
    }

    @NonNull
    private ActionBar getActionBar() {
        final ActionBar actionBar = activity.getSupportActionBar();
        assert actionBar != null;
        return actionBar;
    }

    /**
     * Updates the progress.
     */
    private static final class ShowProgressHandler extends Handler {
        private int counter = 0;

        @NonNull
        private final WeakReference<CGeoMap> mapRef;

        ShowProgressHandler(@NonNull final CGeoMap map) {
            this.mapRef = new WeakReference<>(map);
        }

        @Override
        public void handleMessage(final Message msg) {
            final int what = msg.what;

            if (what == SHOW_PROGRESS) {
                showProgress(true);
                counter++;
            } else if (what == HIDE_PROGRESS && --counter == 0) {
                showProgress(false);
            }
        }

        private void showProgress(final boolean show) {
            final CGeoMap map = mapRef.get();
            if (map != null && map.spinner != null) {
                map.spinner.setVisibility(show ? View.VISIBLE : View.GONE);
            }
        }

    }

    private final Handler showProgressHandler = new ShowProgressHandler(this);

    /* Current source id */
    private int currentSourceId;


    public CGeoMap(@NonNull final MapActivityImpl activity) {
        super(activity);
    }

    protected void countVisibleCaches() {
        final Viewport viewport = mapView.getViewport();
        cachesCnt = viewport == null ? caches.size() : viewport.count(caches.getAsList());
    }

    @Override
    public void onSaveInstanceState(@NonNull final Bundle outState) {
        mapView.onSaveInstanceState(outState);
        outState.putInt(BUNDLE_MAP_SOURCE, currentSourceId);
        outState.putParcelable(BUNDLE_MAP_STATE, currentMapState());
        outState.putBoolean(BUNDLE_LIVE_ENABLED, mapOptions.isLiveEnabled);
        if (proximityNotification != null) {
            outState.putParcelable(BUNDLE_PROXIMITY_NOTIFICATION, proximityNotification);
        }
        if (individualRoute != null) {
            outState.putParcelable(BUNDLE_ROUTE, individualRoute);
        }
        if (mapOptions.filterContext != null) {
            outState.putParcelable(BUNDLE_FILTERCONTEXT, mapOptions.filterContext);
        }
    }

    @Override
    public void onLowMemory() {
        mapView.onLowMemory();
    }

    protected Geopoint getIntentCoords() {
        if (mapOptions.coords != null) {
            return mapOptions.coords;
        } else if (mapOptions.geocode != null) {
            final Viewport bounds = DataStore.getBounds(mapOptions.geocode, false);
            if (bounds != null) {
                return bounds.center;
            }
        }
        return null;
    }

    protected Viewport getIntentViewport() {
        if (mapOptions.coords != null) {
            return new Viewport(mapOptions.coords);
        } else if (mapOptions.geocode != null) {
            return DataStore.getBounds(mapOptions.geocode, Settings.getZoomIncludingWaypoints());
        }
        return null;
    }

    protected void initializeMap(final ArrayList<TrailHistoryElement> trailHistory) {

        mapView.setMapSource();
        mapView.setBuiltInZoomControls(true);
        mapView.displayZoomControls(true);
        mapView.setOnDragListener(new MapDragListener(this));

        // initialize overlays
        mapView.clearOverlays();

        mapView.setOnTapListener(this);

        final Geopoint savedCoords = mapOptions.coords;  // remember for centerMap
        if (mapOptions.mapMode == MapMode.LIVE && savedCoords != null) {
            mapOptions.coords = null;   // no direction line, even if enabled in settings
            followMyLocation = false;   // do not center on GPS position, even if in LIVE mode
        }

        overlayPositionAndScale = mapView.createAddPositionAndScaleOverlay(activity.findViewById(R.id.distance1).getRootView(), getIntentCoords(), mapOptions.geocode);
        if (trailHistory != null) {
            overlayPositionAndScale.setHistory(trailHistory);
        }
        if (null == individualRoute) {
            individualRoute = new IndividualRoute(this::setNavigationTargetFromIndividualRoute);
            reloadIndividualRoute();
        } else {
            individualRoute.updateRoute(overlayPositionAndScale);
        }

        CompactIconModeUtils.setCompactIconModeThreshold(getResources());

        // prepare circular progress spinner
        spinner = (ProgressBar) activity.findViewById(R.id.map_progressbar);
        if (null != spinner) {
            spinner.setVisibility(View.GONE);
        }

        mapView.repaintRequired(null);

        final Viewport viewport = getIntentViewport();
        if (mapOptions.geocode != null && viewport != null && !viewport.topRight.equals(viewport.bottomLeft)) {
            mapView.zoomToBounds(viewport, mapItemFactory.getGeoPointBase(viewport.center));
        } else {
            setZoom(Settings.getMapZoom(mapOptions.mapMode));
            mapView.getMapController().setCenter(Settings.getMapCenter());
        }

        if (mapOptions.mapState == null) {
            followMyLocation = followMyLocation && (mapOptions.mapMode == MapMode.LIVE);
            mapView.setCircles(Settings.isShowCircles());
        } else {
            followMyLocation = mapOptions.mapState.followsMyLocation();
            if (mapView.getCircles() != mapOptions.mapState.showsCircles()) {
                mapView.setCircles(mapOptions.mapState.showsCircles());
            }
        }
        if (mapOptions.geocode != null || mapOptions.searchResult != null || savedCoords != null || mapOptions.mapState != null) {
            centerMap(mapOptions.geocode, mapOptions.searchResult, savedCoords, mapOptions.mapState);
        }


        if (StringUtils.isNotEmpty(mapOptions.geocode) && mapOptions.mapMode != MapMode.COORDS) {
            targetGeocode = mapOptions.geocode;
            final Geocache temp = getCurrentTargetCache();
            if (temp != null) {
                lastNavTarget = temp.getCoords();
            }
        }

    }

    @Override
    public void onCreate(final Bundle savedInstanceState) {
        ApplicationSettings.setLocale(this.getActivity());
        super.onCreate(savedInstanceState);

        // class init
        res = this.getResources();
        activity = this.getActivity();

        MapsforgeMapProvider.getInstance().updateOfflineMaps();

        // hard coded for now to mitigate #9619 - GoogleMaps is the only map type left supported by CGeoMap
        // final MapProvider mapProvider = Settings.getMapProvider();
        final MapProvider mapProvider = GoogleMapProvider.getInstance();
        mapItemFactory = mapProvider.getMapItemFactory();

        // Get parameters from the intent
        final Bundle extras = activity.getIntent().getExtras();
        mapOptions = new MapOptions(activity, extras);

        final ArrayList<TrailHistoryElement> trailHistory = null;

        // Get fresh map information from the bundle if any
        if (savedInstanceState != null) {
            currentSourceId = savedInstanceState.getInt(BUNDLE_MAP_SOURCE, Settings.getMapSource().getNumericalId());
            mapOptions.mapState = savedInstanceState.getParcelable(BUNDLE_MAP_STATE);
            mapOptions.isLiveEnabled = savedInstanceState.getBoolean(BUNDLE_LIVE_ENABLED, false);
            mapOptions.filterContext = savedInstanceState.getParcelable(BUNDLE_FILTERCONTEXT);
            proximityNotification = savedInstanceState.getParcelable(BUNDLE_PROXIMITY_NOTIFICATION);
            individualRoute = savedInstanceState.getParcelable(BUNDLE_ROUTE);
        } else {
            currentSourceId = Settings.getMapSource().getNumericalId();
            proximityNotification = Settings.isGeneralProximityNotificationActive() ? new ProximityNotification(true, false) : null;
            individualRoute = null;
        }
        if (null != proximityNotification) {
            proximityNotification.setTextNotifications(activity);
        }
        if (mapOptions.mapState != null) {
            this.targetGeocode = mapOptions.mapState.getTargetGeocode();
            this.lastNavTarget = mapOptions.mapState.getLastNavTarget();
        }

        // adding the bottom navigation component is handled by {@link AbstractBottomNavigationActivity#setContentView}
        activity.setContentView(MapGoogleBinding.inflate(activity.getLayoutInflater()).getRoot());

        // map settings popup
        activity.findViewById(R.id.map_settings_popup).setOnClickListener(v ->
                MapSettingsUtils.showSettingsPopup(getActivity(), individualRoute, this::refreshMapData, this::routingModeChanged, this::compactIconModeChanged, mapOptions.filterContext));

        // individual route popup
        activity.findViewById(R.id.map_individualroute_popup).setOnClickListener(v ->
                mapActivity.getRouteTrackUtils().showPopup(individualRoute, this::setTarget));

        // If recreating from an obsolete map source, we may need a restart
        if (changeMapSource(Settings.getMapSource())) {
            return;
        }

        ActivityMixin.onCreate(activity, true);

        // set layout
        ActivityMixin.setTheme(activity);

        setTitle();

        // initialize map
        mapView = (MapViewImpl) activity.findViewById(mapProvider.getMapViewId());

        // only add cache if it is currently visible
        activity.getLifecycle().addObserver(new GeocacheChangedBroadcastReceiver(mapView.getContext()) {
            @Override
            protected void onReceive(final Context context, final String geocode) {
                final Geocache cache = DataStore.loadCache(geocode, LoadFlags.LOAD_CACHE_OR_DB);

                // only add cache if it is currently visible
                if (caches.remove(cache)) {
                    caches.add(cache);
                    displayExecutor.execute(new DisplayRunnable(CGeoMap.this));
                }
            }
        });

        // added keys must be removed before passing bundle to google's mapView, otherwise this will be thrown:
        // ClassNotFoundException: Didn't find class "cgeo.geocaching.sensors.GeoData"
        // solution from http://stackoverflow.com/questions/13900322/badparcelableexception-in-google-maps-code/15572337#15572337
        if (savedInstanceState != null) {
            savedInstanceState.remove(BUNDLE_MAP_SOURCE);
            savedInstanceState.remove(BUNDLE_MAP_STATE);
        }
        mapView.onCreate(savedInstanceState);
        mapView.setListId(mapOptions.fromList);

        mapView.onMapReady(() -> initializeMap(trailHistory));

        FilterUtils.initializeFilterBar(activity, mapActivity);
        MapUtils.updateFilterBar(activity, mapOptions.filterContext);

        AndroidBeam.disable(activity);

        MapUtils.showMapOneTimeMessages(activity, mapOptions.mapMode);

        MapsforgeMapProvider.getInstance().updateOfflineMaps();
    }

    public void toggleRouteItem(final IWaypoint item) {
        if (item == null || StringUtils.isEmpty(item.getGeocode())) {
            return;
        }
        if (individualRoute == null) {
            individualRoute = new IndividualRoute(this::setNavigationTargetFromIndividualRoute);
        }
        individualRoute.toggleItem(this.mapView.getContext(), new RouteItem(item), overlayPositionAndScale);
        mapActivity.getRouteTrackUtils().updateRouteTrackButtonVisibility(activity.findViewById(R.id.container_individualroute), individualRoute, mapActivity.getTracks());
        overlayPositionAndScale.repaintRequired();
    }

    private void setNavigationTargetFromIndividualRoute(@Nullable final Geopoint geopoint, final String geocode) {
        if (geopoint != null) {
            setTarget(geopoint, geocode);
        }
    }

    private void initMyLocationSwitchButton(final CheckBox locSwitch) {
        myLocSwitch = locSwitch;
        /* TODO: Switch back to ImageSwitcher for animations?
        myLocSwitch.setFactory(this);
        myLocSwitch.setInAnimation(activity, android.R.anim.fade_in);
        myLocSwitch.setOutAnimation(activity, android.R.anim.fade_out); */
        myLocSwitch.setOnClickListener(new MyLocationListener(this));
        switchMyLocationButton();
    }

    /**
     * Set the zoom of the map. The zoom is restricted to a certain minimum in case of live map.
     */
    private void setZoom(final int zoom) {
        mapView.getMapController().setZoom(mapOptions.isLiveEnabled ? Math.max(zoom, MIN_LIVEMAP_ZOOM) : zoom);
    }

    private void resumeTrack(final String key, final boolean preventReloading) {
        final Tracks tracks = mapActivity.getTracks();
        if (null == tracks && !preventReloading) {
            mapActivity.getRouteTrackUtils().reloadTrack(tracks.getTrackfile(key), this::setTrack);
        } else if (null != tracks && null != overlayPositionAndScale && overlayPositionAndScale instanceof GooglePositionAndHistory) {
            ((GooglePositionAndHistory) overlayPositionAndScale).updateRoute(key, tracks.getRoute(key));
        }
    }


    @Override
    public void onResume() {
        super.onResume();

        // resume location access
        PermissionHandler.executeIfLocationPermissionGranted(this.activity,
                new RestartLocationPermissionGrantedCallback(PermissionRequestContext.CGeoMap) {

                    @Override
                    public void executeAfter() {
                        mapView.onResume();
                        resumeDisposables.addAll(geoDirUpdate.start(GeoDirHandler.UPDATE_GEODIR), startTimer());
                    }
                });

        final List<String> toRefresh;
        synchronized (dirtyCaches) {
            toRefresh = new ArrayList<>(dirtyCaches);
            dirtyCaches.clear();
        }

        if (!toRefresh.isEmpty()) {
            AndroidRxUtils.computationScheduler.scheduleDirect(() -> {
                for (final String geocode : toRefresh) {
                    final Geocache cache = DataStore.loadCache(geocode, LoadFlags.LOAD_WAYPOINTS);
                    if (cache != null) {
                        // new collection type needs to remove first
                        caches.remove(cache);
                        // re-add to update the freshness
                        caches.add(cache);
                    }
                }
                displayExecutor.execute(new DisplayRunnable(CGeoMap.this));
            });
        }
        MapUtils.updateFilterBar(activity, mapOptions.filterContext);
        final Tracks tracks = mapActivity.getTracks();
        if (tracks != null) {
            tracks.resumeAllTracks(this::resumeTrack);
        }
    }

    @Override
    public void onPause() {
        resumeDisposables.clear();
        savePrefs();

        mapView.destroyDrawingCache();

        // do not clear cached items - BitmapDescriptoCache needs same Drawables, not new ones created
        // after cache is cleared, or TODO implement ComparableDrawable?
        MapMarkerUtils.clearCachedItems();

        super.onPause();
        mapView.onPause();
    }

    @Override
    public void onDestroy() {
        super.onDestroy();
        if (mapView != null) { // avoid occasionally NPE
            mapView.onDestroy();
        }
    }

    @Override
    public boolean onCreateOptionsMenu(@NonNull final Menu menu) {
        super.onCreateOptionsMenu(menu);

        MapProviderFactory.addMapviewMenuItems(activity, menu);

        /* if we have an Actionbar find the my position toggle */
        initMyLocationSwitchButton(MapProviderFactory.createLocSwitchMenuItem(activity, menu));
        FilterUtils.initializeFilterMenu(activity, mapActivity);

        return true;
    }

    @Override
    public boolean onPrepareOptionsMenu(@NonNull final Menu menu) {
        super.onPrepareOptionsMenu(menu);
        if (mapOptions != null) {
            ViewUtils.extendMenuActionBarDisplayItemCount(getActivity(), menu);
        }
        for (final MapSource mapSource : MapProviderFactory.getMapSources()) {
            final MenuItem menuItem = menu.findItem(mapSource.getNumericalId());
            if (menuItem != null) {
                menuItem.setVisible(mapSource.isAvailable());
            }
        }

        try {
            final MenuItem itemMapLive = menu.findItem(R.id.menu_map_live);
            if (mapOptions.isLiveEnabled) {
                itemMapLive.setIcon(R.drawable.ic_menu_sync_enabled);
                itemMapLive.setTitle(res.getString(R.string.map_live_disable));
            } else {
                itemMapLive.setIcon(R.drawable.ic_menu_sync_disabled);
                itemMapLive.setTitle(res.getString(R.string.map_live_enable));
            }
            itemMapLive.setVisible(mapOptions.coords == null || mapOptions.mapMode == MapMode.LIVE);

            menu.findItem(R.id.menu_store_caches).setVisible(!isLoading() && CollectionUtils.isNotEmpty(getGeocodesForCachesInViewport()));

            menu.findItem(R.id.menu_theme_mode).setVisible(!GoogleMapProvider.isSatelliteSource(Settings.getMapSource()));

            menu.findItem(R.id.menu_as_list).setVisible(!isLoading() && caches.size() > 1);

            menu.findItem(R.id.menu_hint).setVisible(mapOptions.mapMode == MapMode.SINGLE);
            menu.findItem(R.id.menu_compass).setVisible(mapOptions.mapMode == MapMode.SINGLE);
            if (mapOptions.mapMode == MapMode.SINGLE) {
                LoggingUI.onPrepareOptionsMenu(menu, getSingleModeCache());
            }
            HistoryTrackUtils.onPrepareOptionsMenu(menu);
            // TrackUtils.onPrepareOptionsMenu is in maps/google/v2/GoogleMapActivity only
        } catch (final RuntimeException e) {
            Log.e("CGeoMap.onPrepareOptionsMenu", e);
        }

        return true;
    }

    @Override
    public boolean onOptionsItemSelected(@NonNull final MenuItem item) {
        final int id = item.getItemId();
        if (id == R.id.menu_map_rotation_off) {
            setMapRotation(item, Settings.MAPROTATION_OFF);
        } else if (id == R.id.menu_map_rotation_manual) {
            setMapRotation(item, Settings.MAPROTATION_MANUAL);
        } else if (id == R.id.menu_map_rotation_auto) {
            setMapRotation(item, Settings.MAPROTATION_AUTO);
        } else if (id == R.id.menu_filter) {
            getMapActivity().showFilterMenu();
        } else if (id == R.id.menu_map_live) {
            mapOptions.isLiveEnabled = !mapOptions.isLiveEnabled;
            if (mapOptions.mapMode == MapMode.LIVE) {
                Settings.setLiveMap(mapOptions.isLiveEnabled);
            }
            if (mapOptions.isLiveEnabled) {
                mapOptions.isStoredEnabled = true;
                mapOptions.filterContext = new GeocacheFilterContext(GeocacheFilterContext.FilterType.LIVE);
                refreshMapData(false);
            }
            markersInvalidated = true;
            lastSearchResult = null;
            mapOptions.searchResult = null;
            ActivityMixin.invalidateOptionsMenu(activity);
            if (mapOptions.mapMode == MapMode.SINGLE) {
                setTarget(mapOptions.coords, mapOptions.geocode);
            }
            mapOptions.mapMode = MapMode.LIVE;
            mapOptions.title = StringUtils.EMPTY;
            updateMapTitle();
        } else if (id == R.id.menu_store_caches) {
            CacheDownloaderService.downloadCaches(activity, getGeocodesForCachesInViewport(), false, false, () -> displayExecutor.execute(new DisplayRunnable(CGeoMap.this)));
        } else if (id == R.id.menu_theme_mode) {
            mapView.selectMapTheme(activity);
        } else if (id == R.id.menu_as_list) {
            CacheListActivity.startActivityMap(activity, new SearchResult(getGeocodesForCachesInViewport()));
            ActivityMixin.overrideTransitionToFade(activity);
        } else if (id == R.id.menu_hint) {
            menuShowHint();
        } else if (id == R.id.menu_compass) {
            menuCompass();
        } else if (LoggingUI.onMenuItemSelected(item, activity, getSingleModeCache(), null)) {
            return true;
        } else if (id == R.id.menu_check_routingdata) {
            final Viewport bb = mapView.getViewport();
            MapUtils.checkRoutingData(activity, bb.bottomLeft.getLatitude(), bb.bottomLeft.getLongitude(), bb.topRight.getLatitude(), bb.topRight.getLongitude());
        } else if (HistoryTrackUtils.onOptionsItemSelected(activity, id, () -> mapView.repaintRequired(overlayPositionAndScale instanceof GeneralOverlay ? ((GeneralOverlay) overlayPositionAndScale) : null), this::clearTrailHistory)
                || DownloaderUtils.onOptionsItemSelected(activity, id, false)) {
            return true;
        } else if (id == R.id.menu_routetrack) {
            mapActivity.getRouteTrackUtils().showPopup(individualRoute, this::setTarget);
        } else {
            final MapSource mapSource = MapProviderFactory.getMapSource(id);
            if (mapSource != null) {
                item.setChecked(true);
                changeMapSource(mapSource);
            } else {
                return super.onOptionsItemSelected(item);
            }
        }
        return true;
    }

    public void triggerLongTapContextMenu(final Point tapXY) {
        if (Settings.isLongTapOnMapActivated()) {
            MapUtils.createMapLongClickPopupMenu(activity, new Geopoint(overlayPositionAndScale.getLongTapLatLng().latitude, overlayPositionAndScale.getLongTapLatLng().longitude),
                            tapXY.x, tapXY.y, individualRoute, overlayPositionAndScale, getCurrentTargetCache(), mapOptions)
                    .setOnDismissListener(menu -> overlayPositionAndScale.resetLongTapLatLng())
                    .show();
        }
    }

    @Override
    public void refreshMapData(final boolean circlesSwitched) {
        markersInvalidated = true;
        overlayPositionAndScale.repaintRequired();
        if (circlesSwitched) {
            mapView.setCircles(Settings.isShowCircles());
            mapView.repaintRequired(null);
        }
        MapUtils.updateFilterBar(activity, mapOptions.filterContext);

    }

    private void routingModeChanged(final RoutingMode newValue) {
        Settings.setRoutingMode(newValue);
        final Tracks tracks = mapActivity.getTracks();
        if ((null != individualRoute && individualRoute.getNumSegments() > 0) || null != tracks) {
            Toast.makeText(activity, R.string.brouter_recalculating, Toast.LENGTH_SHORT).show();
        }
        reloadIndividualRoute();
        if (null != tracks) {
            try {
                AndroidRxUtils.andThenOnUi(Schedulers.computation(), () -> tracks.traverse((key, route) -> {
                    route.calculateNavigationRoute();
                    ((GooglePositionAndHistory) overlayPositionAndScale).updateRoute(key, route);
                }), () -> mapView.repaintRequired(overlayPositionAndScale instanceof GeneralOverlay ? ((GeneralOverlay) overlayPositionAndScale) : null));
            } catch (RejectedExecutionException e) {
                Log.e("CGeoMap.routingModeChanged: RejectedExecutionException: " + e.getMessage());
            }
        }
        mapView.repaintRequired(overlayPositionAndScale instanceof GeneralOverlay ? ((GeneralOverlay) overlayPositionAndScale) : null);
    }

    private void compactIconModeChanged(final int newValue) {
        Settings.setCompactIconMode(newValue);
        markersInvalidated = true;
        mapView.repaintRequired(overlayPositionAndScale instanceof GeneralOverlay ? ((GeneralOverlay) overlayPositionAndScale) : null);
    }

    @Override
    public void setTrack(final String key, final Route route) {
        mapActivity.getTracks().setRoute(key, route);
        resumeTrack(key, null == route);
        mapActivity.getRouteTrackUtils().updateRouteTrackButtonVisibility(activity.findViewById(R.id.container_individualroute), individualRoute, mapActivity.getTracks());
    }

    @Override
    public void centerOnPosition(final double latitude, final double longitude, final Viewport viewport) {
        followMyLocation = false;
        switchMyLocationButton();
        mapView.zoomToBounds(viewport, new GoogleGeoPoint(new LatLng(latitude, longitude)));
    }

    @Override
    public void reloadIndividualRoute() {
        individualRoute.reloadRoute((route) -> {
            overlayPositionAndScale.updateIndividualRoute(route);
            mapActivity.getRouteTrackUtils().updateRouteTrackButtonVisibility(activity.findViewById(R.id.container_individualroute), individualRoute, mapActivity.getTracks());
            mapView.repaintRequired(overlayPositionAndScale instanceof GeneralOverlay ? ((GeneralOverlay) overlayPositionAndScale) : null);
        });
    }

    private void clearTrailHistory() {
        DataStore.clearTrailHistory();
        overlayPositionAndScale.setHistory(new ArrayList<>());
        mapView.repaintRequired(overlayPositionAndScale instanceof GeneralOverlay ? ((GeneralOverlay) overlayPositionAndScale) : null);
        ActivityMixin.showToast(activity, res.getString(R.string.map_trailhistory_cleared));
    }

    @Override
    public void clearIndividualRoute() {
        individualRoute.clearRoute((route) -> {
            overlayPositionAndScale.repaintRequired();
            mapActivity.getRouteTrackUtils().updateRouteTrackButtonVisibility(activity.findViewById(R.id.container_individualroute), individualRoute, mapActivity.getTracks());
            ActivityMixin.showToast(activity, res.getString(R.string.map_individual_route_cleared));
        });
    }

    private void setMapRotation(final MenuItem item, final int rotation) {
        Settings.setMapRotation(rotation);
        if (null != overlayPositionAndScale) {
            overlayPositionAndScale.updateMapRotation();
        }
        item.setChecked(true);
    }

    private void menuCompass() {
        final Geocache cache = getSingleModeCache();
        if (cache != null) {
            CompassActivity.startActivityCache(this.getActivity(), cache);
        }
    }

    private void menuShowHint() {
        final Geocache cache = getSingleModeCache();
        if (cache != null) {
            cache.showHintToast(getActivity());
        }
    }

    /**
     * @return a non-null Set of geocodes corresponding to the caches that are shown on screen.
     */
    @NonNull
    private Set<String> getGeocodesForCachesInViewport() {
        final Set<String> geocodes = new HashSet<>();
        final List<Geocache> cachesProtected = caches.getAsList();

        final Viewport viewport = mapView.getViewport();

        if (viewport == null) {
            // fail safe check
            return Collections.emptySet();
        }

        for (final Geocache cache : cachesProtected) {
            if (viewport.contains(cache)) {
                geocodes.add(cache.getGeocode());
            }
        }
        return geocodes;
    }

    /**
     * Restart the current activity if the map provider has changed, or change the map source if needed.
     *
     * @param newSource the new map source, which can be the same as the current one
     * @return true if a restart is needed, false otherwise
     */
    private boolean changeMapSource(@NonNull final MapSource newSource) {
        final MapSource oldSource = Settings.getMapSource();
        final boolean restartRequired = oldSource == null || !MapProviderFactory.isSameActivity(oldSource, newSource);

        Settings.setMapSource(newSource);
        currentSourceId = newSource.getNumericalId();

        if (restartRequired) {
            mapRestart();
        } else if (mapView != null) {  // changeMapSource can be called by onCreate()
            mapOptions.mapState = currentMapState();
            mapView.setMapSource();
            // re-center the map
            centered = false;
            centerMap(mapOptions.geocode, mapOptions.searchResult, mapOptions.coords, mapOptions.mapState);
            // re-build menues
            ActivityMixin.invalidateOptionsMenu(activity);
        }

        mapSource = newSource;

        final int mapAttributionViewId = mapSource.getMapProvider().getMapAttributionViewId();
        if (mapAttributionViewId > 0) {
            final View mapAttributionView = activity.findViewById(mapAttributionViewId);
            if (mapAttributionView != null) {
                mapAttributionView.setOnClickListener(
                        new NewMap.MapAttributionDisplayHandler(() -> this.mapSource.calculateMapAttribution(mapAttributionView.getContext())));
            }
        }

        return restartRequired;
    }

    /**
     * Restart the current activity with the default map source.
     */
    private void mapRestart() {
        mapOptions.mapState = currentMapState();
        activity.finish();
        mapOptions.startIntentWithoutTransition(activity, Settings.getMapProvider().getMapClass());
    }

    /**
     * Get the current map state from the map view if it exists or from the mapStateIntent field otherwise.
     *
     * @return the current map state as an array of int, or null if no map state is available
     */
    private MapState currentMapState() {
        if (mapView == null) {
            return null;
        }
        final GeoPointImpl mapCenter = mapView.getMapViewCenter();

        if (mapCenter == null) {
            return null;
        }

        return new MapState(mapCenter.getCoords(), mapView.getMapZoomLevel(), followMyLocation, mapView.getCircles(), targetGeocode, lastNavTarget, mapOptions.isLiveEnabled, mapOptions.isStoredEnabled);
    }

    private void savePrefs() {
        Settings.setMapZoom(mapOptions.mapMode, mapView.getMapZoomLevel());
        Settings.setMapCenter(mapView.getMapViewCenter());
    }

    // Set center of map to my location if appropriate.
    private void myLocationInMiddle(final GeoData geo) {
        if (followMyLocation) {
            centerMap(geo.getCoords());
        }
    }

    // class: update location
    private static class UpdateLoc extends GeoDirHandler {
        // use the following constants for fine tuning - find good compromise between smooth updates and as less updates as possible

        // minimum time in milliseconds between position overlay updates
        private static final long MIN_UPDATE_INTERVAL = 500;
        // minimum change of heading in grad for position overlay update
        private static final float MIN_HEADING_DELTA = 15f;
        // minimum change of location in fraction of map width/height (whatever is smaller) for position overlay update
        private static final float MIN_LOCATION_DELTA = 0.01f;

        Location currentLocation = Sensors.getInstance().currentGeo();
        float currentHeading;

        private long timeLastPositionOverlayCalculation = 0;
        /**
         * weak reference to the outer class
         */
        private final WeakReference<CGeoMap> mapRef;

        UpdateLoc(final CGeoMap map) {
            mapRef = new WeakReference<>(map);
        }

        @Override
        public void updateGeoDir(@NonNull final GeoData geo, final float dir) {
            currentLocation = geo;
            currentHeading = AngleUtils.getDirectionNow(dir);
            repaintPositionOverlay();
        }

        /**
         * Repaint position overlay but only with a max frequency and if position or heading changes sufficiently.
         */
        void repaintPositionOverlay() {
            final long currentTimeMillis = System.currentTimeMillis();
            if (currentTimeMillis > timeLastPositionOverlayCalculation + MIN_UPDATE_INTERVAL) {
                timeLastPositionOverlayCalculation = currentTimeMillis;

                try {
                    final CGeoMap map = mapRef.get();
                    if (map != null && map.overlayPositionAndScale != null) {
                        final boolean needsRepaintForDistanceOrAccuracy = needsRepaintForDistanceOrAccuracy();
                        final boolean needsRepaintForHeading = needsRepaintForHeading();

                        if (needsRepaintForDistanceOrAccuracy) {
                            if (followMyLocation) {
                                map.centerMap(new Geopoint(currentLocation));
                            }

                            map.overlayPositionAndScale.setCoordinates(currentLocation);
                            map.overlayPositionAndScale.repaintRequired();

                            if (map.proximityNotification != null) {
                                map.proximityNotification.checkDistance(map.getClosestDistanceInM(new Geopoint(currentLocation.getLatitude(), currentLocation.getLongitude())));
                            }
                        } else if (needsRepaintForHeading) {
                            final float mapBearing = map.mapView.getBearing();
                            map.overlayPositionAndScale.setHeading(currentHeading + mapBearing);
                            map.overlayPositionAndScale.repaintRequired();
                        }
                    }
                } catch (final RuntimeException e) {
                    Log.w("Failed to update location", e);
                }
            }
        }

        boolean needsRepaintForHeading() {
            final CGeoMap map = mapRef.get();
            if (map == null || map.overlayPositionAndScale == null) {
                return false;
            }
            return Math.abs(AngleUtils.difference(currentHeading, map.overlayPositionAndScale.getHeading())) > MIN_HEADING_DELTA;
        }

        boolean needsRepaintForDistanceOrAccuracy() {
            final CGeoMap map = mapRef.get();
            if (map == null) {
                return false;
            }
            final Location lastLocation = map.overlayPositionAndScale != null ? map.overlayPositionAndScale.getCoordinates() : null;

            float dist = Float.MAX_VALUE;
            if (lastLocation != null) {
                if (lastLocation.getAccuracy() != currentLocation.getAccuracy()) {
                    return true;
                }
                dist = currentLocation.distanceTo(lastLocation);
            }

            final float[] mapDimension = new float[1];
            if (map.mapView.getWidth() < map.mapView.getHeight()) {
                final double span = map.mapView.getLongitudeSpan() / 1e6;
                Location.distanceBetween(currentLocation.getLatitude(), currentLocation.getLongitude(), currentLocation.getLatitude(), currentLocation.getLongitude() + span, mapDimension);
            } else {
                final double span = map.mapView.getLatitudeSpan() / 1e6;
                Location.distanceBetween(currentLocation.getLatitude(), currentLocation.getLongitude(), currentLocation.getLatitude() + span, currentLocation.getLongitude(), mapDimension);
            }

            return dist > (mapDimension[0] * MIN_LOCATION_DELTA);
        }
    }

    /**
     * Starts the load timer.
     */

    private Disposable startTimer() {
        if (mapOptions.coords != null && mapOptions.mapMode != MapMode.LIVE) {
            // display just one point
            displayPoint(mapOptions.coords);
            loadTimer = new CompositeDisposable();
        } else {
            loadTimer = Schedulers.newThread().schedulePeriodicallyDirect(new LoadTimerAction(this), 0, 250, TimeUnit.MILLISECONDS);
        }
        return loadTimer;
    }

    private static final class LoadTimerAction implements Runnable {

        @NonNull
        private final WeakReference<CGeoMap> mapRef;

                //if in live map and stored caches are found / disables are also shown.
                if (live && settings.maplive >= 1) {
                    final boolean excludeMine = settings.excludeMine > 0;
                    final boolean excludeDisabled = settings.excludeDisabled > 0;

                    for (int i = caches.size() - 1; i >= 0; i--) {
        @Override
        public void run() {
            final CGeoMap map = mapRef.get();
            if (map == null) {
                return;
            }
            try {
                // get current viewport
                final Viewport viewportNow = map.mapView.getViewport();
                if (viewportNow == null) {
                    return;
                }
                // Since zoomNow is used only for local comparison purposes,
                // it is ok to use the Google Maps compatible zoom level of OSM Maps
                final int zoomNow = map.mapView.getMapZoomLevel();

                // check if map moved or zoomed
                //TODO Portree Use Rectangle inside with bigger search window. That will stop reloading on every move
                final boolean moved = map.markersInvalidated || (map.mapOptions.isLiveEnabled && !map.downloaded) || previousViewport == null || zoomNow != previousZoom ||
                        (mapMoved(previousViewport, viewportNow) && (map.cachesCnt <= 0 || CollectionUtils.isEmpty(map.caches) || !previousViewport.includes(viewportNow)));

                // update title on any change
                if (moved || !viewportNow.equals(previousViewport)) {
                    map.updateMapTitle();
                }

                // save new values
                if (moved) {
                    map.markersInvalidated = false;

                    final long currentTime = System.currentTimeMillis();

                    if ((currentTime - map.loadThreadRun) > 1000) {
                        previousViewport = viewportNow;
                        previousZoom = zoomNow;
                        loadExecutor.execute(new LoadRunnable(map));
                    }
                }
            } catch (final Exception e) {
                Log.w("CGeoMap.startLoadtimer.start", e);
            }
        }
    }

    /**
     * get if map is loading something
     */
    private boolean isLoading() {
        return !loadTimer.isDisposed() &&
                (loadExecutor.getActiveCount() > 0 ||
                        downloadExecutor.getActiveCount() > 0 ||
                        displayExecutor.getActiveCount() > 0);
    }

    /**
     * Worker thread that loads caches and waypoints from the database and then spawns the {@link DownloadRunnable}.
     * started by the load timer.
     */

    private static class LoadRunnable extends DoRunnable {

        LoadRunnable(@NonNull final CGeoMap map) {
            super(map);
        }

        @Override
        public void runWithMap(final CGeoMap map) {
            map.doLoadRun();
        }
    }

    private void doLoadRun() {
        try {
            showProgressHandler.sendEmptyMessage(SHOW_PROGRESS);
            loadThreadRun = System.currentTimeMillis();

            final SearchResult searchResult;
            final MapMode mapMode = mapOptions.mapMode;
            if (mapMode == MapMode.LIVE) {
                searchResult = mapOptions.isLiveEnabled ? new SearchResult() : new SearchResult(DataStore.loadStoredInViewport(mapView.getViewport()));
            } else {
                // map started from another activity
                searchResult = mapOptions.searchResult != null ? new SearchResult(mapOptions.searchResult) : new SearchResult();
                if (mapOptions.geocode != null) {
                    searchResult.addGeocode(mapOptions.geocode);
                }
            }
            // live mode search result
            if (mapOptions.isLiveEnabled) {
                searchResult.addSearchResult(DataStore.loadCachedInViewport(mapView.getViewport()));
            }

            downloaded = true;
            final Set<Geocache> cachesFromSearchResult = searchResult.getCachesFromSearchResult(LoadFlags.LOAD_WAYPOINTS);
            // update the caches
            // new collection type needs to remove first
            caches.removeAll(cachesFromSearchResult);
            caches.addAll(cachesFromSearchResult);


            synchronized (caches) {
                MapUtils.filter(caches, mapOptions.filterContext);
            }

            countVisibleCaches();
            // we don't want to see any stale waypoints
            waypoints.clear();
            if (cachesCnt < Settings.getWayPointsThreshold() || mapOptions.geocode != null) {
                if (mapOptions.isLiveEnabled || mapMode == MapMode.LIVE || mapMode == MapMode.COORDS) {
                    //All visible waypoints
                    final Set<Waypoint> waypointsInViewport = DataStore.loadWaypoints(mapView.getViewport());
                    MapUtils.filter(waypointsInViewport, mapOptions.filterContext);
                    waypoints.addAll(waypointsInViewport);
                } else {
                    //All visible waypoints from the viewed caches
                    for (final Geocache c : caches.getAsList()) {
                        final Set<Waypoint> filteredWaypoints = new HashSet<>(c.getWaypoints());
                        MapUtils.filter(filteredWaypoints, mapOptions.filterContext);
                        waypoints.addAll(filteredWaypoints);
                    }
                }
            }

            //render
            displayExecutor.execute(new DisplayRunnable(this));

            if (mapOptions.isLiveEnabled) {
                downloadExecutor.execute(new DownloadRunnable(this));
            }
            lastSearchResult = searchResult;
        } finally {
            showProgressHandler.sendEmptyMessage(HIDE_PROGRESS); // hide progress
        }

    }

    /**
     * Worker thread downloading caches from the Internet.
     * Started by {@link LoadRunnable}.
     */

    private static class DownloadRunnable extends DoRunnable {

        DownloadRunnable(final CGeoMap map) {
            super(map);
        }

        @Override
        public void runWithMap(final CGeoMap map) {
            map.doDownloadRun();
        }
    }

    private void doDownloadRun() {
        try {
            showProgressHandler.sendEmptyMessage(SHOW_PROGRESS); // show progress

            final boolean useLastSearchResult = null != lastSearchResult && null != lastViewport && lastViewport.includes(mapView.getViewport());
            final Viewport newViewport = mapView.getViewport().resize(3.0);
            final SearchResult searchResult = useLastSearchResult ? lastSearchResult : ConnectorFactory.searchByViewport(newViewport);
            downloaded = true;

            final Set<Geocache> result = searchResult.getCachesFromSearchResult(LoadFlags.LOAD_CACHE_OR_DB);
            MapUtils.filter(result, mapOptions.filterContext);

            // update the caches
            // first remove filtered out
            final Set<String> filteredCodes = searchResult.getFilteredGeocodes();
            Log.d("Filtering out " + filteredCodes.size() + " caches: " + filteredCodes);
            caches.removeAll(DataStore.loadCaches(filteredCodes, LoadFlags.LOAD_CACHE_ONLY));
            DataStore.removeCaches(filteredCodes, EnumSet.of(RemoveFlag.CACHE));
            // new collection type needs to remove first to refresh
            caches.removeAll(result);
            caches.addAll(result);

            lastSearchResult = searchResult;
            if (null == lastViewport || !useLastSearchResult || (!caches.isEmpty() && lastSearchResult.getCount() > 400)) {
                lastViewport = containingGCliveCaches(caches.getAsList());
                if (Settings.isDebug() && overlayPositionAndScale instanceof GooglePositionAndHistory) {
                    ((GooglePositionAndHistory) overlayPositionAndScale).drawViewport(lastViewport);
                }
            }
            Log.d("searchByViewport: cached=" + useLastSearchResult + ", results=" + lastSearchResult.getCount() + ", viewport=" + lastViewport);

            //render
            displayExecutor.execute(new DisplayRunnable(this));

        } finally {
            showProgressHandler.sendEmptyMessage(HIDE_PROGRESS); // hide progress
        }
    }

    /**
     * Thread to Display (down)loaded caches. Started by {@link LoadRunnable} and {@link DownloadRunnable}
     */
    private static class DisplayRunnable extends DoRunnable {

        DisplayRunnable(@NonNull final CGeoMap map) {
            super(map);
        }

        @Override
        public void runWithMap(final CGeoMap map) {
            map.doDisplayRun();
        }
    }

    private void doDisplayRun() {
        // don't add anything to mapView if just one point should be displayed
        if (mapOptions.coords != null) {
            return;
        }
        try {
            showProgressHandler.sendEmptyMessage(SHOW_PROGRESS);

            // display caches
            final List<Geocache> cachesToDisplay = caches.getAsList();
            final List<Waypoint> waypointsToDisplay = new ArrayList<>(waypoints);
            final Set<CachesOverlayItemImpl> itemsToDisplay = new HashSet<>();

            if (!cachesToDisplay.isEmpty()) {
                // Only show waypoints for single view or setting
                // when less than showWaypointsthreshold Caches shown
                countVisibleCaches();
                final boolean forceCompactIconMode = CompactIconModeUtils.forceCompactIconMode(cachesCnt);
                if (mapOptions.mapMode == MapMode.SINGLE || cachesCnt < Settings.getWayPointsThreshold()) {
                    for (final Waypoint waypoint : waypointsToDisplay) {
                        if (waypoint != null && waypoint.getCoords() != null && waypoint.getCoords().isValid()) {
                            itemsToDisplay.add(getWaypointItem(waypoint, forceCompactIconMode));
                        }
                    }
                }
                for (final Geocache cache : cachesToDisplay) {
                    if (cache != null && cache.getCoords() != null && cache.getCoords().isValid()) {
                        itemsToDisplay.add(getCacheItem(cache, forceCompactIconMode));
                    }
                }
            }
            // don't add other waypoints to overlayCaches if just one point should be displayed
            if (mapOptions.coords == null) {
                mapView.updateItems(itemsToDisplay);
            }
            displayHandler.sendEmptyMessage(INVALIDATE_MAP);

            updateMapTitle();
        } finally {
            showProgressHandler.sendEmptyMessage(HIDE_PROGRESS);
        }
    }

    private void displayPoint(final Geopoint coords) {
        if (!coords.isValid()) {
            return;
        }
        final Waypoint waypoint = new Waypoint("some place", mapOptions.waypointType != null ? mapOptions.waypointType : WaypointType.WAYPOINT, false);
        waypoint.setCoords(coords);
        waypoint.setGeocode(mapOptions.geocode);

        final CachesOverlayItemImpl item = getWaypointItem(waypoint, Settings.getCompactIconMode() == Settings.COMPACTICON_ON);
        mapView.updateItems(Collections.singletonList(item));
        displayHandler.sendEmptyMessage(INVALIDATE_MAP);
        updateMapTitle();

        cachesCnt = 1;
    }

    private void updateMapTitle() {
        displayHandler.sendEmptyMessage(UPDATE_TITLE);
    }

    private abstract static class DoRunnable implements Runnable {

        private final WeakReference<CGeoMap> mapRef;

        protected DoRunnable(@NonNull final CGeoMap map) {
            mapRef = new WeakReference<>(map);
        }

        @Override
        public final void run() {
            final CGeoMap map = mapRef.get();
            if (map != null) {
                runWithMap(map);
            }
        }

        protected abstract void runWithMap(CGeoMap map);
    }

    private static boolean mapMoved(final Viewport referenceViewport, final Viewport newViewport) {
        return Math.abs(newViewport.getLatitudeSpan() - referenceViewport.getLatitudeSpan()) > 50e-6 ||
                Math.abs(newViewport.getLongitudeSpan() - referenceViewport.getLongitudeSpan()) > 50e-6 ||
                Math.abs(newViewport.center.getLatitude() - referenceViewport.center.getLatitude()) > referenceViewport.getLatitudeSpan() / 4 ||
                Math.abs(newViewport.center.getLongitude() - referenceViewport.center.getLongitude()) > referenceViewport.getLongitudeSpan() / 4;
    }

    // center map to desired location
    private void centerMap(final Geopoint coords) {
        if (coords == null || !coords.isValid()) {
            return;
        }

        final MapControllerImpl mapController = mapView.getMapController();
        final GeoPointImpl target = makeGeoPoint(coords);

        if (alreadyCentered) {
            mapController.animateTo(target);
        } else {
            mapController.setCenter(target);
        }

        alreadyCentered = true;

    }

    // move map to view results of searchIntent
    private void centerMap(final String geocodeCenter, final SearchResult searchCenter, final Geopoint coordsCenter, final MapState mapState) {
        final MapControllerImpl mapController = mapView.getMapController();

        if (!centered && mapState != null) {
            try {
                mapController.setCenter(mapItemFactory.getGeoPointBase(mapState.getCenter()));
                setZoom(mapState.getZoomLevel());
            } catch (final RuntimeException e) {
                Log.e("centermap", e);
            }

            centered = true;
            alreadyCentered = true;
        } else if (!centered && coordsCenter != null) {
            try {
                mapController.setCenter(makeGeoPoint(coordsCenter));
            } catch (final Exception e) {
                Log.e("centermap", e);
            }

            centered = true;
            alreadyCentered = true;
        } else if (!centered && (geocodeCenter != null || mapOptions.searchResult != null)) {
            try {
                Viewport viewport = null;

                if (geocodeCenter != null) {
                    viewport = DataStore.getBounds(geocodeCenter);
                } else if (searchCenter != null) {
                    viewport = DataStore.getBounds(searchCenter.getGeocodes());
                }

                if (viewport == null) {
                    return;
                }

                mapController.setCenter(mapItemFactory.getGeoPointBase(viewport.center));
                if (viewport.getLatitudeSpan() != 0 && viewport.getLongitudeSpan() != 0) {
                    mapController.zoomToSpan((int) (viewport.getLatitudeSpan() * 1e6), (int) (viewport.getLongitudeSpan() * 1e6));
                }
            } catch (final RuntimeException e) {
                Log.e("centermap", e);
            }

            centered = true;
            alreadyCentered = true;
        }
    }

    // switch My Location button image
    private void switchMyLocationButton() {
        // FIXME: temporary workaround for the absence of "follow my location" on Android 3.x (see issue #4289).
        if (myLocSwitch != null) {
            myLocSwitch.setChecked(followMyLocation);
            if (followMyLocation) {
                myLocationInMiddle(Sensors.getInstance().currentGeo());
            }
        }
    }

    // set my location listener
    private static class MyLocationListener implements View.OnClickListener {

        private final WeakReference<CGeoMap> mapRef;

        MyLocationListener(@NonNull final CGeoMap map) {
            mapRef = new WeakReference<>(map);
        }

        @Override
        public void onClick(final View view) {
            final CGeoMap map = mapRef.get();
            if (map != null) {
                map.onFollowMyLocationClicked();
            }
        }
    }

    private void onFollowMyLocationClicked() {
        followMyLocation = !followMyLocation;
        switchMyLocationButton();
    }

    public static class MapDragListener implements OnMapDragListener {

        private final WeakReference<CGeoMap> mapRef;

        public MapDragListener(@NonNull final CGeoMap map) {
            mapRef = new WeakReference<>(map);
        }

        @Override
        public void onDrag() {
            final CGeoMap map = mapRef.get();
            if (map != null) {
                map.onDrag();
            }
        }

    }

    private void onDrag() {
        if (followMyLocation) {
            followMyLocation = false;
            switchMyLocationButton();
        }
    }

    // make geopoint
    private GeoPointImpl makeGeoPoint(final Geopoint coords) {
        return mapItemFactory.getGeoPointBase(coords);
    }

    @Override
    public View makeView() {
        final ImageView imageView = new ImageView(activity);
        imageView.setScaleType(ScaleType.CENTER);
        imageView.setLayoutParams(new ImageSwitcher.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT));
        return imageView;
    }

    public static void markCacheAsDirty(final String geocode) {
        synchronized (dirtyCaches) {
            dirtyCaches.add(geocode);
        }
    }

    private CachesOverlayItemImpl getCacheItem(final Geocache cache, final boolean isDotMode) {
        final CachesOverlayItemImpl item = mapItemFactory.getCachesOverlayItem(cache, cache.applyDistanceRule());
        if (isDotMode) {
            item.setMarker(MapMarkerUtils.getCacheDotMarker(getResources(), cache));
        } else {
            item.setMarker(MapMarkerUtils.getCacheMarker(getResources(), cache, null));
        }
        return item;
    }

    private CachesOverlayItemImpl getWaypointItem(final Waypoint waypoint, final boolean isDotMode) {
        final CachesOverlayItemImpl item = mapItemFactory.getCachesOverlayItem(waypoint, waypoint.applyDistanceRule());
        if (isDotMode) {
            item.setMarker(MapMarkerUtils.getWaypointDotMarker(getResources(), waypoint));
        } else {
            item.setMarker(MapMarkerUtils.getWaypointMarker(getResources(), waypoint, true));
        }
        return item;
    }


    @Override
    public void onCacheTap(final IWaypoint waypoint) {
        if (waypoint == null) {
            return;
        }

        final CoordinatesType coordType = waypoint.getCoordType();

        if (coordType == CoordinatesType.CACHE && StringUtils.isNotBlank(waypoint.getGeocode())) {
            final Geocache cache = DataStore.loadCache(waypoint.getGeocode(), LoadFlags.LOAD_CACHE_OR_DB);
            if (cache != null) {
                CGeoMap.markCacheAsDirty(cache.getGeocode());
                CachePopup.startActivityAllowTarget(activity, cache.getGeocode());
            }
            return;
        }

        if (coordType == CoordinatesType.WAYPOINT && waypoint.getId() >= 0) {
            CGeoMap.markCacheAsDirty(waypoint.getGeocode());
            WaypointPopup.startActivityAllowTarget(getActivity(), waypoint.getId(), waypoint.getGeocode());
        }
    }

    public WaypointDistanceInfo getClosestDistanceInM(final Geopoint coord) {
        int minDistance = 50000000;
        String name = "";
        // check caches
        for (final Geocache item : caches) {
            final int distance = (int) (1000 * coord.distanceTo(item.getCoords()));
            if (distance > 0 && distance < minDistance) {
                minDistance = distance;
                name = item.getShortGeocode() + " " + item.getName();
            }
        }
        // check waypoints
        for (final Waypoint item : waypoints) {
            final int distance = (int) (1000 * coord.distanceTo(item.getCoords()));
            if (distance > 0 && distance < minDistance) {
                minDistance = distance;
                name = item.getName() + " (" + item.getWaypointType().gpx + ")";
            }
        }
        return new WaypointDistanceInfo(name, minDistance);
    }

    public Collection<Geocache> getCaches() {
        return caches;
    }

    @Override
    public GeocacheFilterContext getFilterContext() {
        return mapOptions.filterContext;
    }

    @Override
    public MapOptions getMapOptions() {
        return mapOptions;
    }

}

<MSG> fix #552: don't invoke getRating for non GC caches

I had to change a bit of the options handling to be able to extract the
gcvote related code into an own stateless class. If you have remarks
about that, I'm happy to discuss alternatives of how to change the
settings code.
<DFF> @@ -1145,7 +1145,7 @@ public class CGeoMap extends AbstractMap implements OnDragListener, ViewFactory
 
                 //if in live map and stored caches are found / disables are also shown.
                 if (live && settings.maplive >= 1) {
-                    final boolean excludeMine = settings.excludeMine > 0;
+                    final boolean excludeMine = cgSettings.getExcludeMine();
                     final boolean excludeDisabled = settings.excludeDisabled > 0;
 
                     for (int i = caches.size() - 1; i >= 0; i--) {

fix #552: don't invoke getRating for non GC caches