Skip to content

Incorporating Location Updates Into an OSMDroid Application (Kotlin)

Kwesi Rutledge edited this page Jan 12, 2022 · 6 revisions

This wiki entry attempts to provide a quick, functional introduction to getting Location updates in an Android Application that uses OSMDroid (for Kotlin users).

To do this, it:

  1. Presents a project which interested developers can download and run on their own machine.
  2. Provides a high-level description of what an application needs to do in order to collect the current location of a device and use it in an application with OSMDroid.
  3. Annotates some of the code in the example project to explicitly label what each part is doing.

Getting the Example Project

This page will discuss a publicly available OSMDroid project available on GitHub. It is highly recommended that you clone the repository and run it to gain a better intuition of how it works.

It should be possible to run the project after cloning it and opening it in Android Studio. When working, you should observe that the current longitude and latitude is displayed along with four different buttons below it.

High-Level Description of Application

The project is an application that primarily shows the map of the area surrounding the current location of the device along with the current latitude and longitude of the device as well as four different buttons. The four buttons:

  1. Create a Marker at the current location/gps coordinates,
  2. Draws a circle of a pre-defined area around the current location/gps coordinates,
  3. Enables the compass layer available through OSMDroid, and
  4. Draws a line using multiple points saved during operation.

A summary of how it works is that it specifies how frequently it would like to receive updates by creating a LocationCallback() object and queries a connected client (the FusedLocationProviderClient()) which produces location updates at the desired specifications. With the FusedLocationProviderClient()’s callback updating a local position variable called lastLocation, the main activity simply adds layers to its OSMDroid map which do things such as draw a point at lastLocation, or draw a line where one endpoint is lastLocation, draw a circle with a center of lastLocation, etc.

Useful Concepts

  • Adding the current position to the map (and any number of other symbols) is done by adding on a layer to your OSMDroid map (in this case, the map property which has type MapView).
  • The current location is provided by the android built-in type FusedLocationClientProvider. But you will need to create a request and a callback in order for it to regularly update the location.
  • With the proper settings, a FusedLocationClientProvider will update one of the private variables in your activity at regular intervals without the need to make requests immediately before data is needed. In other words, if the FusedLocationClientProvider is initialized properly, your code can assume that a private variable in the activity (in this case it is called lastLocation) is always up-to-date with the devices current location.
  • When the activity is paused, stopped, or their inverses (started, etc.), the location updates from FusedLocationClientProvider should be halted (or started again) when possible.

Detailed Code Annotation

Initialization

Declare Member Variables for Main Activity
  private var activityResultLauncher: ActivityResultLauncher<Array<String>>
  private lateinit var fusedLocationClient: FusedLocationProviderClient //https://developer.android.com/training/location/retrieve-current
  private var lastLoction: Location? = null 
  private var locationCallback: LocationCallback
  private var locationRequest: LocationRequest
  private var requestingLocationUpdates = false

  companion object {
      val REQUEST_CHECK_SETTINGS = 20202
  }

