Build a Kotlin ride sharing app with push notifications

  • Graham Cox
June 29th, 2018
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 select cz.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 select cz.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.

  • Beams
  • Channels

© 2018 Pusher Ltd. All rights reserved.

Pusher Limited is a company registered in England and Wales (No. 07489873) whose registered office is at 160 Old Street, London, EC1V 9BW.