Build a Kotlin ride sharing app with push notifications
You need suitable IDEs, including Android Studio. The tutorial assumes you have some experience with Android development.
Introduction
Many user facing applications can be greatly improved by introducing realtime notifications for the user. This is especially important when the user is actively waiting for a service to arrive.
In this article we are going to build a ride sharing app. There are two parts to this app, both of which will take advantage of the Pusher Beams functionality.
On the Driver side, we will have an Android application that receives a notification when a new job comes up, when the job is no longer available and when the job has finished with the rating from the rider.
On the Rider side, we will also have an Android application that allows the user to request a car from their current location to a target location, gives regular notifications when the car is en-route to pick up and gives the ability to rate the driver when the ride is finished.
Prerequisites
In order to follow along, you will need some experience with the Kotlin programming language, which we are going to use for both the backend and frontend of our application.
You will also need appropriate IDEs. We suggest IntelliJ IDEA and Android Studio. Finally, you will need a free Pusher Account. Sign up now if you haven’t already done so.
It is also assumed that you know how to use the IDEs that you are working with, including interacting with either an emulated or physical mobile device for running the applications.
Setting up your Pusher account
In order to use the Beams API and SDKs from Pusher, you need to create a new Beams instance in the Pusher Beta Dashboard.
Next, on your Overview for your Beams instance, click Open Quickstart to add your Firebase Cloud Messaging (FCM) Server Key to the Beams Instance.
After saving your FCM key, you can finish the Quickstart wizard by yourself to send your first push notification, or just continue as we’ll cover this below.
It’s important to make sure that you download and keep the google-services.json
file from the Firebase Console as we are going to need this later on.
Once you have created your Beams instance, you will also need to note down your Instance Id and Secret Key from the Pusher Dashboard, found under the CREDENTIALS section of your Instance settings.
Overall architecture
Our overall application will have two Android applications, and a backend application that orchestrates between them. The Rider application will send a message to the backend in order to request a ride. This will contain the start location. The backend will then broadcast out to all of the drivers that a new job is available. Once one of the drivers accepts the job, the rider is then notified of this fact and is kept informed of the car’s location until it turns up. At the same time, the other drivers are all notified that the job is no longer available.
At the other end of the journey, the driver will indicate that the job is finished. At this point, they will be able to collect a new job if they wish.
Backend application
We are going to build our backend application using Spring Boot and the Kotlin programming language, since this gives us a very simple way to get going whilst still working in the same language as the Android applications will be built.
Head over to https://start.spring.io/ to create our project structure. We need to specify that we are building a Gradle project with Kotlin and Spring Boot 2.0.1 (or newer if available at the time of reading), and we need to include the “Web” component:
The Generate Project button will give you a zip file containing our application structure. Unpack this somewhere. At any time, you can execute ./gradlew bootRun
to build and start your backend server running.
Firstly though, we need to add some dependencies. Open up the build.gradle
file and add the following to the dependencies
section:
compile 'com.pusher:push-notifications-server-java:0.9.0'
runtime 'com.fasterxml.jackson.module:jackson-module-kotlin:2.9.2'
The first of these is the Pusher library needed for triggering push notifications. The second is the Jackson module needed for serializing and deserializing Kotlin classes into JSON.
Now, build the project. This will ensure that all of the dependencies are downloaded and made available and that everything compiles and builds correctly:
$ ./gradlew build
Starting a Gradle Daemon (subsequent builds will be faster)
> Task :test
2018-04-27 07:34:27.548 INFO 43169 --- [ Thread-5] o.s.w.c.s.GenericWebApplicationContext :
Closing org.springframework.web.context.support.GenericWebApplicationContext@c1cf60f: startup date [Fri
Apr 27 07:34:25 BST 2018]; root of context hierarchy
BUILD SUCCESSFUL in 17s
5 actionable tasks: 5 executed
Broadcasting events
The sole point of the backend application is to broadcast push notifications via the Pusher Beams service in response to incoming HTTP calls.
We have a few different endpoints that we want to handle, each of which will broadcast their own particular events:
- POST /request-ride
- POST /accept-job/{job}
- POST /update-location/{job}
- POST /pickup/{job}
- POST /dropoff/{job}
Out of these, the first one is used by the riders application whilst the others are all used by the drivers application. There is also a strict workflow between these. The very first one will generate a new job, with a unique ID that will be passed between all of the other requests and which will be used as the intent of the push notification to ensure that only the correct rider gets the messages.
The workflow is going to be:
- Rider makes a call to
/request-ride
supplying their current location, and gets a Job ID back. - All currently active drivers are sent a push notification informing them of the job.
- Driver makes a call to
/accept-job/{job}
, supplying their current location. This causes the rider to be notified that a driver has accepted the job, and where the driver is, and also causes all the other drivers to remove the job from their list. - Driver makes frequent calls to
/update-location/{job}
with their current location. This causes the rider to be notified of where the driver is now. - Driver makes a call to
/pickup/{job}
with their current location. This informs the rider that their ride is waiting for them. - Driver makes frequent calls to
/update-location/{job}
with their current location. This causes the rider to be notified of where the driver is now. - Driver makes a call to
/dropoff/{job}
with their current location. This informs the rider that their ride is over.
The first thing we need is some way to represent a location in the world. All of our endpoints will use this as their payload. Create a new class called Location
:
data class Location(
val latitude: Double,
val longitude: Double
)
We also need an enumeration of the actions that can be performed. Create a new class called Actions
:
enum class Actions {
NEW_JOB,
ACCEPT_JOB,
ACCEPTED_JOB,
UPDATE_LOCATION,
PICKUP,
DROPOFF
}
Now we can create our mechanism to send out Pusher Beams notifications to the relevant clients. There are two different kinda of notification to send - one with a location and one with a rating. Create a new class called JobNotifier
:
@Component
class JobNotifier(
@Value("\${pusher.instanceId}") private val instanceId: String,
@Value("\${pusher.secretKey}") private val secretKey: String
) {
private val pusher = PushNotifications(instanceId, secretKey)
fun notify(job: String, action: Actions, location: Location) {
val interests = when (action) {
Actions.NEW_JOB -> listOf("driver_broadcast")
Actions.ACCEPTED_JOB -> listOf("driver_broadcast")
else -> listOf("rider_$job")
}
pusher.publish(
interests,
mapOf(
"fcm" to mapOf(
"data" to mapOf(
"action" to action.name,
"job" to job,
"latitude" to location.latitude.toString(),
"longitude" to location.longitude.toString()
)
)
)
)
}
}
Note: If the data sent in a notification contains anything that is not a string then the Android client will silently fail to receive the notification.
This will send notifications with one of two interest sets. driver_broadcast
will be received by all drivers that are not currently on a job, and driver_$job
will be received by the driver currently on that job.
You will also need to add to the application.properties
file the credentials needed to access the Pusher Beams API:
pusher.instanceId=<PUSHER_INSTANCE_ID>
pusher.secretKey=<PUSHER_SECRET_KEY>
Finally we need a controller to actually handle the incoming HTTP Requests and trigger the notifications. Create a new class called RideController
:
@RestController
class RideController(
private val jobNotifier: JobNotifier
) {
@RequestMapping(value = ["/request-ride"], method = [RequestMethod.POST])
@ResponseStatus(HttpStatus.CREATED)
fun requestRide(@RequestBody location: Location): String {
val job = UUID.randomUUID().toString()
jobNotifier.notify(job, Actions.NEW_JOB, location)
return job
}
@RequestMapping(value = ["/accept-job/{job}"], method = [RequestMethod.POST])
@ResponseStatus(HttpStatus.NO_CONTENT)
fun acceptJob(@PathVariable("job") job: String, @RequestBody location: Location) {
jobNotifier.notify(job, Actions.ACCEPT_JOB, location)
jobNotifier.notify(job, Actions.ACCEPTED_JOB, location)
}
@RequestMapping(value = ["/update-location/{job}"], method = [RequestMethod.POST])
@ResponseStatus(HttpStatus.NO_CONTENT)
fun updateLocation(@PathVariable("job") job: String, @RequestBody location: Location) {
jobNotifier.notify(job, Actions.UPDATE_LOCATION, location)
}
@RequestMapping(value = ["/pickup/{job}"], method = [RequestMethod.POST])
@ResponseStatus(HttpStatus.NO_CONTENT)
fun pickup(@PathVariable("job") job: String, @RequestBody location: Location) {
jobNotifier.notify(job, Actions.PICKUP, location)
}
@RequestMapping(value = ["/dropoff/{job}"], method = [RequestMethod.POST])
@ResponseStatus(HttpStatus.NO_CONTENT)
fun dropoff(@PathVariable("job") job: String, @RequestBody location: Location) {
jobNotifier.notify(job, Actions.DROPOFF, location)
}
}
Every method simply triggers one notification and returns. The handler for /request-ride
will generate a new UUID as the job ID and will return it to the rider - the drivers will get the job ID in the appropriate push notification if they receive it.
Building the Riders application
The Rider Android application will also be built in Kotlin, using Android Studio. To start, open up Android Studio and create a new project, entering some appropriate details and ensuring that you select the Include Kotlin support option. Note that the Package name must match that specified when you set up the FCM Server Key earlier.
Then on the next screen, ensure that you select support for Phone and Tablet using at least API 23:
Ensure that an Google Maps Activity is selected:
And set the Activity Name to “MainActivity” and Layout Name to “activity_main”:
Once the project opens, you will be presented with the file google_maps_api.xml
with instructions on how to get a Google Maps API key. Follow these instructions to get a key to use in the application.
Next we need to add some dependencies to our project to support Pusher. Add the following to the project level build.gradle
, in the existing dependencies
section:
classpath 'com.google.gms:google-services:3.2.1'
Then add the following to the dependencies
section of the app level build.gradle
:
implementation 'com.google.firebase:firebase-messaging:15.0.0'
implementation 'com.pusher:push-notifications-android:0.10.0'
compile 'com.loopj.android:android-async-http:1.4.9'
compile 'com.google.code.gson:gson:2.2.4'
And this to bottom of the app level build.gradle
:
apply plugin: 'com.google.gms.google-services'
Next, copy the google-services.json
file we downloaded earlier into the app
directory under your project. We are now ready to actually develop our specific application using these dependencies.
Finally, we need to add some permissions to our application. Open up the AndroidManifest.xml
file and add the following immediately before the <application>
tag:
<uses-permission android:name="android.permission.INTERNET"/>
At this point we can run the application and it will display a map.
Note: If you are running this on an emulator then you need to ensure that the emulator is correctly capable of working with the Google Maps API. The “Nexus 5X” with “API 28” works correctly.
Note: if you get a grey screen instead of a map it likely means that the Google Maps API key is not valid or not present. Follow the instructions in
google_maps_api.xml
to set this up.
Displaying the current location
The first thing we want to be able to do is display our current location on the map. This involves requesting permission from the user to determine their location - which we need to know where our ride should pick us up - and then updating the map to display this. All of this is added to the existing MainActivity.
Firstly, update the onMapReady
function as follows:
override fun onMapReady(googleMap: GoogleMap) {
mMap = googleMap
mMap.isMyLocationEnabled = true
mMap.isTrafficEnabled = true
}
This simply updates the map to show the My Location and Traffic layers.
Next, add a new method called setupMap
as follows:
private fun setupMap() {
val mapFragment = supportFragmentManager
.findFragmentById(R.id.map) as SupportMapFragment
mapFragment.getMapAsync(this)
}
This is the code that is currently in onCreate
, but which we will be removing soon.
Next, add a new top-level field to the class called REQUEST_LOCATION_PERMISSIONS
:
private val REQUEST_LOCATION_PERMISSIONS = 1001
This is used so that we know in the callback from requesting permissions which call it was - so that an appropriate response can happen.
Next, another new method called onRequestPermissionsResult
:
override fun onRequestPermissionsResult(requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray) {
if (requestCode == REQUEST_LOCATION_PERMISSIONS) {
if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
setupMap()
} else {
Toast.makeText(this, "Location Permission Denied", Toast.LENGTH_SHORT)
.show();
}
} else {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
}
This is a standard method defined in the FragmentActivity
base class that we are extending for our specific case. If the user grants us permission then we move on to our setupMap
method we’ve just defined, and if they deny us then we show a message and stop there.
Next, a new method called checkLocationPermissions
to actually check if we’ve got permission for accessing the users location already, and if not to request them:
private fun checkLocationPermissions() {
if (checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(android.Manifest.permission.ACCESS_FINE_LOCATION), REQUEST_LOCATION_PERMISSIONS)
return
}
setupMap()
}
Finally we update the onCreate
method as follows:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
checkLocationPermissions()
}
This starts the whole chain off. When the main activity is first created, we check if we have permission to access the users location. If not we request permission. Then, once permission is granted, we use this fact to allow the user to see where they are on the map.
Requesting a ride
Once we know where the user is, we can allow them to request a ride. This will be done by adding a button to the map that they can click on in order to request their ride, which will then send their current location to our backend.
Firstly, lets add our button to the map. Find and update activity_main.xml
as follows:
<fragment xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:map="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/map"
android:name="com.google.android.gms.maps.SupportMapFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.pusher.pushnotify.ride.MainActivity">
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="right|bottom"
android:id="@+id/request_ride"
android:text="Request Ride"
android:padding="10dp"
android:layout_marginTop="20dp"
android:paddingRight="10dp"
android:enabled="false"
android:onClick="requestRide" />
</fragment>
Note: the value for “tools:context” should match the class name of your main activity class.
Most of this was already present. We are adding the Button
element inside the fragment
that was already there.
Next we want to only have this button enabled when we have the location of the user. For this we are going to rely on the Map component telling us when it has got the users location. Update the onMapReady
method of MainActivity
and add this in to the bottom:
mMap.setOnMyLocationChangeListener {
findViewById<Button>(R.id.request_ride).isEnabled = true
}
We’re also going to create a new helper method to display a Toast message to the user:
private fun displayMessage(message: String) {
Toast.makeText(
this,
message,
Toast.LENGTH_SHORT).show();
}
Finally, we will add the requestRide
method that is triggered when the button is clicked. For now this is as follows:
fun requestRide(view: View) {
val location = mMap.myLocation
val request = JSONObject(mapOf(
"latitude" to location.latitude,
"longitude" to location.longitude
))
mMap.animateCamera(CameraUpdateFactory.newLatLngZoom(LatLng(location.latitude, location.longitude), 15.0f))
val client = AsyncHttpClient()
client.post(applicationContext, "http://10.0.2.2:8080/request-ride", StringEntity(request.toString()),
"application/json", object : TextHttpResponseHandler() {
override fun onSuccess(statusCode: Int, headers: Array<out Header>, responseString: String) {
runOnUiThread {
displayMessage("Your ride has been requested")
findViewById<Button>(R.id.request_ride).visibility = View.INVISIBLE
}
}
override fun onFailure(statusCode: Int, headers: Array<out Header>, responseString: String, throwable: Throwable) {
runOnUiThread {
displayMessage("An error occurred requesting your ride")
}
}
});
}
Note: The import for
Header
may be ambiguous. Ensure that you selectcz.msebera.android.httpclient.Header
Note: The IP Address “10.0.2.2” is what the Android emulator sees the host machine as. You will want to change this to the real address of the server if you are running this for real.
This builds our JSON message and sends it to the /request-ride endpoint that we built earlier. That in turn will broadcast out to all potential drivers that there is a new job. We then display a message to the rider that their ride has been requested, or else an error if we failed to request the ride. We also hide the Request Ride button when we have successfully requested a ride, so that we can’t request more than one at a time.
Receiving push notifications
The other major feature we need in the riders app is to be able to receive updates from the driver. This includes when a driver has accepted the job, where he is, and when he is ready to pick up or drop off the rider.
All of these notifications work in very similar manner, containing the location of the driver and the action to perform. We want to always update our map to show the position of the driver, and in many cases to display a message to the rider informing them as to what is going on.
Firstly, we need to register with the Pusher Beams service to be able to receive push notifications. Add the following to the onCreate
method of MainActivity
:
PushNotifications.start(getApplicationContext(), "YOUR_INSTANCE_ID");
Where “YOUR_INSTANCE_ID” is replaced with the value received from the Pusher Beams registration process, and must match the value used in the backend application.
Next we want to actually register to receive notifications from the backend. This is done by updating the o``nSuccess
method inside the requestRide
method of MainActivity
as follows:
override fun onSuccess(statusCode: Int, headers: Array<out Header>, responseString: String) {
PushNotifications.subscribe("rider_$responseString")
runOnUiThread {
displayMessage("Your ride has been requested")
findViewById<Button>(R.id.request_ride).visibility = INVISIBLE
}
}
This builds an interest string that contains the job ID that we were provided, meaning that we will now receive all rider-focused notifications for this job.
The only thing remaining is to actually handle the notifications. This involves displaying where on the map the driver currently is, and potentially displaying an update message to the rider.
Firstly, add a new field to the MainAction
class to store the marker for the drivers location:
private var driverMarker: Marker? = null
This defaults to null
until we actually first get a location.
Next, add a new method called updateDriverLocation
in the MainActivity
class to set the location of the driver, creating the marker if needed:
private fun updateDriverLocation(latitude: Double, longitude: Double) {
val location = LatLng(latitude, longitude)
if (driverMarker == null) {
driverMarker = mMap.addMarker(MarkerOptions()
.title("Driver Location")
.position(location)
)
} else {
driverMarker?.position = location
}
mMap.animateCamera(CameraUpdateFactory.newLatLngZoom(location, 17.0f))
}
Finally, add the necessary handler to receive the push notifications and react accordingly. For this, create a new method called onResume
in the MainActivity
class as follows:
override fun onResume() {
super.onResume()
PushNotifications.setOnMessageReceivedListenerForVisibleActivity(this, object : PushNotificationReceivedListener {
override fun onMessageReceived(remoteMessage: RemoteMessage) {
val action = remoteMessage.data["action"]
runOnUiThread {
updateDriverLocation(remoteMessage.data["latitude"]!!.toDouble(), remoteMessage.data["longitude"]!!.toDouble())
if (action == "ACCEPT_JOB") {
displayMessage("Your ride request has been accepted. Your driver is on their way.")
} else if (action == "PICKUP") {
displayMessage("Your driver has arrived and is waiting for you.")
} else if (action == "DROPOFF") {
displayMessage("You are at your destination")
findViewById<Button>(R.id.request_ride).visibility = View.VISIBLE
}
}
}
})
}
This will call our new method to update the location of the driver on the map, and for selected actions will display a message informing the rider of what is happening. We also re-display the Request Ride button when the drop-off action occurs, so that the rider can use the app again if needed.
This completes the riders side of the application, allowing them to do everything they need to for the ride:
Building the drivers application
The driver Android application will also be built in Kotlin, using Android Studio. To start, open up Android Studio and create a new project, entering some appropriate details and ensuring that you select the Include Kotlin support option. Note that the Package name must match that specified when you set up the FCM Server Key earlier.
Note: these instructions are almost exactly the same as for the riders app, but are repeated here for ease of following along.
Then on the next screen, ensure that you select support for Phone and Tablet using at least API 23:
Ensure that an Google Maps Activity is selected:
And set the Activity Name to “MainActivity” and Layout Name to “activity_main”:
Once the project opens, you will be presented with the file google_maps_api.xml
with instructions on how to get a Google Maps API key. Follow these instructions to get a key to use in the application. This can not be the same key as for the rider application since they are tied to the actual Android application that is using it. It should belong to the same Google project however.
Next we need to add some dependencies to our project to support Pusher. Add the following to the project level build.gradle
, in the existing dependencies
section:
classpath 'com.google.gms:google-services:3.2.1'
Then add the following to the dependencies
section of the app level build.gradle
:
implementation 'com.google.firebase:firebase-messaging:15.0.0'
implementation 'com.pusher:push-notifications-android:0.10.0'
compile 'com.loopj.android:android-async-http:1.4.9'
compile 'com.google.code.gson:gson:2.2.4'
And this to bottom of the app level build.gradle
:
apply plugin: 'com.google.gms.google-services'
Next, copy the google-services.json
file we downloaded earlier into the app
directory under your project. We are now ready to actually develop our specific application using these dependencies.
Finally, we need to add some permissions to our application. Open up the AndroidManifest.xml
file and add the following immediately before the <application>
tag:
<uses-permission android:name="android.permission.INTERNET"/>
At this point we can run the application and it will display a map.
Note: If you are running this on an emulator then you need to ensure that the emulator is correctly capable of working with the Google Maps API. The “Nexus 5X” with “API 28” works correctly.
Note: if you get a grey screen instead of a map it likely means that the Google Maps API key is not valid or not present. Follow the instructions in
google_maps_api.xml
to set this up.
Displaying the current location
The first thing we want to be able to do is display our current location on the map. This involves requesting permission from the user to determine their location - which we need to know where our ride should pick us up - and then updating the map to display this. All of this is added to the existing MainActivity.
Note: this is all exactly the same as for the riders application, but is repeated here for ease of following along.
Firstly, update the onMapReady
function as follows:
override fun onMapReady(googleMap: GoogleMap) {
mMap = googleMap
mMap.isMyLocationEnabled = true
mMap.isTrafficEnabled = true
}
This simply updates the map to show the My Location and Traffic layers.
Next, add a new method called setupMap
as follows:
private fun setupMap() {
val mapFragment = supportFragmentManager
.findFragmentById(R.id.map) as SupportMapFragment
mapFragment.getMapAsync(this)
}
This is the code that is currently in onCreate
, but which we will be removing soon.
Next, add a new top-level field to the class called REQUEST_LOCATION_PERMISSIONS
:
private val REQUEST_LOCATION_PERMISSIONS = 1001
This is used so that we know in the callback from requesting permissions which call it was - so that an appropriate response can happen.
Next, another new method called onRequestPermissionsResult
:
override fun onRequestPermissionsResult(requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray) {
if (requestCode == REQUEST_LOCATION_PERMISSIONS) {
if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
setupMap()
} else {
Toast.makeText(this, "Location Permission Denied", Toast.LENGTH_SHORT)
.show();
}
} else {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
}
This is a standard method defined in the FragmentActivity
base class that we are extending for our specific case. If the user grants us permission then we move on to our setupMap
method we’ve just defined, and if they deny us then we show a message and stop there.
Next, a new method called checkLocationPermissions
to actually check if we’ve got permission for accessing the users location already, and if not to request them:
private fun checkLocationPermissions() {
if (checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(android.Manifest.permission.ACCESS_FINE_LOCATION), REQUEST_LOCATION_PERMISSIONS)
return
}
setupMap()
}
Finally we update the onCreate
method as follows:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
checkLocationPermissions()
}
This starts the whole chain off. When the main activity is first created, we check if we have permission to access the users location. If not we request permission. Then, once permission is granted, we use this fact to allow the user to see where they are on the map.
Receive notifications of new jobs
Now that we can show the driver where they are on the map, we want to show them where the potential riders are and allow them to accept a job.
Firstly, we need to register with the Pusher Beams service to be able to receive push notifications, and then subscribe to the driver_broadcast
interest to be told about the jobs. Add the following to the onCreate
method of MainActivity
:
PushNotifications.start(getApplicationContext(), "YOUR_INSTANCE_ID");
PushNotifications.subscribe("driver_broadcast")
Where “YOUR_INSTANCE_ID” is replaced with the value received from the Pusher Beams registration process, and must match the value used in the backend application.
Next, add a method to display a message to the user when we need to inform them of something. Create the method displayMessage
in the MainActivity
class as follows:
private fun displayMessage(message: String) {
Toast.makeText(
this,
message,
Toast.LENGTH_SHORT).show();
}
Next, add a new top level field into the MainActivity
class to store the markers that we are placing:
private val markers = mutableMapOf<String, Marker>()
Next, we add a listener so that when we are notified about a job we can place a pin on the map showing where the rider is. For this, add a new onResume
method to the MainActivity
class as follows:
override fun onResume() {
super.onResume()
PushNotifications.setOnMessageReceivedListenerForVisibleActivity(this, object : PushNotificationReceivedListener {
override fun onMessageReceived(remoteMessage: RemoteMessage) {
val action = remoteMessage.data["action"]
runOnUiThread {
if (action == "NEW_JOB") {
val jobId = remoteMessage.data["job"]!!
val location = LatLng(remoteMessage.data["latitude"]!!.toDouble(), remoteMessage.data["longitude"]!!.toDouble())
val marker = mMap.addMarker(MarkerOptions()
.position(location)
.title("New job"))
marker.tag = jobId
markers[jobId] = marker
displayMessage("A new job is available")
}
}
}
})
}
We are setting the tag
on the marker to the ID of the job that has turned up. This will be used next to allow the driver to accept the job. We are also storing the marker in a map so that we can look it up later by ID.
Accepting a job
Accepting a job is going to be done by clicking on a marker. Once done, the app will send a message to the backend to accept the job, and will start sending regular messages with the drivers location. It will also allow for a Pickup and Dropoff button to be displayed for the driver to click as appropriate.
Firstly, add a new top-level field to the MainActivity
class to store the ID of the current job:
private var currentJob: String? = null
Next, update the onMapReady
method to add a handler for clicking on a marker. This will send the HTTP request to our backend to accept the job, and record the fact in the application that this is now the current job.
mMap.setOnMarkerClickListener { marker ->
if (currentJob != null) {
runOnUiThread {
displayMessage("You are already on a job!")
}
} else {
val jobId = marker.tag
val location = mMap.myLocation
val request = JSONObject(mapOf(
"latitude" to location.latitude,
"longitude" to location.longitude
))
val client = AsyncHttpClient()
client.post(applicationContext, "http://10.0.2.2:8080/accept-job/$jobId", StringEntity(request.toString()),
"application/json", object : TextHttpResponseHandler() {
override fun onSuccess(statusCode: Int, headers: Array<out Header>, responseString: String) {
runOnUiThread {
displayMessage("You have accepted this job")
currentJob = jobId as String
}
}
override fun onFailure(statusCode: Int, headers: Array<out Header>, responseString: String, throwable: Throwable) {
runOnUiThread {
displayMessage("An error occurred accepting this job")
}
}
});
}
true
}
Note: The import for
Header
may be ambiguous. Ensure that you selectcz.msebera.android.httpclient.Header
Removing old jobs from the map
We also want to tidy up the map when a job is accepted, removing markers from every drivers map - including the driver that accepted the job - but adding a new one in a different colour back to the local drivers map.
Firstly, add another new field to the MainActivity
class for the marker of the job we are currently on:
private var currentJobMarker: Marker? = null
Next, update the onMessageReceived
callback inside the onResume
method of MainActivity
as follows:
override fun onMessageReceived(remoteMessage: RemoteMessage) {
val action = remoteMessage.data["action"]
runOnUiThread {
if (action == "NEW_JOB") {
val jobId = remoteMessage.data["job"]!!
val location = LatLng(remoteMessage.data["latitude"]!!.toDouble(), remoteMessage.data["longitude"]!!.toDouble())
val marker = mMap.addMarker(MarkerOptions()
.position(location)
.title("New job"))
marker.tag = jobId
markers[jobId] = marker
displayMessage("A new job is available")
} else if (action == "ACCEPTED_JOB") {
val jobId = remoteMessage.data["job"]!!
val location = LatLng(remoteMessage.data["latitude"]!!.toDouble(), remoteMessage.data["longitude"]!!.toDouble())
markers[jobId]?.remove()
markers.remove(jobId)
}
}
}
Here we are adding the block to handle the ACCEPTED_JOB
event alongside the NEW_JOB
event. This is broadcast out to every driver when any driver accepts a job, and is used to remove the markers indicating a job is waiting for pickup.
Finally, add the following in to the onSuccess
handler in the onMapReady
method of MainActivity
:
val selectedJobMarker = markers[jobId]!!
val marker = mMap.addMarker(MarkerOptions()
.position(selectedJobMarker.position)
.icon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_AZURE))
.title("Current job"))
marker.tag = jobId
currentJobMarker = marker
This adds a new marker to the map, coloured blue instead of the default red, to indicate the job that we are actively on.
Picking up and dropping off
In order to pick up and drop off the rider, we need to add UI controls to support this. We are going to add buttons that appear on the map at appropriate times to allow the driver to indicate that he’s ready for pickup and for dropoff.
Firstly, update activity_main.xml
as follows to add the buttons:
<fragment xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:map="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/map"
android:name="com.google.android.gms.maps.SupportMapFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.pusher.pushnotify.ride.MainActivity">
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="right|bottom"
android:id="@+id/pickup_ride"
android:text="Pickup"
android:padding="10dp"
android:layout_marginTop="20dp"
android:paddingRight="10dp"
android:visibility="invisible"
android:onClick="pickupRide" />
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="right|bottom"
android:id="@+id/dropoff_ride"
android:text="Dropoff"
android:padding="10dp"
android:layout_marginTop="20dp"
android:paddingRight="10dp"
android:visibility="invisible"
android:onClick="dropoffRide" />
</fragment>
These buttons are initially invisible, but we will display them as necessary in the application.
Next, update the onSuccess
method inside of onMapReady
to ensure the correct buttons are displayed. This should now look like:
override fun onSuccess(statusCode: Int, headers: Array<out Header>, responseString: String) {
runOnUiThread {
displayMessage("You have accepted this job")
currentJob = jobId as String
findViewById<Button>(R.id.dropoff_ride).visibility = View.INVISIBLE
findViewById<Button>(R.id.pickup_ride).visibility = View.VISIBLE
}
}
Finally, we add the handlers for these buttons. First the one to pick up the rider. Add a new method called pickupRide
as follows:
fun pickupRide(view: View) {
val location = mMap.myLocation
val request = JSONObject(mapOf(
"latitude" to location.latitude,
"longitude" to location.longitude
))
val client = AsyncHttpClient()
client.post(applicationContext, "http://10.0.2.2:8080/pickup/$currentJob", StringEntity(request.toString()),
"application/json", object : TextHttpResponseHandler() {
override fun onSuccess(statusCode: Int, headers: Array<out Header>, responseString: String?) {
runOnUiThread {
findViewById<Button>(R.id.dropoff_ride).visibility = View.VISIBLE
findViewById<Button>(R.id.pickup_ride).visibility = View.INVISIBLE
currentJobMarker?.remove()
currentJobMarker = null
}
}
override fun onFailure(statusCode: Int, headers: Array<out Header>, responseString: String, throwable: Throwable) {
runOnUiThread {
displayMessage("An error occurred picking up your ride")
}
}
});
}
This will make the call to the backend, and on success will cause the Pickup button to be hidden and the Dropoff button to be displayed. It also removes the blue marker for the current job, since we have just picked them up.
Next the handler for dropping off the rider. Add another new method called dropoffRide
as follows:
fun dropoffRide(view: View) {
val location = mMap.myLocation
val request = JSONObject(mapOf(
"latitude" to location.latitude,
"longitude" to location.longitude
))
val client = AsyncHttpClient()
client.post(applicationContext, "http://10.0.2.2:8080/dropoff/$currentJob", StringEntity(request.toString()),
"application/json", object : TextHttpResponseHandler() {
override fun onSuccess(statusCode: Int, headers: Array<out Header>, responseString: String?) {
runOnUiThread {
findViewById<Button>(R.id.dropoff_ride).visibility = View.INVISIBLE
findViewById<Button>(R.id.pickup_ride).visibility = View.INVISIBLE
currentJob = null
}
}
override fun onFailure(statusCode: Int, headers: Array<out Header>, responseString: String, throwable: Throwable) {
runOnUiThread {
displayMessage("An error occurred dropping off your ride")
}
}
});
}
Sending location updates
The final thing that we need to do is have the driver application send updates about its location so that the rider can be updated.
This involves using the phones GPS to get updates every time the phone moves, and sending these updates to the backend - but only if we are currently on a job.
In order to do this, add the following to the bottom of the setupMap
method in MainActivity
. This is used because it’s called already once we know we have permission to get the devices location.
val locationManager = applicationContext.getSystemService(LocationManager::class.java)
locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 100, 0.0f, object : LocationListener {
override fun onLocationChanged(location: Location) {
if (currentJob != null) {
val request = JSONObject(mapOf(
"latitude" to location.latitude,
"longitude" to location.longitude
))
val client = AsyncHttpClient()
client.post(applicationContext, "http://10.0.2.2:8080/update-location/$currentJob", StringEntity(request.toString()),
"application/json", object : TextHttpResponseHandler() {
override fun onSuccess(statusCode: Int, headers: Array<out Header>, responseString: String?) {
}
override fun onFailure(statusCode: Int, headers: Array<out Header>, responseString: String, throwable: Throwable) {
}
});
}
}
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {
}
override fun onProviderEnabled(provider: String?) {
}
override fun onProviderDisabled(provider: String?) {
}
}, null)
Note: it’s likely that Android Studio will complain about having not performed the correct permissions checks. This error is actually wrong, except that Android Studio can’t tell that because of the way the methods are structured.
Note: we have a number of empty methods here. They are required to be defined by the calling class, but we don’t actually have any need for them.
At this point, we have a fully working application suite that allows riders to request rides, and drivers to pick them up and drop them off. Remember to run your backend application before you launch the Android apps, and then we can test them out working together.
Conclusion
This article shows how to use Pusher Beams along with the location and maps functionality of your phone to give a truly interactive experience of requesting a ride. We have painlessly implemented the sending of appropriate details from one device to another, keeping both parties updated to the current job.
The full source code for this application is available on GitHub. Why not try extending it yourself. There are many additional things that can be added very easily using Pusher technology to improve the application even further.
29 June 2018
by Graham Cox