The following variables are the first few defined in the main activity. They can be better explained through looking into the Android Developers documentation:

  • ActivityResultLauncher: The Android Developer documentation describes this as a ‘launcher for a previously-prepared call to start the process of executing an [ActivityResultContract](https://developer.android.com/reference/androidx/activity/result/contract/ActivityResultContract).’
  • FusedLocationProviderClient (Related Tutorial On Getting the Last Known Location of the Device): The primary client offered by android services for getting location data (from GPS, network or other sources).
  • Location: GPS Coordinates, a built-in type coming from Android (see earlier import import android.location.Location)
  • LocationCallback (Making location requests using the LocationCallback approach): The LocationCallback is an interface which one can override certain methods. By implementing one of the methods, you can use this object to respond to location update events.
init Statement
  init {
      locationRequest = LocationRequest.create()
          .apply { //https://stackoverflow.com/questions/66489605/is-constructor-locationrequest-deprecated-in-google-maps-v2
              interval = 1000 //can be much higher
              fastestInterval = 500
              smallestDisplacement = 10f //10m
              priority = LocationRequest.PRIORITY_HIGH_ACCURACY
              maxWaitTime = 1000
          }
      locationCallback = object : LocationCallback() {
          override fun onLocationResult(locationResult: LocationResult?) {
              locationResult ?: return
              for (location in locationResult.locations) {
                  // Update UI with location data
                  updateLocation(location) //MY function
              }
          }
      }

      this.activityResultLauncher = registerForActivityResult(
          ActivityResultContracts.RequestMultiplePermissions()
      ) { result ->
          var allAreGranted = true
          for (b in result.values) {
              allAreGranted = allAreGranted && b
          }

          Timber.d("Permissions granted $allAreGranted")
          if (allAreGranted) {
              initCheckLocationSettings()
              //initMap() if settings are ok
          }
      }
  }

The init block is used to initialize properties of the enclosing class which might require nontrivial operations. We will see that several of the properties of the MainActivity class require complicated initializations.

This init block does a few things:

  1. It creates the locationRequest member variable.
    1. This member variable will be used in the future to create requests for the location of the device.
    2. The locationRequest variable will be an object of type LocationRequest. It defines (i) the interval at which the location is requested (in milliseconds), (ii) the fastest time interval at which the location is requested (in milliseconds), (iii) the smallest distance between location updates, (iv) the priority of the request, and (v) the maximum wait time for the request.
  2. It creates the locationCallback member variable.
    1. This defines a new LocationCallback object.
      1. “Used for receiving notifications from the FusedLocationProviderApi when the device location has changed or can no longer be determined. The methods are called if the LocationCallback has been registered with the location client using the FusedLocationProviderApi.requestLocationUpdates(GoogleApiClient, LocationRequest, LocationCallback, Looper) method.”
      2. The object itself overrides a single member function onLocationResult(locationResult : LocationResult) as directed in the above description.
    2. The LocationCallback object will return:
      1. Null, if the input locationResult is null.
      2. Otherwise, it updates the User Interface with values coming from the most LocationResult input. (Using the updateLocation() member function.)
    3. Because we have chosen to override the onLocationResult() member function, this should be called whenever “device location is available.”
  3. Defines activityResultLauncher, the member variable.
    1. The member variable is defined by the registerForActivityResult() function which registers the potential callback activity. This is required for any activity that we know we will launch from this activity and want to receive results from.

      For this reason, the Activity Result APIs decouple the result callback from the place in your code where you launch the other activity. As the result callback needs to be available when your process and activity are recreated, the callback must be unconditionally registered every time your activity is created, even if the logic of launching the other activity only happens based on user input or other business logic.

      1. The output of this function is an ActivityResultLauncher which is used to launch the separate activity at a later time.
    2. The input to the function is ActivityResultContracts.RequestMultiplePermissions() which is a special set of contracts.

    3. It appears that these lines make it so that the member variable activityResultLauncher() is an ActivityResultLauncher for the RequestMultiplePermissions() activity. Thus, when the launch() command is later called on this member variable, it retrieves the proper permissions for the application.

More Member Variables
private lateinit var binding: ActivityMainBinding
val rnd = Random()
lateinit var map: MapView
var startPoint: GeoPoint = GeoPoint(46.55951, 15.63970);
lateinit var mapController: IMapController
var marker: Marker? = null
var path1: Polyline? = null

This portion of the code is declaring more member variables which will be important later.

  • binding:
    • A generated binding class “... links the layout variables with the views within the layout... A binding class is generated for each layout file. By default, the name of the class is based on the name of the layout file, converting it to Pascal case and adding the Binding suffix to it... [the name of our file is] activity_main.xml so the corresponding generated class is ActivityMainBinding
  • rnd: “An instance of this class is used to generate a stream of pseudorandom numbers.”
  • map: The map view which will be where the OSM Tile visual data is displayed.
  • startPoint: A GeoPoint object (e.g. long-lat coordinates) which will be used initially in the application.
  • mapController: A controller object which can be used to add layers (i.e. draw things) on the map.
  • marker: A marker object which can be used to place an image at a given location on the map.
  • path1: A polyline object which has data that can be defined using the buttons in the app.

Activity Member Functions

onCreate()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (BuildConfig.DEBUG) {
    Timber.plant(Timber.DebugTree()) //Init report type
}
val br: BroadcastReceiver = LocationProviderChangedReceiver()
val filter = IntentFilter(LocationManager.PROVIDERS_CHANGED_ACTION)
registerReceiver(br, filter)

//LocalBroadcastManager.getInstance(this).registerReceiver(locationProviderChange)
Configuration.getInstance()
    .load(applicationContext, this.getPreferences(Context.MODE_PRIVATE))
binding = ActivityMainBinding.inflate(layoutInflater) //ADD THIS LINE

map = binding.map
map.setTileSource(TileSourceFactory.MAPNIK)
map.setMultiTouchControls(true)
mapController = map.controller
setContentView(binding.root)
val appPerms = arrayOf(
    Manifest.permission.ACCESS_FINE_LOCATION,
    Manifest.permission.ACCESS_NETWORK_STATE,
    Manifest.permission.READ_EXTERNAL_STORAGE,
    Manifest.permission.WRITE_EXTERNAL_STORAGE,
    Manifest.permission.INTERNET
)
activityResultLauncher.launch(appPerms)
}

