Java tutorial
/** * CentralMap.java * Copyright (C)2015 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.Manifest; import android.annotation.SuppressLint; import android.app.Activity; import android.app.Dialog; import android.app.DialogFragment; import android.content.BroadcastReceiver; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.IntentFilter; import android.content.IntentSender; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.location.Location; import android.net.Uri; import android.os.Bundle; import android.os.Parcelable; import android.preference.PreferenceManager; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v4.app.ActivityCompat; import android.util.Log; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import com.commonsware.cwac.wakeful.WakefulIntentService; import com.google.android.gms.common.ConnectionResult; import com.google.android.gms.common.GoogleApiAvailability; import com.google.android.gms.common.api.GoogleApiClient; import com.google.android.gms.location.LocationListener; import com.google.android.gms.location.LocationRequest; import com.google.android.gms.location.LocationServices; 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.Marker; import com.google.android.gms.maps.model.MarkerOptions; import net.exclaimindustries.geohashdroid.R; import net.exclaimindustries.geohashdroid.fragments.AboutDialogFragment; import net.exclaimindustries.geohashdroid.fragments.GHDDatePickerDialogFragment; import net.exclaimindustries.geohashdroid.fragments.MapTypeDialogFragment; import net.exclaimindustries.geohashdroid.fragments.PermissionDeniedDialogFragment; import net.exclaimindustries.geohashdroid.fragments.VersionHistoryDialogFragment; import net.exclaimindustries.geohashdroid.services.AlarmService; import net.exclaimindustries.geohashdroid.services.StockService; import net.exclaimindustries.geohashdroid.util.ExpeditionMode; import net.exclaimindustries.geohashdroid.util.GHDConstants; import net.exclaimindustries.geohashdroid.util.Graticule; import net.exclaimindustries.geohashdroid.util.Info; import net.exclaimindustries.geohashdroid.util.PermissionsDeniedListener; import net.exclaimindustries.geohashdroid.util.SelectAGraticuleMode; import net.exclaimindustries.geohashdroid.util.UnitConverter; import net.exclaimindustries.geohashdroid.util.VersionHistoryParser; import net.exclaimindustries.geohashdroid.widgets.ErrorBanner; import net.exclaimindustries.tools.LocationUtil; import org.xmlpull.v1.XmlPullParserException; import java.text.DateFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; import java.util.HashSet; import java.util.Set; /** * CentralMap replaces MainMap as the map display. Unlike MainMap, it also * serves as the entry point for the entire app. These comments are going to * make so much sense later when MainMap is little more than a class that only * exists on the legacy branch. */ public class CentralMap extends Activity implements GoogleApiClient.ConnectionCallbacks, GoogleApiClient.OnConnectionFailedListener, GHDDatePickerDialogFragment.GHDDatePickerCallback, MapTypeDialogFragment.MapTypeCallback { private static final String DEBUG_TAG = "CentralMap"; private static final String DATE_PICKER_DIALOG = "datePicker"; private static final String MAP_TYPE_DIALOG = "mapType"; private static final String VERSION_HISTORY_DIALOG = "versionHistory"; private static final String ABOUT_DIALOG = "about"; private static final String STATE_WAS_ALREADY_ZOOMED = "alreadyZoomed"; private static final String STATE_WAS_SELECT_A_GRATICULE = "selectAGraticule"; private static final String STATE_WAS_GLOBALHASH = "globalhash"; private static final String STATE_WAS_RESOLVING_CONNECTION_ERROR = "resolvingError"; private static final String STATE_WERE_PERMISSIONS_DENIED = "permissionsDenied"; private static final String STATE_LAST_GRATICULE = "lastGraticule"; private static final String STATE_LAST_CALENDAR = "lastCalendar"; private static final String STATE_MAP_TYPE = "mapType"; private static final String STATE_INFO = "info"; private static final String STATE_LAST_MODE_BUNDLE = "lastModeBundle"; private static final int LOCATION_PERMISSION_REQUEST = 1; // If we're in Select-A-Graticule mode (as opposed to expedition mode). private boolean mSelectAGraticule = false; // If we already did the initial zoom for this expedition. private boolean mAlreadyDidInitialZoom = false; // If the map's ready. private boolean mMapIsReady = false; private Info mCurrentInfo; private GoogleMap mMap; private GoogleApiClient mGoogleClient; private Location mLastKnownLocation; // This is either the current expedition Graticule (same as in mCurrentInfo) // or the last-selected Graticule in Select-A-Graticule mode (needed if we // need to reconstruct from an onDestroy()). private Graticule mLastGraticule; private Calendar mLastCalendar; // Because a null Graticule is considered to be the Globalhash indicator, we // need a boolean to keep track of whether we're actually in a Globalhash or // if we just don't have a Graticule yet. private boolean mGlobalhash; private ErrorBanner mBanner; private Bundle mLastModeBundle; private CentralMapMode mCurrentMode; // Request code to use when launching the resolution activity. private static final int REQUEST_RESOLVE_ERROR = 1001; // Unique tag for the error dialog fragment. private static final String DIALOG_API_ERROR = "ApiErrorDialog"; // Bool to track whether the app is already resolving an error. private boolean mResolvingError = false; // Bool to track whether or not the user's refused permissions. private boolean mPermissionsDenied = false; /** * <p> * A <code>CentralMapMode</code> is a set of behaviors that happen whenever * some corresponding event occurs in {@link CentralMap}. * </p> * * <p> * Note the {@link #pause()} and {@link #resume()} methods. While those * correspond to {@link CentralMap}'s onPause and onResume methods, there is * NOT a similar lifecycle in <code>CentralMapMode</code>. That is, * {@link #pause()} and {@link #resume()} are NOT guaranteed to be called in * any relation to {@link #init(Bundle)} or {@link #cleanUp()}. If there's * never an onPause or onResume in the life of a CentralMapMode, it will NOT * receive the corresponding calls, and will instead just get the * {@link #init(Bundle)} and {@link #cleanUp()} calls. * </p> */ public abstract static class CentralMapMode implements LocationListener, PermissionsDeniedListener { protected boolean mInitComplete = false; private boolean mCleanedUp = false; /** Bundle key for the current Graticule. */ public final static String GRATICULE = "graticule"; /** Bundle key for the current date, as a Calendar. */ public final static String CALENDAR = "calendar"; /** * Bundle key for a boolean indicating that, if the Graticule is null, * this was actually a Globalhash, not just a request with an empty * Graticule. */ public final static String GLOBALHASH = "globalhash"; /** * Bundle key for the current Info. In cases where this can be given, * the Graticule, Calendar, and boolean indicating a Globalhash can be * implied from it. */ public final static String INFO = "info"; /** The current GoogleMap object. */ protected GoogleMap mMap; /** The calling CentralMap Activity. */ protected CentralMap mCentralMap; /** The current destination Marker. */ protected Marker mDestination; /** * Sets the {@link GoogleMap} this mode deals with. When implementing * this, make sure to actually do something with it like subscribe to * events as the mode needs them if you're not doing so in * {@link #init(Bundle)}. * * @param map that map */ public void setMap(@NonNull GoogleMap map) { mMap = map; } /** * Sets the {@link CentralMap} to which this will talk back. * * @param centralMap that CentralMap */ public void setCentralMap(@NonNull CentralMap centralMap) { mCentralMap = centralMap; } /** * Gets the current GoogleApiClient held by CentralMap. This will * return null if the client isn't usable (not connected, null itself, * etc). * * @return the current GoogleApiClient */ @Nullable protected final GoogleApiClient getGoogleClient() { if (mCentralMap != null) { GoogleApiClient gClient = mCentralMap.getGoogleClient(); if (gClient != null && gClient.isConnected()) return gClient; else return null; } else { return null; } } /** * <p> * Does whatever init tomfoolery is needed for this class, using the * given Bundle of stuff. You're probably best calling this AFTER * {@link #setMap(GoogleMap)} and {@link #setCentralMap(CentralMap)} are * called and when the GoogleApiClient object is ready for use. * </p> * * @param bundle a bunch of stuff, or null if there's no stuff to be had */ public abstract void init(@Nullable Bundle bundle); /** * Does whatever cleanup rigmarole is needed for this class, such as * unsubscribing to all those subscriptions you set up in {@link #setMap(GoogleMap)} * or {@link #init(Bundle)}. */ public void cleanUp() { // The marker always goes away, at the very least. removeDestinationPoint(); if (mCentralMap != null) mCentralMap.getErrorBanner().animateBanner(false); // Set the cleaned up flag, too. mCleanedUp = true; } /** * Stores the state of this mode into yonder Bundle. This is NOT * guaranteed to be followed by {@link #cleanUp()}, apparently. Did not * know that at first. This is also where you write out any data that * might be useful to other modes, such as the selected Graticule in * SelectAGraticuleMode. * * @param bundle the Bundle to which to write data. */ public abstract void onSaveInstanceState(@NonNull Bundle bundle); /** * Called when the Activity gets onPause(). Remember, the mode object * might not ever get this call. This is only if the Activity is * EXPLICITLY pausing AFTER this mode was created. */ public abstract void pause(); /** * Called when the Activity gets onResume(). Remember, the mode object * might not ever get this call. This is only if the Activity is * EXPLICITLY resuming AFTER this mode was created. */ public abstract void resume(); /** * Convenience method to call {@link CentralMap#requestStock(Graticule, Calendar, int)}. * * @param g the Graticule (can be null for globalhashes) * @param c the Calendar * @param flags the {@link StockService} flags */ protected void requestStock(@Nullable Graticule g, @NonNull Calendar c, int flags) { mCentralMap.getErrorBanner().animateBanner(false); mCentralMap.requestStock(g, c, flags); } /** * Called when a new Info has come in from StockService. * * @param info that Info * @param nearby any nearby Infos that may have been requested (can be null) * @param flags the request flags that were sent with it */ public abstract void handleInfo(Info info, @Nullable Info[] nearby, int flags); /** * Called when a stock lookup fails for some reason. * * @param reqFlags the flags used in the request * @param responseCode the response code (won't be {@link StockService#RESPONSE_OKAY}, for obvious reasons) */ public abstract void handleLookupFailure(int reqFlags, int responseCode); /** * Called when the menu needs to be built. * * @param c the current Context (the mode may not be fully up by the time this is needed, and thus may not have mCentralMap) * @param inflater a MenuInflater, for convenience * @param menu the Menu that needs inflating. */ public abstract void onCreateOptionsMenu(Context c, MenuInflater inflater, Menu menu); /** * Called when a menu item is selected but CentralMap didn't handle it * itself. * * @param item the item that got selected * @return true if it was handled, false if not */ public abstract boolean onOptionsItemSelected(MenuItem item); /** * Called when a new Calendar comes in. The modes should update as need * be. This should mean calling for a new Info from StockService, but * NOT updating its own Info or concept of the current Calendar if there * was a problem with the stock (i.e. it wasn't posted yet). * * @param newDate the new Calendar */ public abstract void changeCalendar(@NonNull Calendar newDate); /** * Draws a final destination point on the map given the appropriate * Info. This also removes any old point that might've been around. * * @param info the new Info */ protected void addDestinationPoint(Info info) { // Clear any old destination marker first. removeDestinationPoint(); if (info == null) return; // We need a marker! And that marker needs a title. And that title // depends on globalhashiness and retroness. String title; if (!info.isRetroHash()) { // Non-retro hashes don't have today's date on them. They just // have "today's [something]". if (info.isGlobalHash()) { title = mCentralMap.getString(R.string.marker_title_today_globalpoint); } else { title = mCentralMap.getString(R.string.marker_title_today_hashpoint); } } else { // Retro hashes, however, need a date string. String date = DateFormat.getDateInstance(DateFormat.LONG).format(info.getDate()); if (info.isGlobalHash()) { title = mCentralMap.getString(R.string.marker_title_retro_globalpoint, date); } else { title = mCentralMap.getString(R.string.marker_title_retro_hashpoint, date); } } // The snippet's just the coordinates in question. Further details // will go in the infobox. String snippet = UnitConverter.makeFullCoordinateString(mCentralMap, info.getFinalLocation(), false, UnitConverter.OUTPUT_LONG); // Under the current marker image, the anchor is the very bottom, // halfway across. Presumably, that's what the default icon also // uses, but we're not concerned with the default icon, now, are we? mDestination = mMap.addMarker(new MarkerOptions().position(info.getFinalDestinationLatLng()) .icon(BitmapDescriptorFactory.fromResource(R.drawable.final_destination)).anchor(0.5f, 1.0f) .title(title).snippet(snippet)); } /** * Removes the destination point, if one exists. */ protected void removeDestinationPoint() { if (mDestination != null) { mDestination.remove(); mDestination = null; } } /** * Sets the title of the map Activity using a String. * * @param title the new title */ protected final void setTitle(String title) { mCentralMap.setTitle(title); } /** * Sets the title of the map Activity using a resource ID. * * @param resid the new title's resource ID */ protected final void setTitle(int resid) { mCentralMap.setTitle(resid); } /** * Returns whether or not {@link #cleanUp()} has been called yet. If * so, you should generally not call anything else. * * @return true if cleaned up, false if not */ public final boolean isCleanedUp() { return mCleanedUp; } /** * Returns whether or not {@link #init(Bundle)} has finished. If so, * you probably shouldn't call init again, and are probably looking to * call resume instead. * * @return true if init is complete, false if not */ public final boolean isInitComplete() { return mInitComplete; } @Override public String toString() { return this.getClass().getSimpleName(); } /** * Gets the user's last known location as seen by CentralMap. Note that * this may be null if the user's location isn't known yet (or if the * user refused location permissions). Also, there's no guarantee that * this is at all recent. * * @return a Location, or null */ @Nullable protected Location getLastKnownLocation() { if (mCentralMap != null) return mCentralMap.mLastKnownLocation; else return null; } /** * <p> * Gets whether or not the user explicitly denied permissions during * this session. Updates on this state will be sent via {@link #permissionsDenied(boolean)}, * but this should be called during {@link #init(Bundle)} to get things * set up initially. * </p> * * <p> * Remember, just because this returns false does NOT mean permissions * have been GRANTED; it just means permissions weren't DENIED, and most * importantly, weren't denied YET. There is a difference. In the * false case, for instance, the user might still be being prompted for * permissions, in which case {@link #permissionsDenied(boolean)} might * eventually come up with true. * </p> * * @return true if permissions were denied, false if not */ protected boolean arePermissionsDenied() { return mCentralMap != null && mCentralMap.mPermissionsDenied; } } private class StockReceiver extends BroadcastReceiver { private final static String DEBUG_TAG = "StockReceiver"; // This allows us to NOT blast out responses if the current mode didn't // request it. private Set<Long> mWaitingList; public StockReceiver() { mWaitingList = new HashSet<>(); } /** * Adds the given ID to the waiting list. If an ID comes back and it * wasn't in the waiting list, it won't be dispatched to the modes. * * @param id the request ID */ public void addToWaitingList(long id) { mWaitingList.add(id); } /** * Removes all waiting IDs from the list */ public void clearWaitingList() { // Yes, since we can have multiple IDs pointing to the same mode, we // have to do it this way. mWaitingList.clear(); } @Override public void onReceive(Context context, Intent intent) { Log.d(DEBUG_TAG, "Stock has come in!"); Bundle bun = intent.getBundleExtra(StockService.EXTRA_STUFF); bun.setClassLoader(getClassLoader()); // A stock result arrives! Let's get data! That oughta tell us // whether or not we're even going to bother with it. int reqFlags = bun.getInt(StockService.EXTRA_REQUEST_FLAGS, 0); long reqId = bun.getLong(StockService.EXTRA_REQUEST_ID, -1); Calendar cal = (Calendar) bun.getSerializable(StockService.EXTRA_DATE); // Now, if the flags state this was from the alarm or somewhere else // we weren't expecting, give up now. We don't want it. if ((reqFlags & StockService.FLAG_ALARM) != 0) return; // Well, it's what we're looking for. What was the result? The // default is RESPONSE_NETWORK_ERROR, as not getting a response code // is a Bad Thing(tm). int responseCode = bun.getInt(StockService.EXTRA_RESPONSE_CODE, StockService.RESPONSE_NETWORK_ERROR); // Since the mode switchers wipe all requests from a given mode, all // we need for a mode match is whether or not the item exists in the // waiting list. boolean modeMatches = mWaitingList.remove(reqId); if (responseCode == StockService.RESPONSE_OKAY) { // Hey, would you look at that, it actually worked! So, get // the Info out of it and fire it away to the corresponding // CentralMapMode, if applicable. if (modeMatches) { Info received = bun.getParcelable(StockService.EXTRA_INFO); Parcelable[] pArr = bun.getParcelableArray(StockService.EXTRA_NEARBY_POINTS); Info[] nearby = null; if (pArr != null) nearby = Arrays.copyOf(pArr, pArr.length, Info[].class); mCurrentMode.handleInfo(received, nearby, reqFlags); } else { Log.w(DEBUG_TAG, "Request ID " + reqId + " was NOT expected by this mode, ignoring..."); } } else { // Make sure the mode knows what's up first. if (modeMatches) mCurrentMode.handleLookupFailure(reqFlags, responseCode); if ((reqFlags & StockService.FLAG_USER_INITIATED) != 0) { // ONLY notify the user of an error if they specifically // requested this stock. switch (responseCode) { case StockService.RESPONSE_NOT_POSTED_YET: // Just in case, change the text if it's today's // date that was requested. That's a bit clearer. Calendar today = Calendar.getInstance(); boolean isActuallyToday = (cal != null && today.get(Calendar.YEAR) == cal.get(Calendar.YEAR) && today.get(Calendar.MONTH) == cal.get(Calendar.MONTH) && today.get(Calendar.DAY_OF_MONTH) == cal.get(Calendar.DAY_OF_MONTH)); mBanner.setText(getString(isActuallyToday ? R.string.error_not_yet_posted_today : R.string.error_not_yet_posted)); mBanner.setErrorStatus(ErrorBanner.Status.ERROR); mBanner.animateBanner(true); break; case StockService.RESPONSE_NO_CONNECTION: mBanner.setText(getString(R.string.error_no_connection)); mBanner.setErrorStatus(ErrorBanner.Status.ERROR); mBanner.animateBanner(true); break; case StockService.RESPONSE_NETWORK_ERROR: mBanner.setText(getString(R.string.error_server_failure)); mBanner.setErrorStatus(ErrorBanner.Status.ERROR); mBanner.animateBanner(true); break; default: break; } } } } } private StockReceiver mStockReceiver = new StockReceiver(); private LocationListener mLocationListener = new LocationListener() { @Override public void onLocationChanged(Location location) { // New location! mLastKnownLocation = location; if (mCurrentMode != null) mCurrentMode.onLocationChanged(location); } }; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); int mapType = -1; // Load up! if (savedInstanceState != null) { mCurrentInfo = savedInstanceState.getParcelable(STATE_INFO); mAlreadyDidInitialZoom = savedInstanceState.getBoolean(STATE_WAS_ALREADY_ZOOMED, false); mSelectAGraticule = savedInstanceState.getBoolean(STATE_WAS_SELECT_A_GRATICULE, false); mGlobalhash = savedInstanceState.getBoolean(STATE_WAS_GLOBALHASH, false); mResolvingError = savedInstanceState.getBoolean(STATE_WAS_RESOLVING_CONNECTION_ERROR, false); mPermissionsDenied = savedInstanceState.getBoolean(STATE_WERE_PERMISSIONS_DENIED, false); mLastGraticule = savedInstanceState.getParcelable(STATE_LAST_GRATICULE); mLastCalendar = (Calendar) savedInstanceState.getSerializable(STATE_LAST_CALENDAR); // This will just get dropped right back into the mode wholesale. mLastModeBundle = savedInstanceState.getBundle(STATE_LAST_MODE_BUNDLE); // Map type? mapType = savedInstanceState.getInt(STATE_MAP_TYPE, -1); } // Finalize the map type. That's going into a callback. final int reallyMapType = mapType; setContentView(R.layout.centralmap); // We deal with locations, so we deal with the GoogleApiClient. It'll // connect during onStart. mGoogleClient = new GoogleApiClient.Builder(this).addConnectionCallbacks(this) .addOnConnectionFailedListener(this).addApi(LocationServices.API).build(); mBanner = (ErrorBanner) findViewById(R.id.error_banner); // Get a map ready. We'll know when we've got it. Oh, we'll know. 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 we're going to have the // infobox right around there. set.setMyLocationButtonEnabled(false); // Restore the map's type, if it was changed. if (reallyMapType >= 0) mMap.setMapType(reallyMapType); // Now, set the flag that tells everything else (especially the // doReadyChecks method) we're ready. Then, call doReadyChecks. // We might still be waiting on the API. mMapIsReady = true; doReadyChecks(); } }); // If at this point we don't have any mode bundle, we're starting in // ExpeditionMode with a flag set. This means that this overrides // the boolean. if (mLastModeBundle == null) { mLastModeBundle = new Bundle(); mLastModeBundle.putBoolean(ExpeditionMode.DO_INITIAL_START, true); mSelectAGraticule = false; } // Perform startup and cleanup work before the modes arrive. doStartupStuff(); // Now, we get our initial mode set up based on mSelectAGraticule. We // do NOT init it yet; we have to wait for both the map fragment and the // API to be ready first. if (mSelectAGraticule) mCurrentMode = new SelectAGraticuleMode(); else mCurrentMode = new ExpeditionMode(); } @Override protected void onPause() { // The modes should know what they need to do when pausing. if (mCurrentMode != null) mCurrentMode.pause(); super.onPause(); } @Override protected void onResume() { super.onResume(); // Do a permissions check. If it turns out we DO have permissions, we // can mark the denied flag as false. This covers cases where the user // denies permission at one point, suspends the Activity, grants it some // other way, and returns. I'm quite certain someone will make a really // big deal out of it and claim it's a common use case if I don't catch // this circumstance. if (checkLocationPermissions(0, true)) mPermissionsDenied = false; // The mode will resume itself once the client comes back in from // onStart. } @Override protected void onStart() { super.onStart(); // The receiver goes on during onStart, since the modes might need it // before onResume has a chance to kick in, thanks to the possibility of // the API connection happening really quickly. IntentFilter filt = new IntentFilter(); filt.addAction(StockService.ACTION_STOCK_RESULT); registerReceiver(mStockReceiver, filt); // Service up! mGoogleClient.connect(); } @Override protected void onStop() { // The receiver goes right off as soon as we stop. unregisterReceiver(mStockReceiver); // TODO: I probably want this in onPause, not onStop, but the Google API // client disconnect hits here, not in onPause, so I'd have to keep // track of more things to make sure I know if I need to start listening // again on onResume or wait for the client to reconnect. And I don't // want the client disconnecting on onPause. stopListening(); // Service down! mGoogleClient.disconnect(); super.onStop(); } @Override protected void onDestroy() { // Make sure that mode's been cleaned up first. mCurrentMode.cleanUp(); super.onDestroy(); } @Override protected void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); // Also, keep the latest Info around. outState.putParcelable(STATE_INFO, mCurrentInfo); // Keep the various flags, too. outState.putBoolean(STATE_WAS_ALREADY_ZOOMED, mAlreadyDidInitialZoom); outState.putBoolean(STATE_WAS_SELECT_A_GRATICULE, mSelectAGraticule); outState.putBoolean(STATE_WAS_GLOBALHASH, mGlobalhash); outState.putBoolean(STATE_WAS_RESOLVING_CONNECTION_ERROR, mResolvingError); outState.putBoolean(STATE_WERE_PERMISSIONS_DENIED, mPermissionsDenied); // And some additional data. outState.putParcelable(STATE_LAST_GRATICULE, mLastGraticule); outState.putSerializable(STATE_LAST_CALENDAR, mLastCalendar); // Aaaaaaaand the map type. if (mMap != null) outState.putInt(STATE_MAP_TYPE, mMap.getMapType()); // Also, shut down the current mode. We'll rebuild it later. Also, if // init isn't complete yet, don't update the state. if (mCurrentMode != null && mCurrentMode.isInitComplete()) { mLastModeBundle = new Bundle(); mCurrentMode.onSaveInstanceState(mLastModeBundle); } outState.putBundle(STATE_LAST_MODE_BUNDLE, mLastModeBundle); } @Override public boolean onCreateOptionsMenu(Menu menu) { MenuInflater inflater = getMenuInflater(); // Just hand it off to the current mode, it'll know what to do. mCurrentMode.onCreateOptionsMenu(this, inflater, menu); return true; } @Override public boolean onOptionsItemSelected(MenuItem item) { // CentralMap should just cover the items that can always be selected no // matter what mode we're in. switch (item.getItemId()) { case R.id.action_map_type: { // The map type can be changed at any time, so it has to be // common. To the alert dialog! MapTypeDialogFragment frag = MapTypeDialogFragment.newInstance(this); frag.show(getFragmentManager(), MAP_TYPE_DIALOG); return true; } case R.id.action_versionhistory: { // The version history has no real actions at all. VersionHistoryDialogFragment frag = VersionHistoryDialogFragment.newInstance(this); frag.show(getFragmentManager(), VERSION_HISTORY_DIALOG); return true; } case R.id.action_about: { // About is just a dialog with a view. AboutDialogFragment frag = AboutDialogFragment.newInstance(); frag.show(getFragmentManager(), ABOUT_DIALOG); return true; } case R.id.action_date: { // The date picker is common to all modes and is best handled by // the Activity itself. if (mLastCalendar == null) { // Of course, we need a date to fill in. mLastCalendar = Calendar.getInstance(); } GHDDatePickerDialogFragment frag = GHDDatePickerDialogFragment.newInstance(mLastCalendar); frag.setCallback(this); frag.show(getFragmentManager(), DATE_PICKER_DIALOG); return true; } case R.id.action_whatisthis: { // The everfamous and much-beloved "What's Geohashing?" button, // because honestly, this IS sort of confusing if you're // expecting something for geocaching. Intent i = new Intent(); i.setAction(Intent.ACTION_VIEW); i.setData(Uri.parse("http://wiki.xkcd.com/geohashing/How_it_works")); startActivity(i); return true; } case R.id.action_preferences: { // Preferences! To the Preferencemobile! Intent i = new Intent(this, PreferencesActivity.class); startActivity(i); return true; } default: return mCurrentMode.onOptionsItemSelected(item); } } @SuppressLint("CommitPrefEdits") private void doStartupStuff() { // This handles all the oddities that need to be covered at startup // time, including cleaning up old preferences that have been replaced // or otherwise changed, starting the stock alarm service if it should // be up, and throwing up the version history dialog if it's a new // version. SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); SharedPreferences.Editor edit = prefs.edit(); // Let's start with the stock alarm service. Intent i = new Intent(this, AlarmService.class); if (prefs.getBoolean(GHDConstants.PREF_STOCK_ALARM, false)) { // Alarm gets set! Fire it up! i.setAction(AlarmService.STOCK_ALARM_ON); } else { // No alarm! Off it goes! i.setAction(AlarmService.STOCK_ALARM_OFF); } startService(i); // Now for preference cleanup. Unfortunately, this section will only // get bigger with time, as I can't guarantee what version the user // might've come from. The version from which the user might've come. // The Infobox is now controlled by a boolean, not a string. if (prefs.contains("InfoBoxSize")) { if (!prefs.contains(GHDConstants.PREF_INFOBOX)) { String size; try { size = prefs.getString("InfoBoxSize", "None"); } catch (ClassCastException cce) { size = "Off"; } edit.putBoolean(GHDConstants.PREF_INFOBOX, size.equals("None")); } edit.remove("InfoBoxSize"); } // These prefs either don't exist any more or we found better ways to // deal with them. edit.remove("DefaultLatitude").remove("DefaultLongitude").remove("GlobalhashMode") .remove("RememberGraticule").remove("ClosestOn").remove("AlwaysToday").remove("ClosenessReported"); // Anything edit-worthy we just did needs to be committed. edit.commit(); // We still have that prefs object. Let's see if we've got a newer // version than what we last saw. int lastVersion = prefs.getInt(GHDConstants.PREF_LAST_SEEN_VERSION, 0); int curVersion = -1; try { curVersion = getPackageManager().getPackageInfo(getPackageName(), 0).versionCode; } catch (PackageManager.NameNotFoundException nnfe) { // Since this is OUR OWN PACKAGE NAME, this better work. } Log.d(DEBUG_TAG, "We are version " + curVersion + ", we last reported version history on version " + lastVersion); if (lastVersion < curVersion) { // Aha! We're newer! Now, let's see if there's a new version to // display. That is, if the first entry in version history is newer // than the last-seen version. ArrayList<VersionHistoryParser.VersionEntry> entries = new ArrayList<>(); try { entries = VersionHistoryParser.parseVersionHistory(this); } catch (XmlPullParserException xppe) { // You get NOTHING! } if (entries.isEmpty()) { Log.w(DEBUG_TAG, "Couldn't parse version history, not displaying anything."); } else { Log.d(DEBUG_TAG, "Newest version with an entry is " + entries.get(0).versionCode); if (entries.get(0).versionCode > lastVersion) { VersionHistoryDialogFragment frag = VersionHistoryDialogFragment.newInstance(entries); frag.show(getFragmentManager(), VERSION_HISTORY_DIALOG); } } } // In any case, update the version. edit.putInt(GHDConstants.PREF_LAST_SEEN_VERSION, curVersion); edit.apply(); } /** * Requests a stock. This'll come back and be handled appropriately by * CentralMap, which more or less amounts to handling the ErrorBanner and * sending the result off to the active CentralMapMode. * * @param g the Graticule (can be null for globalhashes) * @param cal the date * @param flags the {@link StockService} flags */ private void requestStock(@Nullable Graticule g, @NonNull Calendar cal, int flags) { // As a request ID, we'll use the current date, because why not? long date = cal.getTimeInMillis(); Intent i = new Intent(this, StockService.class).putExtra(StockService.EXTRA_DATE, cal) .putExtra(StockService.EXTRA_GRATICULE, g).putExtra(StockService.EXTRA_REQUEST_ID, date) .putExtra(StockService.EXTRA_REQUEST_FLAGS, flags); mStockReceiver.addToWaitingList(date); WakefulIntentService.sendWakefulWork(this, i); } @Override public void onConnected(Bundle bundle) { // We're connected! Start listening for updates! The modes will get // their updates through us. startListening(); if (!isFinishing()) { doReadyChecks(); } } @Override public void onConnectionSuspended(int i) { // Since the location API doesn't appear to connect back to the network, // I'm not sure I need to do anything special here. I'm not even // entirely convinced the connection CAN become suspended after it's // made unless things are completely hosed. At the very least, though, // I can stop listening. stopListening(); } @Override public void onConnectionFailed(ConnectionResult result) { // Oh, so THAT'S how the connection can fail: If we're using Marshmallow // and the user refused to give permissions to the API or the user // doesn't have the Google Play Services installed. Okay, that's fair. // Let's deal with it, then. if (!mResolvingError) { if (result.hasResolution()) { try { mResolvingError = true; result.startResolutionForResult(this, REQUEST_RESOLVE_ERROR); } catch (IntentSender.SendIntentException e) { // We get this if something went wrong sending the intent. So, // let's just try to connect again. mGoogleClient.connect(); } } else { // If we can't actually resolve this, give up and throw an error. // doReadyChecks() won't ever be called. showErrorDialog(result.getErrorCode()); mResolvingError = true; } } } /** * Tells Select-A-Graticule to start. */ public void enterSelectAGraticuleMode() { if (mSelectAGraticule) return; mSelectAGraticule = true; // We can at least get a starter Graticule for Select-A-Graticule, if // Expedition had one yet. mLastModeBundle = new Bundle(); mCurrentMode.onSaveInstanceState(mLastModeBundle); mCurrentMode.cleanUp(); mStockReceiver.clearWaitingList(); mCurrentMode = new SelectAGraticuleMode(); doReadyChecks(); } /** * Tells Select-A-Graticule mode to exit, and does whatever's needed to make * that work. I could sure use a better way to do this other than making * the method public... */ public void exitSelectAGraticuleMode() { if (!mSelectAGraticule) return; mSelectAGraticule = false; // The result can be retrieved from the Bundle and shoved right into // ExpeditionMode via doReadyChecks. mLastModeBundle = new Bundle(); mCurrentMode.onSaveInstanceState(mLastModeBundle); mCurrentMode.cleanUp(); mStockReceiver.clearWaitingList(); mCurrentMode = new ExpeditionMode(); doReadyChecks(); } @Override public void onBackPressed() { // If we're in Select-A-Graticule, pressing back will send us back to // expedition mode. This seems obvious, especially when the default // implementation will close the graticule fragment anyway when the back // stack is popped, but we also need to do the other stuff like change // the menu back, stop the tap-the-map selections, etc. Also, I really // wish there were a better way to do this that didn't require this // Activity keeping track of things. if (mCurrentMode instanceof SelectAGraticuleMode) exitSelectAGraticuleMode(); else super.onBackPressed(); } private boolean doReadyChecks() { // This should be called any time the Google API client or MapFragment // become ready. It'll check to see if both are up, starting the // current mode when so. if (!mCurrentMode.isCleanedUp() && mMapIsReady && mGoogleClient != null && mGoogleClient.isConnected()) { if (mCurrentMode.isInitComplete()) { mCurrentMode.resume(); } else { mCurrentMode.setMap(mMap); mCurrentMode.setCentralMap(this); mCurrentMode.init(mLastModeBundle); } if (mLastKnownLocation != null && LocationUtil.isLocationNewEnough(mLastKnownLocation)) mCurrentMode.onLocationChanged(mLastKnownLocation); invalidateOptionsMenu(); return true; } else { return false; } } /** * Gets the {@link ErrorBanner} we currently hold. This is mostly for the * {@link CentralMapMode} classes. * * @return the current ErrorBanner */ public ErrorBanner getErrorBanner() { return mBanner; } /** * Gets the {@link GoogleApiClient} we currently hold. There's no guarantee * it's connected at this point, so be careful. * * @return the current GoogleApiClient */ public GoogleApiClient getGoogleClient() { return mGoogleClient; } @Override public void datePicked(Calendar picked) { // Calendar! mLastCalendar = picked; mCurrentMode.changeCalendar(mLastCalendar); } @Override public void mapTypeSelected(int type) { // Map type! if (mMap != null) { mMap.setMapType(type); } } private void startListening() { if (checkLocationPermissions(LOCATION_PERMISSION_REQUEST)) { LocationRequest lRequest = LocationRequest.create(); lRequest.setInterval(1000); lRequest.setPriority(LocationRequest.PRIORITY_HIGH_ACCURACY); LocationServices.FusedLocationApi.requestLocationUpdates(mGoogleClient, lRequest, mLocationListener); // As per the 8.3.0 services, setMyLocationEnabled is a permissions- // locked method. Which, to be honest, is a good thing, really, it // didn't make much sense that you could turn that on without // permissions before. mMap.setMyLocationEnabled(true); } } private void stopListening() { if (mGoogleClient != null && checkLocationPermissions(LOCATION_PERMISSION_REQUEST, true)) { LocationServices.FusedLocationApi.removeLocationUpdates(mGoogleClient, mLocationListener); } } // Here's a chunk of stuff from the Android docs on just what to do when the // API connect fails due to permissions: private void showErrorDialog(int errorCode) { // Create a fragment for the error dialog ErrorDialogFragment dialogFragment = new ErrorDialogFragment(); // Pass the error that should be displayed Bundle args = new Bundle(); args.putInt(DIALOG_API_ERROR, errorCode); dialogFragment.setArguments(args); dialogFragment.show(getFragmentManager(), "errordialog"); } /* Called from ErrorDialogFragment when the dialog is dismissed. */ public void onDialogDismissed() { mResolvingError = false; } /* A fragment to display an error dialog */ public static class ErrorDialogFragment extends DialogFragment { public ErrorDialogFragment() { } @Override public Dialog onCreateDialog(Bundle savedInstanceState) { // Get the error code and retrieve the appropriate dialog int errorCode = this.getArguments().getInt(DIALOG_API_ERROR); return GoogleApiAvailability.getInstance().getErrorDialog(this.getActivity(), errorCode, REQUEST_RESOLVE_ERROR); } @Override public void onDismiss(DialogInterface dialog) { ((CentralMap) getActivity()).onDialogDismissed(); } } @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { if (requestCode == REQUEST_RESOLVE_ERROR) { mResolvingError = false; if (resultCode == RESULT_OK) { // Make sure the app is not already connected or attempting to connect if (!mGoogleClient.isConnecting() && !mGoogleClient.isConnected()) { mGoogleClient.connect(); } } } } /** * <p> * Checks for permissions on {@link Manifest.permission#ACCESS_FINE_LOCATION}, * automatically firing off the permission request if it hasn't been * granted yet. This method DOES return, mind; if it returns true, continue * as normal, and if it returns false, don't do anything. In the false * case, it will (usually) ask for permissions, with CentralMap handling the * callback. * </p> * * <p> * If skipRequest is set, permissions won't be asked for in the event that * they're not already granted, and no explanation popup will show up, * either. Use that for cases like shutdowns where all the listeners are * being unregistered. * </p> * * @param requestCode the type of check this is, so that whatever it was can be tried again on permissions being granted * @param skipRequest if true, don't bother requesting permission, just drop it and go on * @return true if permissions are good, false if not (in the false case, a request might be in progress) */ public synchronized boolean checkLocationPermissions(int requestCode, boolean skipRequest) { // First, the easy case: Permissions granted. if (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) { // Yay! return true; } else { // Boo! Now we need to fire off a permissions request! If we were // already denied permissions once, though, don't bother trying // again. if (!skipRequest && !mPermissionsDenied) ActivityCompat.requestPermissions(this, new String[] { Manifest.permission.ACCESS_FINE_LOCATION }, requestCode); return false; } } /** * Convenience method that calls {@link #checkLocationPermissions(int, boolean)} * with skipRequest set to false. * * @param requestCode the type of check this is, so that whatever it was can be tried again on permissions being granted * @return true if permissions are good, false if not (in the false case, a request might be in progress) */ public synchronized boolean checkLocationPermissions(int requestCode) { return checkLocationPermissions(requestCode, false); } @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) { // Whoops. We're sunk. Go to the permission failure dialog thing. Bundle args = new Bundle(); args.putInt(PermissionDeniedDialogFragment.TITLE, R.string.title_permission_location); args.putInt(PermissionDeniedDialogFragment.MESSAGE, R.string.explain_permission_location); PermissionDeniedDialogFragment frag = new PermissionDeniedDialogFragment(); frag.setArguments(args); frag.show(getFragmentManager(), "PermissionDeniedDialog"); mPermissionsDenied = true; } else { // Thankfully, we don't need to ask for forgiveness, as we've // got permissions right here! startListening(); mPermissionsDenied = false; } if (mCurrentMode != null) mCurrentMode.permissionsDenied(mPermissionsDenied); } }