Field MapThe overwhelming majority of my professional choices have been heavily impacted by my love of the outdoors.  I am at my happiest in remote locations, far from the presence of other humans.  This has led to fields like forestry and archaeology and even GIS.  But when it comes to GIS, I’m more of a Field Operative than anything else.  By that, I mean I am more likely to find myself out in the field gathering and/or verifying data than I am to be at a computer manipulating data gathered or created by someone else.  Because of this, field maps have always figured prominently in my tool kit.

For a long time, paper maps more than sufficed (still do in a pinch), mainly because they were the only show in town and therefore had to.  Truth is I still don’t go into the field without a paper map and compass, if only because their batteries never wear out and the only GPS fix they require is the one in my head.

More recently, I expanded my tool kit to include some sort of hand-held unit, usually a Garmin.  It wasn’t long before I figured out how to plug my own custom maps into these devices (although I cannot now remember the software I used to do so), neatly laying the information I needed over the pre-existing base maps built in to the devices.

Then smartphones came along.  At first glance you’d think such devices would be tailor-made for my purposes, but it turned out to be otherwise.  Like much of our world, smartphone apps are driven by the market, and a ridiculously high percentage of smartphone users have absolutely no use whatsoever for custom field maps (they do, however, really need to know where the closest cup of coffee is).

So I set out to write my own.  It turned out to be much easier than I had envisioned.

Beside the ability to use my own maps, the next biggest requirement I had was the ability to work offline.  There isn’t much connectivity in the kinds of places where I need field maps, so I needed the ability to store the maps in the device.  Google Maps allows for local caching (for later offline use), but I’m an archaeologist.  I need a field map that looks less like this:

Google Map

And more like this:

FortVancouver-Map

Not surprisingly, Google Play is not overflowing with apps along these lines.  I can’t imagine they’d be big sellers.

Which left me with only one recourse.  What follows is how it went.  This will not be a comprehensive app-writing tutorial.  I simply intend to tell you – in general terms – how I wrote this app, and I will include all the necessary code in case you’re inclined to construct your own.  If you know nothing about app development but want to learn, there are numerous resources available all over the Internet.  Google is your friend.

I use Eclipse when I write apps, which is simply a matter of personal preference.  I mention this only because it means that my description of the process will necessarily be specific to my personal development environment, so if you want to play along you might have to adapt things a bit if your personal preferences differ.

Our target end result is a simple map app with minimal bells and whistles.  I didn’t want to clutter the code too terribly, so wherever I felt a need to explain something, I placed a marker comment saying “Note 1” or similar.  I will address these notes in the body of this post.  Let’s get to it, shall we?

First, you’ll have to create a new Android app.  Call it whatever you want.  I called mine “FieldMap” for obvious reasons.  Before we can start plugging code in, though, we have to import a couple of libraries and add them to our build path.  These libraries are OSMDroid (available here.  I used osmdroid-android-3.0.8.jar) and SLF4J Android (available here.  I used slf4j-android-1.5.8.jar).  Import both of these into your “libs” folder, then right-click on each of them in turn and click on ‘Build Path’ –‘Add to Build Path’.

From here, it’s just a question of plugging the necessary code into the appropriate places.  You should, of course, feel free to alter any or all of it to suit your needs.

Main Activity (MainActivity.java)


package com.fieldmap;

import org.osmdroid.views.MapController;
import org.osmdroid.views.MapView;
import org.osmdroid.views.overlay.MyLocationOverlay;
import org.osmdroid.views.overlay.ScaleBarOverlay;
import android.location.LocationManager;
import android.os.Bundle;
import android.provider.Settings;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.DialogInterface;
import android.content.Intent;
public class MainActivity extends Activity {

MyItemizedOverlay myItemizedOverlay = null;
 MyLocationOverlay myLocationOverlay = null;

 private MapController myMapController;