At a high level, this function (called whenever the app is started for the first time in a session) seems to:

  • Check if there is a saved instance state (i.e. old data on the app).
    • “If its process needs to be killed, when the user navigates back to the activity (making it visible on the screen again), its onCreate(Bundle) method will be called with the savedInstanceState it had previously supplied in onSaveInstanceState(Bundle) so that it can restart itself in the same state as the user last left it.”
  • Define a LocationProviderChangedReceiver() object which seems to get updated whenever the location provider changes (i.e. between GPS and Network).
    • This is a custom class which is a subclass of BroadcastReceiver().
  • Registers the intent filter with the custom broadcast receiver LocationProviderChangedReceiver().
    • Thus, whenever the PROVIDERS_CHANGED_ACTION action occurs, this broadcast receiver will do something.
  • Defines the ActivityMainBinding object binding using the inflate() function.
  • Defines the map using the binding which was just inflated.
    • Sets tile source to MAPNIK (others are available as well).
    • Sets multi touch controls on.
  • Attaches the map’s controller element to the member variable mapController.
  • Requests permissions using the launch() method of the ActivityResultLauncher object.
onResume()
override fun onResume() {
    super.onResume()
    binding.map.onResume()
}

No need to discuss; Remember to mention that map should always execute this onResume() command.

onPause()
override fun onPause() {
    super.onPause()
    if (requestingLocationUpdates) {
        requestingLocationUpdates = false
        stopLocationUpdates()
    }
    binding.map.onPause()
}

This function will stop location updates whenever the activity enters the ‘Pause’ state.

onStart()
override fun onStart() {
      super.onStart()
      EventBus.getDefault().register(this);
  }

This registers the activity on something called the EventBus whenever onStart() is called.

The EventBus is a third party library designed to “simplify communication between Activities, Fragments, Threads, Services, etc.” Read more of that project’s documentation to understand how that works.

onStop()
override fun onStop() {
      super.onStop()
      EventBus.getDefault().unregister(this);
  }

This function unregisters the main activity whenever onStop() is called.

onMsg()
@Subscribe(threadMode = ThreadMode.MAIN)
fun onMsg(status: MyEventLocationSettingsChange) {
    if (status.on) {
        initMap()
    } else {
        Timber.i("Stop something")
    }
}

The @Subscribe() directive comes from Greenrobot’s EventBus library. It defines onMsg() as being subscribed to the bus with ThreadMode.MAIN, for any event that is pushed onto the bus this function should be called.

onActivityResult()
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
      super.onActivityResult(requestCode, resultCode, data)
      Timber.d("Settings onActivityResult for $requestCode result $resultCode")
      if (requestCode == REQUEST_CHECK_SETTINGS) {
          if (resultCode == RESULT_OK) {
              initMap()
          }
      }
  }

This function appears to define what happens if this activity is asked for a result? Check when onActivityResult() is called. When returned successfully with the REQUEST_CHECK_SETTINGS code, the map is initialized.

