/* * KnownLocationsPicker.java * Copyright (C) 2016 Nicholas Killewald * * This file is distributed under the terms of the BSD license. * The source package should have a LICENSE file at the toplevel. */ package net.exclaimindustries.geohashdroid.activities; import android.annotation.SuppressLint; import android.app.AlertDialog; import android.app.Dialog; import android.app.DialogFragment; import android.app.backup.BackupManager; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.location.Address; import android.location.Geocoder; import android.os.AsyncTask; import android.os.Bundle; import android.preference.PreferenceActivity; import android.preference.PreferenceManager; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.util.Log; import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.View; import android.view.inputmethod.EditorInfo; import android.widget.ArrayAdapter; import android.widget.CheckBox; import android.widget.EditText; import android.widget.Spinner; import android.widget.TextView; import android.widget.Toast; import com.google.android.gms.common.api.GoogleApiClient; import com.google.android.gms.location.LocationServices; import com.google.android.gms.maps.CameraUpdate; import com.google.android.gms.maps.CameraUpdateFactory; import com.google.android.gms.maps.GoogleMap; import com.google.android.gms.maps.MapFragment; import com.google.android.gms.maps.OnMapReadyCallback; import com.google.android.gms.maps.UiSettings; import com.google.android.gms.maps.model.BitmapDescriptorFactory; import com.google.android.gms.maps.model.Circle; import com.google.android.gms.maps.model.LatLng; import com.google.android.gms.maps.model.LatLngBounds; import com.google.android.gms.maps.model.MapStyleOptions; import com.google.android.gms.maps.model.Marker; import com.google.android.gms.maps.model.MarkerOptions; import com.google.android.gms.maps.model.VisibleRegion; import com.google.common.collect.BiMap; import com.google.common.collect.HashBiMap; import net.exclaimindustries.geohashdroid.R; import net.exclaimindustries.geohashdroid.util.GHDConstants; import net.exclaimindustries.geohashdroid.util.KnownLocation; import net.exclaimindustries.geohashdroid.util.KnownLocationPinData; import net.exclaimindustries.geohashdroid.util.UnitConverter; import org.opensextant.geodesy.Angle; import org.opensextant.geodesy.Geodetic2DArc; import org.opensextant.geodesy.Geodetic2DPoint; import org.opensextant.geodesy.Latitude; import org.opensextant.geodesy.Longitude; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.List; /** * KnownLocationsPicker is another map-containing Activity. This one allows the * user to set "known locations", which can trigger notifications if the day's * hashpoint is near one of them. This isn't CentralMap, mind; it doesn't have * the entire CentralMapMode architecture or stock-grabbing functionality. And * for that, I'm thankful. */ public class KnownLocationsPicker extends BaseMapActivity implements GoogleMap.OnMapLongClickListener, GoogleMap.OnMarkerClickListener, GoogleMap.OnInfoWindowClickListener { private static final String DEBUG_TAG = "KnownLocationsPicker"; // These get passed into the dialog. private static final String NAME = "name"; private static final String LATLNG = "latLng"; private static final String RANGE = "range"; private static final String RESTRICT = "restrict"; private static final String EXISTING = "existing"; private static final String ADDRESS = "address"; // This is for restoring the map from an instance bundle. private static final String CLICKED_MARKER = "clickedMarker"; private static final String LAST_ADDRESSES = "lastAddresses"; private static final String RELOADING = "reloading"; private static final String EDIT_DIALOG = "editDialog"; /** Response codes from LocationSearchTask. */ public enum LookupErrorCode { /** All is well, results are to follow. */ OKAY, /** All is well, but there were no results. */ NO_RESULTS, /** An I/O error occurred (probably no network connection). */ IO_ERROR, /** No geocoder is installed (and it's weird that we got this far). */ NO_GEOCODER, /** Some manner of internal error occurred. */ INTERNAL_ERROR, /** This search was actually canceled, so ignore it. */ CANCELED } /** * This dialog pops up when either adding or editing a KnownLocation. */ public static class EditKnownLocationDialog extends DialogFragment { private LatLng mLocation; private KnownLocation mExisting; private Address mAddress; @Override @SuppressLint("InflateParams") public Dialog onCreateDialog(Bundle savedInstanceState) { // It's either this or we make a callback mechanism for something // that literally only gets used once. if(!(getActivity() instanceof KnownLocationsPicker)) throw new IllegalStateException("An EditKnownLocationDialog can only be instantiated from the KnownLocationsPicker activity!"); final KnownLocationsPicker pickerActivity = (KnownLocationsPicker)getActivity(); // The arguments MUST be defined, else we're not doing anything. Bundle args = getArguments(); if(args == null || !args.containsKey(RANGE) || !args.containsKey(LATLNG) || !args.containsKey(NAME)) { throw new IllegalArgumentException("Missing arguments to EditKnownLocationDialog!"); } // Time to inflate! LayoutInflater inflater = ((LayoutInflater)getActivity().getSystemService(Context.LAYOUT_INFLATER_SERVICE)); View dialogView = inflater.inflate(R.layout.edit_known_location_dialog, null); String name; int range; boolean restrict; // Right! Go to the arguments first. name = args.getString(NAME); range = convertRangeToPosition(args.getDouble(RANGE)); restrict = args.getBoolean(RESTRICT); mExisting = args.getParcelable(EXISTING); mAddress = args.getParcelable(ADDRESS); mLocation = args.getParcelable(LATLNG); // If there's a saved instance state, that overrides the name and // range. Not the location, though. That's locked in at this // point. if(savedInstanceState != null) { name = savedInstanceState.getString(NAME); range = savedInstanceState.getInt(RANGE); restrict = savedInstanceState.getBoolean(RESTRICT); } // Now then! Let's create this mess. First, if this is a location // that already exists, the user can delete it. Otherwise, that // button goes away. View deleteButton = dialogView.findViewById(R.id.delete_location); if(mExisting == null) { deleteButton.setVisibility(View.GONE); } else { deleteButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { pickerActivity.deleteActiveKnownLocation(mExisting); dismiss(); } }); } // The input takes on whatever name it needs to. final EditText nameInput = (EditText)dialogView.findViewById(R.id.input_location_name); nameInput.setText(name); // The spinner needs an adapter. Fortunately, a basic one will do, // as soon as we figure out what units we're using. SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(pickerActivity); String units = prefs.getString(GHDConstants.PREF_DIST_UNITS, GHDConstants.PREFVAL_DIST_METRIC); ArrayAdapter<CharSequence> adapter; if(units.equals(GHDConstants.PREFVAL_DIST_METRIC)) adapter = ArrayAdapter.createFromResource(pickerActivity, R.array.known_locations_ranges_metric, android.R.layout.simple_spinner_item); else adapter = ArrayAdapter.createFromResource(pickerActivity, R.array.known_locations_ranges_imperial, android.R.layout.simple_spinner_item); adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); final Spinner spinner = (Spinner)dialogView.findViewById(R.id.spinner_location_range); spinner.setAdapter(adapter); spinner.setSelection(range); final CheckBox restrictBox = (CheckBox)dialogView.findViewById(R.id.restrict); restrictBox.setChecked(restrict); // There! Now, let's make it a dialog. return new AlertDialog.Builder(pickerActivity) .setView(dialogView) .setTitle(mExisting != null ? R.string.known_locations_title_edit : R.string.known_locations_title_add) .setPositiveButton(R.string.ok_label, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { // There HAS to be a better way to do this... if(mAddress != null) pickerActivity.confirmKnownLocationFromDialog( nameInput.getText().toString(), mLocation, convertPositionToRange(spinner.getSelectedItemPosition()), restrictBox.isChecked(), mAddress); else pickerActivity.confirmKnownLocationFromDialog( nameInput.getText().toString(), mLocation, convertPositionToRange(spinner.getSelectedItemPosition()), restrictBox.isChecked(), mExisting); dismiss(); } }) .setNegativeButton(R.string.cancel_label, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { pickerActivity.removeActiveKnownLocation(); dismiss(); } }) .create(); } private double convertPositionToRange(int id) { return (double)getResources().getIntArray(R.array.known_locations_values)[id]; } private int convertRangeToPosition(double range) { int pos = 0; for(int i : getResources().getIntArray(R.array.known_locations_values)) { if(range <= i) return pos; pos++; } return pos; } } private class LocationSearchTask extends AsyncTask<String, Void, LookupErrorCode> { private List<Address> mAddresses; private VisibleRegion mVis; private float mBearing; public LocationSearchTask(VisibleRegion vis, float bearing) { super(); mVis = vis; mBearing = bearing; } @Override protected LookupErrorCode doInBackground(String... params) { if(mGeocoder == null) return LookupErrorCode.NO_GEOCODER; mAddresses = new ArrayList<>(); LookupErrorCode toReturn = LookupErrorCode.OKAY; // As initial tests proved, we really should try to narrow down the // location to roughly where the user is looking at the time. // Remember that the projection can do all sorts of crazy stuff, so // let's get the biggest rectangle we can from there. double lowerLeftLat, lowerLeftLon, upperRightLat, upperRightLon; // All we need is more or less an estimate of what the proper // rectangle is. Since we have the visible region AND we know what // the rotation is, we can guess at a decent rectangle quickly. And // more than a bit hackishly. Come with me on this journey. if(mBearing >= 0.0f && mBearing < 45.0f) { // 0 - 45: The near-left and far-right coordinates are directly // what we want, more or less. lowerLeftLat = mVis.nearLeft.latitude; lowerLeftLon = mVis.nearLeft.longitude; upperRightLat = mVis.farRight.latitude; upperRightLon = mVis.farRight.longitude; } else if(mBearing >= 45.0f && mBearing < 90.0f) { // 45 - 90: Near-left works for the left boundary, but we need // near-right for the bottom. Similarly, far-left is the top // and far-right is the right. lowerLeftLat = mVis.nearRight.latitude; lowerLeftLon = mVis.nearLeft.longitude; upperRightLat = mVis.farLeft.latitude; upperRightLon = mVis.farRight.longitude; } else if(mBearing >= 90.0f && mBearing < 135.0f) { // And we continue rotating in that manner. lowerLeftLat = mVis.nearRight.latitude; lowerLeftLon = mVis.nearRight.longitude; upperRightLat = mVis.farLeft.latitude; upperRightLon = mVis.farLeft.longitude; } else if(mBearing >= 135.0f && mBearing < 180.0f) { lowerLeftLat = mVis.farRight.latitude; lowerLeftLon = mVis.nearRight.longitude; upperRightLat = mVis.nearLeft.latitude; upperRightLon = mVis.farLeft.longitude; } else if(mBearing >= 180.0f && mBearing < 225.0f) { lowerLeftLat = mVis.farRight.latitude; lowerLeftLon = mVis.farRight.longitude; upperRightLat = mVis.nearLeft.latitude; upperRightLon = mVis.nearLeft.longitude; } else if(mBearing >= 225.0f && mBearing < 270.0f) { lowerLeftLat = mVis.farLeft.latitude; lowerLeftLon = mVis.farRight.longitude; upperRightLat = mVis.nearRight.latitude; upperRightLon = mVis.nearLeft.longitude; } else if(mBearing >= 270.0f && mBearing < 315.0f) { lowerLeftLat = mVis.farLeft.latitude; lowerLeftLon = mVis.farLeft.longitude; upperRightLat = mVis.nearRight.latitude; upperRightLon = mVis.nearRight.longitude; } else { lowerLeftLat = mVis.nearLeft.latitude; lowerLeftLon = mVis.farRight.longitude; upperRightLat = mVis.farRight.latitude; upperRightLon = mVis.nearLeft.longitude; } // I really hope we're not calling this with a bunch of Strings, but // sure, let's be defensive, why not? try { for(String s : params) { if(isCancelled()) return LookupErrorCode.CANCELED; List<Address> result = mGeocoder.getFromLocationName( s, 10, lowerLeftLat, lowerLeftLon, upperRightLat, upperRightLon); if(isCancelled()) return LookupErrorCode.CANCELED; // If there was no result, well, broaden the search. if(result == null || result.isEmpty()) result = mGeocoder.getFromLocationName(s, 10); if(isCancelled()) return LookupErrorCode.CANCELED; if(result != null) mAddresses.addAll(result); } } catch (IOException ioe) { toReturn = LookupErrorCode.IO_ERROR; } catch (IllegalArgumentException iae) { toReturn = LookupErrorCode.INTERNAL_ERROR; } // Remember, we're returning the error code, not the list of // addresses, since we want to report that error if need be. if(mAddresses.isEmpty()) toReturn = LookupErrorCode.NO_RESULTS; // Last chance for the cancel check... if(isCancelled()) return LookupErrorCode.CANCELED; return toReturn; } @Override protected void onPostExecute(LookupErrorCode code) { if(code != LookupErrorCode.CANCELED) // Got a response! Go go go! searchResults(code, mAddresses); } } private Geocoder mGeocoder; private LocationSearchTask mSearchTask; private boolean mMapIsReady = false; private boolean mReloaded = false; private BiMap<Marker, KnownLocation> mMarkerMap; private BiMap<Circle, KnownLocation> mCircleMap; private List<KnownLocation> mLocations; private Marker mMapClickMarker; private MarkerOptions mMapClickMarkerOptions; private List<Address> mActiveAddresses; private BiMap<Marker, Address> mActiveAddressMap; private Marker mActiveMarker; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // We've got a layout, so let's use the layout. setContentView(R.layout.known_locations); // Now, we'll need to get the list of KnownLocations right away so we // can put them on the map. Well, I guess not RIGHT away. We still // have to wait on the map callbacks, but still, let's fetch them now. mLocations = KnownLocation.getAllKnownLocations(this); // We need maps. mMarkerMap = HashBiMap.create(); mCircleMap = HashBiMap.create(); // Prep a client (it'll get going during onStart)! mGoogleClient = new GoogleApiClient.Builder(this) .addConnectionCallbacks(this) .addOnConnectionFailedListener(this) .addApi(LocationServices.API) .build(); // We need a Geocoder! Well, not really; if we can't get one, remove // the search option. if(Geocoder.isPresent()) { mGeocoder = new Geocoder(this); // A valid Geocoder also means we can attach the click listener. final EditText input = (EditText)findViewById(R.id.search); final View go = findViewById(R.id.search_go); go.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { searchForLocation(input.getText().toString()); } }); input.setOnEditorActionListener(new TextView.OnEditorActionListener() { @Override public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { if(actionId == EditorInfo.IME_ACTION_GO) { searchForLocation(v.getText().toString()); return true; } return false; } }); } else { findViewById(R.id.search_box).setVisibility(View.GONE); } // Our friend the map needs to get ready, too. MapFragment mapFrag = (MapFragment)getFragmentManager().findFragmentById(R.id.map); mapFrag.getMapAsync(new OnMapReadyCallback() { @Override public void onMapReady(GoogleMap googleMap) { mMap = googleMap; // I could swear you could do this in XML... UiSettings set = mMap.getUiSettings(); // The My Location button has to go off, as the search bar sort // of takes up that space. set.setMyLocationButtonEnabled(false); // Also, get rid of the map toolbar. That just doesn't make any // sense here if we've already got a search widget handy. set.setMapToolbarEnabled(false); // Get ready to listen for clicks! mMap.setOnMapLongClickListener(KnownLocationsPicker.this); mMap.setOnInfoWindowClickListener(KnownLocationsPicker.this); // Were we waiting on a long-tapped marker? if(mMapClickMarkerOptions != null) { // Well, then put the marker back on the map! mMapClickMarker = mMap.addMarker(mMapClickMarkerOptions); mActiveMarker = mMapClickMarker; mMapClickMarker.showInfoWindow(); } // Should this be the night map? Maybe I'll add in the full // map type picker later, but for now, it's just the day or // night street map. if(isNightMode()) if(!mMap.setMapStyle(MapStyleOptions.loadRawResourceStyle(KnownLocationsPicker.this, R.raw.map_night))) Log.e(DEBUG_TAG, "Couldn't parse the map style JSON!"); // Activate My Location if permissions are right. if(checkLocationPermissions(0)) permissionsGranted(); // Same as CentralMap, we need to wait on both this AND the Maps // API to be ready. mMapIsReady = true; doReadyChecks(); } }); // Now, this only kicks in if stock pre-fetching is on. If it isn't, we // ought to make sure the user knows this. final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); if(!prefs.getBoolean(GHDConstants.PREF_STOCK_ALARM, false) && !prefs.getBoolean(GHDConstants.PREF_STOP_BUGGING_ME_PREFETCH_WARNING, false)) { // Dialog! new AlertDialog.Builder(this) .setMessage(R.string.known_locations_prefetch_is_off) .setNegativeButton(R.string.stop_reminding_me_label, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { dialog.dismiss(); SharedPreferences.Editor editor = prefs.edit(); editor.putBoolean(GHDConstants.PREF_STOP_BUGGING_ME_PREFETCH_WARNING, true); editor.apply(); BackupManager bm = new BackupManager(KnownLocationsPicker.this); bm.dataChanged(); } }) .setNeutralButton(R.string.go_to_preference, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { dialog.dismiss(); Intent intent = new Intent(KnownLocationsPicker.this, PreferencesScreen.class); intent.putExtra(PreferenceActivity.EXTRA_SHOW_FRAGMENT, PreferencesScreen.OtherPreferenceFragment.class.getName()); intent.putExtra(PreferenceActivity.EXTRA_NO_HEADERS, true); startActivity(intent); } }) .setPositiveButton(R.string.gotcha_label, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { dialog.dismiss(); } }) .show(); } } @Override protected void onStart() { super.onStart(); // Service up! mGoogleClient.connect(); } @Override protected void onStop() { // Service down! mGoogleClient.disconnect(); if(mSearchTask != null) mSearchTask.cancel(true); super.onStop(); } @Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putBoolean(RELOADING, true); // If we were looking at a click marker, hold on to it. if(mMapClickMarkerOptions != null) { outState.putParcelable(CLICKED_MARKER, mMapClickMarkerOptions); } if(mActiveAddresses != null) { // mActiveAddresses is a List, not an ArrayList, so we have to do // this manually. Address addresses[] = new Address[mActiveAddresses.size()]; outState.putParcelableArray(LAST_ADDRESSES, mActiveAddresses.toArray(addresses)); } } @Override protected void onRestoreInstanceState(Bundle savedInstanceState) { super.onRestoreInstanceState(savedInstanceState); if(savedInstanceState != null) { mReloaded = savedInstanceState.getBoolean(RELOADING, false); // Did we have a click marker? Once the map's ready, we'll put it // back in place. if(savedInstanceState.containsKey(CLICKED_MARKER)) { mMapClickMarkerOptions = savedInstanceState.getParcelable(CLICKED_MARKER); } if(savedInstanceState.containsKey(LAST_ADDRESSES)) { // I'm actually surprised Geocoder doesn't return an ArrayList, // or that there's no direct putParcelableList method. Address[] addresses = (Address[])savedInstanceState.getParcelableArray(LAST_ADDRESSES); if(addresses != null) { mActiveAddresses = new ArrayList<>(); Collections.addAll(mActiveAddresses, addresses); } } } } @Override public void onConnected(Bundle bundle) { if(!isFinishing()) doReadyChecks(); } @Override public void onConnectionSuspended(int i) { } @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { if(permissions.length <= 0 || grantResults.length <= 0) return; // CentralMap will generally be handling location permissions. So... if(grantResults[0] == PackageManager.PERMISSION_DENIED) { // If permissions get denied here, we ignore them and just don't // enable My Location support. mPermissionsDenied = true; } else { // Permissions... HO!!!! permissionsGranted(); mPermissionsDenied = false; } } private void permissionsGranted() { try { mMap.setMyLocationEnabled(true); } catch(SecurityException se) { // This shouldn't happen (permissionsGranted is called AFTER we get // permissions), but Android Studio simply is NOT going to be happy // unless I surround it with a try/catch, so... checkLocationPermissions(0); } } private boolean doReadyChecks() { if(mMapIsReady && mGoogleClient != null && mGoogleClient.isConnected()) { // The map should be centered on the currently-known locations. // Otherwise, well, default to dead zero, I guess. Log.d(DEBUG_TAG, "There are " + mLocations.size() + " known location(s)."); // Throw any search addresses back on the map. if(mActiveAddresses != null) doAddressMarkers(mActiveAddresses); else mActiveAddressMap = HashBiMap.create(); // Known locations also ought to be initialized. initKnownLocations(); if(!mReloaded) { // If we're reloading, I think the map fragment knows to restore // itself. If not, we default to the current KnownLocations. if(!mLocations.isEmpty()) { CameraUpdate cam; LatLngBounds.Builder builder = LatLngBounds.builder(); for(KnownLocation kl : mLocations) { // Now, we want to include the range of each location, // too. Unfortunately, Android doesn't supply us with // a method for "calculate point that is X distance at Y // heading from another point" (or, the inverse geodetic // problem, as it's better known). So for this, we turn // to the OpenSextant library! Geodetic2DPoint gPoint = new Geodetic2DPoint( new Longitude(kl.getLatLng().longitude, Angle.DEGREES), new Latitude(kl.getLatLng().latitude, Angle.DEGREES)); // Start at due north and include it. Geodetic2DArc gArc = new Geodetic2DArc(gPoint, kl.getRange(), new Angle(0, Angle.DEGREES)); builder.include(new LatLng(gArc.getPoint2().getLatitudeAsDegrees(), gArc.getPoint2().getLongitudeAsDegrees())); // Repeat for the other cardinal directions. That'll // give us what we need to fit the circle. gArc.setForwardAzimuth(new Angle(90, Angle.DEGREES)); builder.include(new LatLng(gArc.getPoint2().getLatitudeAsDegrees(), gArc.getPoint2().getLongitudeAsDegrees())); gArc.setForwardAzimuth(new Angle(180, Angle.DEGREES)); builder.include(new LatLng(gArc.getPoint2().getLatitudeAsDegrees(), gArc.getPoint2().getLongitudeAsDegrees())); gArc.setForwardAzimuth(new Angle(270, Angle.DEGREES)); builder.include(new LatLng(gArc.getPoint2().getLatitudeAsDegrees(), gArc.getPoint2().getLongitudeAsDegrees())); } LatLngBounds bounds = builder.build(); cam = CameraUpdateFactory.newLatLngBounds(bounds, getResources().getDimensionPixelSize(R.dimen.map_zoom_padding)); mMap.animateCamera(cam); } } return true; } else { return false; } } @Override public void onMapLongClick(LatLng latLng) { // If there's already a marker, clear it out. if(mMapClickMarker != null) { mMapClickMarker.remove(); mMapClickMarker = null; mMapClickMarkerOptions = null; } // If the user long-taps the map, we place a marker on the map and offer // the user the option to add that as a known location. We want to keep // track of the MarkerOptions object because that's Parcelable, allowing // us to stash it away if we need to save the activity's bundle state. mMapClickMarkerOptions = createMarker(latLng, null); mMapClickMarker = mMap.addMarker(mMapClickMarkerOptions); mMapClickMarker.showInfoWindow(); } @Override public void onInfoWindowClick(Marker marker) { // Is this marker associated with a KnownLocation or Address? If so, we // can init the data with that, AND keep track of it. String name = ""; double range = 5.0; boolean restrict = false; KnownLocation loc = null; Address address = null; if(mMarkerMap.containsKey(marker)) { // Got it! loc = mMarkerMap.get(marker); name = loc.getName(); range = loc.getRange(); restrict = loc.isRestrictedGraticule(); } else if(mActiveAddressMap.containsKey(marker)) { // An address! address = mActiveAddressMap.get(marker); name = address.getFeatureName(); } mActiveMarker = marker; // Now, we've got a dialog to pop up! Bundle args = new Bundle(); args.putString(NAME, name); args.putParcelable(EXISTING, loc); args.putParcelable(ADDRESS, address); args.putParcelable(LATLNG, marker.getPosition()); args.putDouble(RANGE, range); args.putBoolean(RESTRICT, restrict); EditKnownLocationDialog dialog = new EditKnownLocationDialog(); dialog.setArguments(args); dialog.show(getFragmentManager(), EDIT_DIALOG); } @Override public boolean onMarkerClick(Marker marker) { return false; } @NonNull private MarkerOptions createMarker(@NonNull LatLng latLng, @Nullable String title) { // This builds up the basic marker for a potential KnownLocation. By // "potential", I mean something that isn't stored yet as a // KnownLocation, such as search results or map taps. KnownLocation // ITSELF has a makeMarker method. if(title == null || title.isEmpty()) title = UnitConverter.makeFullCoordinateString(this, latLng, false, UnitConverter.OUTPUT_SHORT); return new MarkerOptions() .position(latLng) .flat(true) .icon(BitmapDescriptorFactory.fromResource(R.drawable.known_location_tap_marker)) .anchor(0.5f, 0.5f) .title(title) .snippet(getString(R.string.known_locations_tap_to_add)); } private void initKnownLocations() { if(mMarkerMap != null) { for(Marker m : mMarkerMap.keySet()) m.remove(); } if(mCircleMap != null) { for(Circle c : mCircleMap.keySet()) c.remove(); } mMarkerMap = HashBiMap.create(); mCircleMap = HashBiMap.create(); if(!mLocations.isEmpty()) { for(KnownLocation kl : mLocations) { // Each KnownLocation gives us a MarkerOptions we can use. Log.d(DEBUG_TAG, "Making marker for KnownLocation " + kl.toString() + " at a range of " + kl.getRange() + "m"); Marker newMark = mMap.addMarker(makeExistingMarker(kl)); Circle newCircle = mMap.addCircle(kl.makeCircle(this)); mMarkerMap.put(newMark, kl); mCircleMap.put(newCircle, kl); } } } @NonNull private MarkerOptions makeExistingMarker(@NonNull KnownLocation loc) { return loc.makeMarker(this).snippet(getString(R.string.known_locations_tap_to_edit)); } private void confirmKnownLocationFromDialog(@NonNull String name, @NonNull LatLng location, double range, boolean restrictGraticule, @NonNull Address address) { // An address! We know what to do with this, right? KnownLocation newLoc = new KnownLocation(name, location, range, restrictGraticule); // Of course we do! It's guaranteed to be a new marker! mLocations.add(newLoc); // And what's more, it's guaranteed to have an old version on the map! mActiveAddressMap.inverse().remove(address).remove(); // Then, replace it with the new one. Marker newMark = mMap.addMarker(makeExistingMarker(newLoc)); Circle newCircle = mMap.addCircle(newLoc.makeCircle(this)); mMarkerMap.forcePut(newMark, newLoc); mCircleMap.forcePut(newCircle, newLoc); KnownLocation.storeKnownLocations(this, mLocations); mActiveAddresses.remove(address); if(mActiveMarker != null) mActiveMarker.remove(); // Done! removeActiveKnownLocation(); } private void confirmKnownLocationFromDialog(@NonNull String name, @NonNull LatLng location, double range, boolean restrictGraticule, @Nullable KnownLocation existing) { // Okay, we got location data in. Make one! KnownLocation newLoc = new KnownLocation(name, location, range, restrictGraticule); // Is this new or a replacement? if(existing != null) { // Replacement! Or rather, remove the old one and re-add the new // one in place. int oldIndex = mLocations.indexOf(existing); mLocations.remove(oldIndex); mLocations.add(oldIndex, newLoc); // Since this is an existing KnownLocation, the marker should be in // that map, ripe for removal. mMarkerMap.inverse().remove(existing).remove(); mCircleMap.inverse().remove(existing).remove(); } else { // Brand new! mLocations.add(newLoc); } // In both cases, store the data and add a new marker. Marker newMark = mMap.addMarker(makeExistingMarker(newLoc)); Circle newCircle = mMap.addCircle(newLoc.makeCircle(this)); mMarkerMap.forcePut(newMark, newLoc); mCircleMap.forcePut(newCircle, newLoc); KnownLocation.storeKnownLocations(this, mLocations); // And remove the marker from the map. The visual one this time. // TODO: Null-checking shouldn't be necessary here. if(mActiveMarker != null) mActiveMarker.remove(); // And end the active parts. removeActiveKnownLocation(); } private void deleteActiveKnownLocation(@NonNull KnownLocation existing) { // This better exist, else we're in trouble. if(!mMarkerMap.containsValue(existing)) return; // Clear it from the map and from the marker list. Marker marker = mMarkerMap.inverse().get(existing); marker.remove(); mMarkerMap.remove(marker); mCircleMap.inverse().remove(existing).remove(); // Then, remove it from the location list and push that back to the // preferences. mLocations.remove(existing); KnownLocation.storeKnownLocations(this, mLocations); // Also, clear out the active location and marker. removeActiveKnownLocation(); } private void removeActiveKnownLocation() { mActiveMarker = null; mMapClickMarkerOptions = null; } private void searchForLocation(@NonNull String input) { // If we didn't init a Geocoder by this point, that means the search box // shouldn't have been available. if(mGeocoder == null) return; // Same if this was a blank input. if(input.trim().isEmpty()) return; // Disable the input field and search button until we're done. findViewById(R.id.search).setEnabled(false); findViewById(R.id.search_go).setEnabled(false); // Let's do it this way: We try to search, and if the Activity goes away // by the time it comes back, we act like it never happened. That's the // simplest way around it. if(mSearchTask != null) { mSearchTask.cancel(false); } // Fire up a task! Remember, getProjection and getCameraPosition need // to be called on main, so we pass those in to the AsyncTask. mSearchTask = new LocationSearchTask(mMap.getProjection().getVisibleRegion(), mMap.getCameraPosition().bearing); mSearchTask.execute(input); } private void searchResults(LookupErrorCode code, @NonNull List<Address> addresses) { // No matter what, a result means the searchy parts come back on. findViewById(R.id.search).setEnabled(true); findViewById(R.id.search_go).setEnabled(true); // If anything went wrong, report it, but don't remove any markers we // already have on the map. But if we got something... if(code == LookupErrorCode.OKAY) { Log.d(DEBUG_TAG, "Addresses found: " + addresses.size()); for(Address a : addresses) { Log.d(DEBUG_TAG, "Address: " + a.toString()); } doAddressMarkers(addresses); // Reposition the map, too. LatLngBounds.Builder builder = LatLngBounds.builder(); for(Address a : addresses) { builder.include(new LatLng(a.getLatitude(), a.getLongitude())); } mMap.animateCamera(CameraUpdateFactory.newLatLngBounds(builder.build(), getResources().getDimensionPixelSize(R.dimen.map_zoom_padding))); } else { // Else we TOAST! int resId = R.string.known_locations_search_error_internal_error; String debugString = "Fell through the switch?"; switch(code) { case NO_RESULTS: resId = R.string.known_locations_search_error_no_results; debugString = "There weren't any results."; break; case IO_ERROR: resId = R.string.known_locations_search_error_io_error; debugString = "I/O error; probably no network connection."; break; case NO_GEOCODER: resId = R.string.known_locations_search_error_no_geocoder; debugString = "No geocoder; how did we get here in the first place?"; break; case INTERNAL_ERROR: resId = R.string.known_locations_search_error_internal_error; debugString = "Internal error; this'll probably result in a bug report..."; break; case CANCELED: // This really shouldn't have happened, but we can ignore it // anyway. Log.d(DEBUG_TAG, "Search was canceled; not actually sure how we got here, but ignoring..."); return; } Toast.makeText(this, resId, Toast.LENGTH_LONG).show(); Log.w(DEBUG_TAG, "Location search lookup error: " + debugString); } } private void doAddressMarkers(@NonNull List<Address> addresses) { // Wipe out any current markers, if any. if(mActiveAddressMap != null) { for(Marker m : mActiveAddressMap.keySet()) { m.remove(); } } mActiveAddressMap = HashBiMap.create(); // Keep track of the current address list. mActiveAddresses = addresses; // Now, throw down a bunch of brand new markers. for(Address a : addresses) { LatLng curPos = new LatLng(a.getLatitude(), a.getLongitude()); MarkerOptions opts = new MarkerOptions() .position(curPos) .title(a.getFeatureName() == null ? curPos.latitude + ", " + curPos.longitude : a.getFeatureName()) .icon(BitmapDescriptorFactory.fromBitmap(makeAddressBitmap(curPos))) .anchor(0.5f, 1.0f) .snippet(getString(R.string.known_locations_tap_to_add)); mActiveAddressMap.put(mMap.addMarker(opts), a); } } @NonNull private Bitmap makeAddressBitmap(LatLng loc) { // The signpost for address search results will just be two rectangles. // The top rectangle will be the color the pin will be. int dim = getResources().getDimensionPixelSize(R.dimen.known_location_marker_canvas_size); Bitmap bitmap = Bitmap.createBitmap(dim, dim, Bitmap.Config.ARGB_8888); Canvas canvas = new Canvas(bitmap); Paint paint = new Paint(); paint.setAntiAlias(true); paint.setStrokeWidth(getResources().getDimension(R.dimen.known_location_stroke)); KnownLocationPinData pinData = new KnownLocationPinData(this, loc); // Draw us a rectangle. Centered horizontally, anchored to the bottom // of the canvas. Draw the color block first, then outline it. int width = getResources().getDimensionPixelSize(R.dimen.known_location_address_post_width); int height = getResources().getDimensionPixelSize(R.dimen.known_location_address_post_height); paint.setColor(Color.HSVToColor(new float[]{25, 1.0f, 0.36f})); paint.setStyle(Paint.Style.FILL); canvas.drawRect((dim - width) / 2, dim - height, (dim + width) / 2, dim, paint); paint.setColor(Color.BLACK); paint.setStyle(Paint.Style.STROKE); canvas.drawRect((dim - width) / 2, dim - height, (dim + width) / 2, dim, paint); // Then, draw us another rectangle. Center it horizontally again, inset // it from the top by a little bit. width = getResources().getDimensionPixelSize(R.dimen.known_location_address_sign_width); height = getResources().getDimensionPixelSize(R.dimen.known_location_address_sign_height); int inset = getResources().getDimensionPixelSize(R.dimen.known_location_address_sign_inset); paint.setColor(pinData.getColor()); paint.setStyle(Paint.Style.FILL); canvas.drawRect((dim - width) / 2, inset, (dim + width) / 2, inset + height, paint); paint.setColor(Color.BLACK); paint.setStyle(Paint.Style.STROKE); canvas.drawRect((dim - width) / 2, inset, (dim + width) / 2, inset + height, paint); // And one white rectangle so the sign isn't completely blank. No // outline this time around. int innerInset = getResources().getDimensionPixelSize(R.dimen.known_location_address_sign_inner_inset); paint.setColor(Color.WHITE); paint.setStyle(Paint.Style.FILL); canvas.drawRect((dim - width) / 2 + innerInset, inset + innerInset, (dim + width) / 2 - innerInset, inset + height - innerInset, paint); return bitmap; } }