 @Override
 public void onCreate(Bundle savedInstanceState) {
 super.onCreate(savedInstanceState);
 setContentView(R.layout.main);

 LocationManager service = (LocationManager) getSystemService(LOCATION_SERVICE);
 boolean enabled = service.isProviderEnabled(LocationManager.GPS_PROVIDER);

 if (!enabled) {

AlertDialog.Builder builder = new AlertDialog.Builder(this);
 builder.setTitle("GPS Disabled")
 .setMessage("You forgot to turn on the GPS.")
 .setCancelable(false)

//Note 1
 .setIcon(R.drawable.hdpi)
 //

.setPositiveButton("Fix It", new DialogInterface.OnClickListener() {
 public void onClick(DialogInterface dialog, int id) {
 Intent intent = new Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS);
 startActivity(intent);
 }
 })
 .setNegativeButton("Quit", new DialogInterface.OnClickListener() {
 public void onClick(DialogInterface dialog, int id) {
 MainActivity.this.finish();
 }
 });

builder.create().show();

}
 {
 final MapView mapView = (MapView) findViewById(R.id.mapview);

//Note2

mapView.setBuiltInZoomControls(false);
 mapView.setMultiTouchControls(true);

//

mapView.setUseDataConnection(false);

 myMapController = mapView.getController();
 myMapController.setZoom(15);

 myLocationOverlay = new MyLocationOverlay(this, mapView);
 mapView.getOverlays().add(myLocationOverlay);

myLocationOverlay.runOnFirstFix(new Runnable() {
 public void run() {
 mapView.getController().animateTo(myLocationOverlay.getMyLocation());
 }
 });

 ScaleBarOverlay myScaleBarOverlay = new ScaleBarOverlay(this);
 mapView.getOverlays().add(myScaleBarOverlay);
 }
 }

 @Override
 protected void onResume() {
 // TODO Auto-generated method stub
 super.onResume();
 myLocationOverlay.enableMyLocation();
 myLocationOverlay.enableCompass();
 myLocationOverlay.enableFollowLocation();
 }

 @Override
 protected void onPause() {
 // TODO Auto-generated method stub
 super.onPause();
 myLocationOverlay.disableMyLocation();
 myLocationOverlay.disableCompass();
 myLocationOverlay.disableFollowLocation();
 }

 @Override
 public boolean onCreateOptionsMenu(Menu menu) {
 MenuInflater inflater = getMenuInflater();
 inflater.inflate(R.menu.main, menu);
 return true;

 }

 @Override
 public boolean onOptionsItemSelected(MenuItem item)
 {
 super.onOptionsItemSelected(item);
 switch (item.getItemId())
 {
 case R.id.menu_center:
 myLocationOverlay.runOnFirstFix(new Runnable() {
 public void run() {
 final MapView mapView = (MapView) findViewById(R.id.mapview);
 mapView.getController().animateTo(myLocationOverlay.getMyLocation());
 }
 });

break;

 case R.id.menu_quit:
 Intent intent = new Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS);
 startActivity(intent);
 this.finish();
 }
 return true;
 }
 }

Note 1:  I include icons in my apps as a matter of form.  It is, however, completely unnecessary.  Whether you include one is up to you.

Note 2:  OSMDroid has built-in zoom controls.  Since pretty much every single device that’s capable of running this app has a touch screen (and it’s safe to assume the person holding the device knows how to use it), I don’t see any reason to clutter up screen real estate with ‘+’ and ‘-’ buttons.  If you would prefer it otherwise, it should be fairly obvious how to bring this about.

The Main Activity calls for an Itemized Overlay to house our compass and scalebar (necessary components of any map).  This particular class doesn’t already exist in the Android SDK, so we’ll have to create it ourselves.  Create the Class inside your package (under ‘src’).

My Itemized Overlay (MyItemizedOverlay.java)


package com.fieldmap;

import java.util.ArrayList;
import org.osmdroid.ResourceProxy;
import org.osmdroid.api.IMapView;
import org.osmdroid.util.GeoPoint;
import org.osmdroid.views.overlay.ItemizedOverlay;
import org.osmdroid.views.overlay.OverlayItem;

import android.graphics.Point;
import android.graphics.drawable.Drawable;

public class MyItemizedOverlay extends ItemizedOverlay<OverlayItem> {

private ArrayList<OverlayItem> overlayItemList = new ArrayList<OverlayItem>();

public MyItemizedOverlay(Drawable pDefaultMarker,
 ResourceProxy pResourceProxy) {
 super(pDefaultMarker, pResourceProxy);
 // TODO Auto-generated constructor stub
}

public void addItem(GeoPoint p, String title, String snippet){
 OverlayItem newItem = new OverlayItem(title, snippet, p);
 overlayItemList.add(newItem);
 populate();
}

@Override
public boolean onSnapToItem(int arg0, int arg1, Point arg2, IMapView arg3) {
 // TODO Auto-generated method stub
 return false;
}

@Override
protected OverlayItem createItem(int arg0) {
 // TODO Auto-generated method stub
 return overlayItemList.get(arg0);
}

@Override
public int size() {
 // TODO Auto-generated method stub
 return overlayItemList.size();
}

}