Helper Functions

initLocation()
fun initLoaction() { //call in create
    fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
    readLastKnownLocation()
}

initLocation() appears to get a FusedLocationProviderClient() object and then assigns it to the appropriate member property. The function then reads the last known location and sets the appropriate member variable lastLocation.

startLocationUpdates()
@SuppressLint("MissingPermission")
private fun startLocationUpdates() { //onResume
    fusedLocationClient.requestLocationUpdates(
        locationRequest,
        locationCallback,
        Looper.getMainLooper()
    )
}

I’m not sure what @SuppressLint() is doing. This part of the documentation says that this function may “[indicate] that Lint should ignore the specified warnings for the annotated element.”

The main body of the function appears to send a request for location updates according to the activity’s properties locationRequest and locationCallback.

stopLocationUpdates()
private fun stopLocationUpdates() { //onPause
      fusedLocationClient.removeLocationUpdates(locationCallback)
  }

This function simply removes the location callback defined by the activity’s property locationCallback which should stop location updates from coming in.

readLastKnownLocation()
//https://developer.android.com/training/location/retrieve-current
@SuppressLint("MissingPermission") //permission are checked before
fun readLastKnownLocation() {
    fusedLocationClient.lastLocation
        .addOnSuccessListener { location: Location? ->
            location?.let { updateLocation(it) }
        }
}

This function appears to add an OnSuccessListener() object to the lastLocation member of fusedLocationClient. To the best of my ability, I think that this means that the function within the brackets {} is called whenver a “success” value is received from the lastLocation member of fusedLocationClient.

initCheckLocationSettings()
fun initCheckLocationSettings() {
    val builder = LocationSettingsRequest.Builder()
        .addLocationRequest(locationRequest)
    val client: SettingsClient = LocationServices.getSettingsClient(this)
    val task: Task<LocationSettingsResponse> = client.checkLocationSettings(builder.build())
    task.addOnSuccessListener { locationSettingsResponse ->
        Timber.d("Settings Location IS OK")
        MyEventLocationSettingsChange.globalState = true //default
        initMap()
        // All location settings are satisfied. The client can initialize
        // location requests here.
        // ...
    }

    task.addOnFailureListener { exception ->
        if (exception is ResolvableApiException) {
            // Location settings are not satisfied, but this can be fixed
            // by showing the user a dialog.
            Timber.d("Settings Location addOnFailureListener call settings")
            try {
                // Show the dialog by calling startResolutionForResult(),
                // and check the result in onActivityResult().
                exception.startResolutionForResult(
                    this@MainActivity,
                    REQUEST_CHECK_SETTINGS
                )
            } catch (sendEx: IntentSender.SendIntentException) {
                // Ignore the error.
                Timber.d("Settings Location sendEx??")
            }
        }
    }

}

This function attempts to check the location settings of the property locationRequest via the SettingsClient from LocationServices. If the check is successful, then a debugging message is printed and the map is initialized. If the check is not successful, then a prompt appears to be displayed on screen.

The final component (task.addOnFailureListener) comes from the Change Location Settings article in the Android Developers documentation. If the location settings are inappropriate (failure occurs), then this function “displays a dialog that prompts the user for permission to modify the location settings by calling startResolutionForResult().”

updateLocation()
fun updateLocation(newLocation: Location) {
    lastLoction = newLocation
    //GUI, MAP TODO
    binding.tvLat.setText(newLocation.latitude.toString())
    binding.tvLon.setText(newLocation.longitude.toString())
    //var currentPoint: GeoPoint = GeoPoint(newLocation.latitude, newLocation.longitude);
    startPoint.longitude = newLocation.longitude
    startPoint.latitude = newLocation.latitude
    mapController.setCenter(startPoint)
    getPositionMarker().position = startPoint
    map.invalidate()

}

This function changes the values of the property lastLocation in the main activity. It also updates the GUI on the map (specifically it sets the text on the latitude and longitude areas on the application.

The function also updates startPoint with the current latitude and longitude for drawing lines if the user wants to use that feature.

The map is centered on the new coordinate and the marker for the position is changed to be located at the new gps coordinate.

Not sure what map.invalidate() does. Let me know and I will give you a reward!

initMap()
fun initMap() {
    initLoaction()
    if (!requestingLocationUpdates) {
        requestingLocationUpdates = true
        startLocationUpdates()
    }
    mapController.setZoom(18.5)
    mapController.setCenter(startPoint);
    map.invalidate()
}

When initializing the map, this first tries to initialize the current location using initLocation(). Then, location updates are started using startLocationUpdates() if they have not already been turned on. The map’s default zoom (18.5) and center (startPoint aka the current location) are also assigned.

getPath()
private fun getPath(): Polyline { //Singelton
    if (path1 == null) {
        path1 = Polyline()
        path1!!.outlinePaint.color = Color.RED
        path1!!.outlinePaint.strokeWidth = 10f
        path1!!.addPoint(startPoint.clone())
        map.overlayManager.add(path1)
    }
    return path1!!
}

This function attempts to add a line (Polyline() object) to the map if one does not currently exist. The line will be red with strokeWidth 10f and contains the coordinates from startPoint(). It returns the found path.

getPositionMarker()
private fun getPositionMarker(): Marker { //Singelton
    if (marker == null) {
        marker = Marker(map)
        marker!!.title = "Here I am"
        marker!!.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
        marker!!.icon = ContextCompat.getDrawable(this, R.drawable.ic_position);
        map.overlays.add(marker)
    }
    return marker!!
}

This function adds a marker to the map with title “Here I am” and which is anchored at the relative locations. The marker’s appearance is the image stored in the ic_position drawable item.

onClickDraw1()
fun onClickDraw1(view: View?) {
    startPoint.latitude = startPoint.latitude + (rnd.nextDouble() - 0.5) * 0.001
    mapController.setCenter(startPoint)
    getPositionMarker().position = startPoint
    map.invalidate()
}

This is a function which is called whenver one of the buttons in the GUI is pressed (see activity_main.xml to find out which button is connected to onClickDraw1()). For this button, the map is centered at the current value of startPoint.

onClickDraw2()
fun onClickDraw2(view: View?) {
    startPoint.latitude = startPoint.latitude + (rnd.nextDouble() - 0.5) * 0.001
    mapController.setCenter(startPoint)
    val circle = Polygon(map)
    circle.points = Polygon.pointsAsCircle(startPoint, 40.0 + rnd.nextInt(100))
    circle.fillPaint.color = 0x32323232 //transparent
    circle.outlinePaint.color = Color.GREEN
    circle.outlinePaint.strokeWidth = 2f
    circle.title = "Area X"
    map.overlays.add(circle) //Duplicate every time new
    map.invalidate()
}

This is a function which is called whenever one of the buttons in the GUI is pressed (see activity_main.xml to find out which button is connected to onClickDraw2()).

When called this function sets the center of the map to be the value of startPoint. It also creates a circle with a 40m radius with transparent fill and green boundary that is meant to surround the current gps coordinates.

onClickDraw3()
fun onClickDraw3(view: View?) {
    val mCompassOverlay = CompassOverlay(this, InternalCompassOrientationProvider(this), map)
    mCompassOverlay.enableCompass()
    map.overlays.add(mCompassOverlay)
    map.invalidate()
}

This is a function which is called whenever a specific button in the GUI is pressed (see activity_main.xml to find out which button is connected to onClickDraw3()).

This function simply enables the compass overlay and then adds it to the map.

onClickDraw4()
fun onClickDraw4(view: View?) {
    //Polyline path = new Polyline();
    startPoint.latitude = startPoint.latitude + (rnd.nextDouble() - 0.5) * 0.001
    startPoint.longitude = startPoint.longitude + (rnd.nextDouble() - 0.5) * 0.001
    getPath().addPoint(startPoint.clone())
    map.invalidate()
}

This is a function which is called whenever a specific button in the GUI is pressed (see activity_main.xml to find out which button is connected to onClickDraw4()).

This function adds a new point to the map at the current position of the device.