And that pretty much concludes the heavy lifting.  Now all we have to do is tweak a few things.  The drawable folders can be completely ignored if you’re so inclined.  They will already contain launcher icons provided automatically by Eclipse (unless you chose to replace them with your own), and the folders need no other attention unless you want to include your own graphics (see Note 1 above).  We will need to address the layout, as follows:

Layout (main.xml)

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
 xmlns:tools="http://schemas.android.com/tools"
 android:layout_width="match_parent"
 android:layout_height="match_parent"
 tools:context=".MainActivity" >

 <org.osmdroid.views.MapView
android:id="@+id/mapview"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:clickable="true"/>

</RelativeLayout>

Also, we need to configure a menu:

Menu (main.xml)


<menu xmlns:android="http://schemas.android.com/apk/res/android" >

 <item android:id="@+id/menu_center"
 android:title="@string/menu_center" />
 <item android:id="@+id/menu_quit"
 android:title="@string/menu_quit" />

</menu>

And we also need to give some values to our strings (under ‘values’):

Strings (strings.xml)


<?xml version="1.0" encoding="utf-8"?>
<resources>

<string name="app_name">Field Map</string>
<string name="action_settings">Settings</string>
<string name="menu_center">Center Map</string>
<string name="menu_quit">Quit</string>

</resources>

Lastly, we have to alter the Manifest.  Eclipse will automatically target the latest Android API, but doing so produces a minor clash with OSMDroid.  This issue arises when we overlay the compass and scalebar.  In order to work around the issue, we simply have to tell the app to use an older API.  It’s a simple matter of changing the target SDK to ‘11’.

Manifest (AndroidManifest.xml)

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
 package="com.fieldmap"
 android:versionCode="1"
 android:versionName="1.0" >

<uses-sdk
 android:minSdkVersion="8"
 android:targetSdkVersion="11" />

 <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
 <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
 <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

<application
 android:allowBackup="true"
 android:icon="@drawable/ic_launcher"
 android:label="@string/app_name"
 android:theme="@style/AppTheme" >
 <activity
 android:name="com.fieldmap.MainActivity"
 android:label="@string/app_name" >
 <intent-filter>
 <action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
 </intent-filter>
 </activity>
 </application>

</manifest>

At this point you have a functioning map app, although it currently lacks a map.  In fact, if you changed one line of the Main Activity (just after Note 2) from “mapView.setUseDataConnection(false);” to “mapView.setUseDataConnection(true);”, you would have a fine app that pulls map data from OpenStreetMap (you could even pull your map tiles from other sources, like MapQuest).  However, our original intent was to use this app offline, so we now have to give ourselves some local map data, for which we will turn to one of my favorite tools, TileMill.  I won’t go into the mechanics of map production using TileMill – there’s plenty of good resources over at MapBox.

Once you’ve made your map, it’ll be time to get it into the app.  My initial searches on the subject eventually led to this post, which led me to believe such a thing was possible.  It looked as though it may be a little tricky, but I assumed I could beat my head against it long enough to figure out how to make it happen.

This turned out not to be the case.  In truth, it turned out to be rather simple.  In fact, I’d go so far as to call it stupid simple.  It goes like this:

Connect your device as a hard drive to the computer housing the map you exported from TileMill (the .mbtiles file).  On your Android device, navigate to the SD card (/mnt/sdcard).  Create a folder on the SD card called “osmdroid” (without the quotes).  Place the .mbtiles file in the osmdroid folder.

And you’re done.  The app will now see and play nicely with your TileMill-generated map.  See?  I told you it was stupid simple.

But wait – there’s more.  And it gets even better.  Let’s say you need detailed maps of two different and geographically divergent areas in one day.  The logical conclusion is that you’d have to create a large map encompassing both areas.  While this would indeed suffice, the resulting map would likely be far larger than you’d like to stuff into a mobile device.  So it’s a good thing you don’t have to.  If you instead make two separate maps of the different areas, the app will automatically use the one closest to your geographic location when you start the app.  It will not, however, switch between maps as you travel between them (it stays on the one you started with).  This is easily corrected by stopping and restarting the app (besides – just use Google Maps if you need help getting from A to B).

A couple notes on the app itself:  Android does not allow for programmatically starting a device’s GPS (for security reasons).  Because of this, the app checks to see if it’s on at startup.  If the GPS isn’t enabled, the app offers to take you to Location Services to turn it on.  Also, if you exit through the app (Menu-Quit), it automatically takes you back to Location Services to remind you to turn the GPS off (it’s a serious battery hog).  The other button on the menu centers the map on your current location (in case you lose your way while looking around).

Enjoy.

Signature

About these ads