Create a carpooling app with React Native - Part 1: Setting up the server

  • Wern Ancheta
June 7th, 2018
You will need a React Native development environment, Docker and Docker Compose installed on your machine. This tutorial assumes you know how to install and configure these on your own system. You will need experience with React Native and Docker.

In this series, we’re going to create a carpooling app with React Native. This will be a two-part series showing you how to create a full-stack React Native app which uses PHP as the backend. The first part covers setting up the backend, while the second part will cover building the frontend and running the app.

I’ve previously written a similar tutorial: Build a ride hailing app with React Native. The main difference between the two is that the first one shows how to build an app similar to the following:

The main idea of the above apps is to provide a ride-hailing service to users. This is traditionally called “Ridesharing”.

While this tutorial will show you how to build an app similar to these:

The main idea of the above apps is for users to share their ride with people who are going the same route as them. This is traditionally called “Carpooling”.

Prerequisites

This tutorial requires the following to be already set up on your machine:

  • React Native development environment - the series assumes that you already have set up all the software needed to create and run React Native apps. The series will show you how to create the app for both Android and iOS devices. We will use the react-native init command to create a React Native project. You can either have both Android Studio and Xcode set up on your machine or just one of them. Additionally, you can set up Genymotion so you can easily change your in-app location. Be sure to check out the setup instructions if you haven’t setup your machine already.
  • Docker and Docker Compose - the series assumes that you already have Docker and Docker Compose running on your machine. We will be using those to easily setup a server with all the software that we need. This also assures that we both have the same environment.
  • Git - used for cloning repos.

Knowing the basics of creating a React Native app is required. This means you have to know how to run the app on an emulator or your device. You should also have a good grasp of basic React concepts such as props, refs, state, and the component lifecycle.

Knowledge of Docker is required. You should know how to setup Docker on your operating system and setup a containers from scratch. Note that Docker has poor support for Windows 7 and 8. So if you’re using any of those systems, you might have difficulty in following this tutorial.

Knowledge of the following will be helpful, but not required. I’ll try to cover as much detail as I can, so readers with zero knowledge of the following will still be able to follow along:

Lastly, the tutorial assumes that you know your way around the operating system that you’re using. Knowing how to install new software, execute commands in the terminal is required.

What we’ll be building

Before we proceed, it’s important to know what exactly we’ll be building. The app will have two modes:

  • sharing - this allows the user to share their ride so that others can make a request to ride with them. For the rest of the series, I’ll be referring to the users who uses this feature as the “rider”.
  • hiking - this allows the user to make a request to ride with someone. I’ll be referring to these users as “hikers”.

Below is the entire flow of the app. I’m using Genymotion emulator for the user that plays the rider, and iPhone for the hiker. This is so I can emulate a moving vehicle by using Genymotion’s GPS emulation tool:

I can simply click around the map so that React Native’s Geolocation is triggered. This then allows me to use Pusher Channels to send a message to the hiker so that they’re informed of the rider’s current location.

Now, let’s proceed with the app flow:

  1. First, the rider enters their username and clicks Share a ride:

  1. Rider types in where they want to go and selects it from the drop-down. Google Places Autocomplete makes this feature work:

  1. After selecting a place, the app plots the most desirable route from the origin to the destination. The red marker being the origin, and the blue one being the destination:

If the rider wants to pick another place, they can click on the Reset button. This will empty the text field for entering the place as well as remove the markers and the route from the map.

  1. At this point, the rider clicks on the Share Ride button. This triggers a request to the server which then saves all the relevant data to an Elasticsearch index. This allows hikers to search for them later on.

To keep the route information updated, we use React Native’s Geolocation feature to watch the rider’s current location. Every time their location changes, the Elasticsearch index is also updated:

  1. Now let’s take a look at the hiker’s side of things. First, the hiker enters their username and clicks on Hitch a ride:

  1. Next, the hiker searches for their destination. To keep things simple, let’s pick the same place where the rider is going:

  1. Once again, the app plots the most desirable route from the hiker’s origin to their destination:

  1. The hiker then clicks on the Search Ride button. At this point, the app makes a request to the server to look for riders matching the route added by the hiker. The rider should now receive the request. Pusher Channels makes this feature work:

  1. Once the rider accepts the request, the hiker receives an alert that the rider accepted their request:

  1. At this point, the hiker’s map will show rider’s current location. React Native’s Geolocation feature and Pusher Channels make this work:

At the same time, the rider’s map will show their current location on the map. This is where you can use Genymotion’s GPS emulation tool to update the rider’s location:

  1. Once the rider is near the hiker, both users will receive a notification informing them that they’re already near each other:

  1. Once they are within 20 meters of each other, the app’s UI resets and it goes back to the login screen:

We will use the following technologies to build the app:

  • Elasticsearch - for saving and searching for routes.
  • Pusher Channels - for establishing realtime communication between the rider and the hiker so they are kept updated where each other is.
  • PHP - for saving and searching documents from the Elasticsearch index.
  • Google Maps - for showing maps inside the app.
  • Google Places Autocomplete - for searching for places.
  • Google Directions API - for getting the directions between the origin and the destination of the riders and hikers.
  • Geometry Library Google Maps API V3 - for determining whether a specific coordinate lies within a set of coordinates.

The full source code of the app is available on this Github repo.

Setting up a Pusher app

We’ll need to create a Pusher app to use Pusher Channels. Start by creating a Pusher account if you haven’t done so already.

Once you have an account, go to your dashboard and click on Channels apps on the left side of the screen, then click on Create Channels apps. Enter the name of your app and select a desirable cluster, preferably one that’s nearest to your current location:

Once the app is created, click on the App Settings tab and enable client events:

This will allow us to trigger events right from the app itself. That way, the only thing that we need to do on the server is to authenticate requests. Don’t forget to click on Update once you’re done.

The API keys which we’ll be using later are on the App keys tab.

Setting up a Google project

We will be using three of Google’s services to build this app:

  • Google Maps
  • Google Places
  • Google Directions

This requires us to create a Google project at console.developers.google.com so we can use those services.

On your dashboard, click on the Select a project dropdown then click on Create project. Enter the name of the project and click Create:

Once the project is created, click on Library on the left side. Look for the following APIs and enable them:

  • Maps SDK for Android
  • Maps SDK for iOS - note that if you don’t enable this, and followed the installation instructions for iOS, Apple Maps will be used instead.
  • Places SDK for Android
  • Places SDK for iOS
  • Directions API
  • Geocoding API

Once those are enabled, click on the Credentials menu on the left side, then click on the Create credentials button and select API key:

That will generate an API key which allows you to use the services mentioned above. Take note of the key as we will be using it later.

You can choose to restrict access so not just anybody can use your key once they get access to it. To avoid problems while developing the app, I recommend to just leave it for now.

Setting up Laradock

Laradock is a full PHP development environment for Docker. It allows us to easily set up the development server. Go through the following steps to setup Laradock.

Configuring the environment

  1. Clone the official repo. This will create a laradock directory:
    git clone https://github.com/laradock/laradock.git --branch v7.0.0

Note that in the above command we’re cloning a specific release tag (v7.0.0). This is to make sure we’re both using the same version of Laradock. This helps you avoid issues that has to do with different configuration and software versions installed by Laradock. You can choose to clone the most recent version, but you’ll have to handle the compatibility issues on your own.

  1. Navigate inside the laradock directory and create a copy of the sample .env file:
    cd laradock
    cp env-example .env
  1. Open the .env file on your text editor and replace the existing config with the following:

This is the directory where your projects are saved. Go ahead and create a laradock-projects folder outside the laradock folder. Then inside the laradock-projects, create a new folder named ridesharer. This is where we will add the server code:

    APP_CODE_PATH_HOST=../laradock-projects

This is the Elasticsearch port configuration. The one below is actually the default one so in most cases, you don’t really need to change anything. But if you have a different configuration, or if you want to use a different port because an existing application is already using these ports then this is a good place to change them:

    ELASTICSEARCH_HOST_HTTP_PORT=9200
    ELASTICSEARCH_HOST_TRANSPORT_PORT=9300

This is the path where the Apache site configuration is located. We will be updating it at a later step. This is just to let you know that this is where it’s located:

    APACHE_SITES_PATH=./apache2/sites

Adding a virtual host

  1. Open the laradock/apache2/sites/default.apache.conf file and add a new virtual host (you can also replace the existing one if you’re not using it):
    <VirtualHost *:80>
      ServerName ridesharer.loc
      DocumentRoot /var/www/ridesharer
      Options Indexes FollowSymLinks

      <Directory "/var/www/ridesharer">
        AllowOverride All
        <IfVersion < 2.4>
          Allow from all
        </IfVersion>
        <IfVersion >= 2.4>
          Require all granted
        </IfVersion>
      </Directory>

    </VirtualHost>

The code above tells Apache to serve the files inside the /var/www/ridesharer directory when http://ridesharer.loc is accessed on the browser. If the directory has index.php file in it, then it will get served by default (if the filename is not specified).

The /var/www directory maps to the application directory you’ve specified earlier on the .env file:

    APP_CODE_PATH_HOST=../laradock-projects

This means that /var/www/ridesharer is equivalent to /laradock-projects/ridesharer.

This is why we’ve created a ridesharer folder inside the laradock-projects directory earlier. Which means that any file you create inside the ridesharer folder will get served.

  1. Update the operating system’s hosts file to point out ridesharer.loc to localhost:
    127.0.0.1     ridesharer.loc

This tells the browser to not go looking anywhere else on the internet when http://ridesharer.loc is accessed. Instead, it will just look in the localhost.

Configuring Elasticsearch

Open the docker-compose.yml file and search for ElasticSearch Container. This will show you the Elasticsearch configuration:

    ### ElasticSearch ########################################
        elasticsearch:
          build: ./elasticsearch
          volumes:
            - elasticsearch:/usr/share/elasticsearch/data
          environment:
            - cluster.name=laradock-cluster
            - bootstrap.memory_lock=true
            - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
          ulimits:
            memlock:
              soft: -1
              hard: -1
          ports:
            - "${ELASTICSEARCH_HOST_HTTP_PORT}:9200"
            - "${ELASTICSEARCH_HOST_TRANSPORT_PORT}:9300"
          depends_on:
            - php-fpm
          networks:
            - frontend
            - backend

Under the environment, add the following:

    - xpack.security.enabled=false

So it should look like this:

    environment:
      - cluster.name=laradock-cluster
      - bootstrap.memory_lock=true
      - xpack.security.enabled=false
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"

This disables the need to authenticate when connecting to Elasticsearch.

You can choose to enable it later so that not just anyone can have access to the Elasticsearch index. But to avoid problems with authentication while we’re developing, we’ll disable it for now.

Bringing up the container

Navigate inside the laradock directory and bring up the container with Docker Compose:

    docker-compose up -d apache2 php-fpm elasticsearch workspace

This will install and setup Apache, PHP, and Elasticsearch on the container. There’s also a workspace so you can log in to the container. This allows you to install packages using Composer.

This process should take a while depending on your internet connection.

Troubleshooting Laradock issues

If you’re having problems completing this step, it is most likely a port issue. That is, another process is already using the port that the containers wants to use.

The quickest way to deal with a port issue is to change the default ports that Apache and Elasticsearch are using (or whatever port is already occupied by another process). Open the .env file inside the laradock folder and make the following changes:

For Apache, replace the values for either APACHE_HOST_HTTPS_PORT or APACHE_PHP_UPSTREAM_PORT (or both):

    # APACHE_HOST_HTTPS_PORT=443 
    APACHE_HOST_HTTPS_PORT=445 

    # APACHE_PHP_UPSTREAM_PORT=9000
    APACHE_PHP_UPSTREAM_PORT=9001

For Elasticsearch:

    # ELASTICSEARCH_HOST_HTTP_PORT=9200
    ELASTICSEARCH_HOST_HTTP_PORT=9211

    # ELASTICSEARCH_HOST_TRANSPORT_PORT=9300
    ELASTICSEARCH_HOST_TRANSPORT_PORT=9311

It’s a good practice to comment out the default config so you know which one’s you’re replacing.

If the issue you’re having isn’t a port issue, then you can visit Laradock’s issues page and search for the issue you’re having.

Creating the server component

Installing the Dependencies

Once all the software is installed in the container, Docker will automatically bring it up. This allows you to login to the container. You can do that by executing the following command while inside the laradock directory:

    docker-compose exec --user=laradock workspace bash

Once you’re inside, navigate inside the ridesharer folder and create a composer.json file:

    {
      "require": {
        "alexpechkarev/geometry-library": "1.0",
        "elasticsearch/elasticsearch": "^6.0",
        "pusher/pusher-php-server": "^3.0",
        "vlucas/phpdotenv": "^2.4"
      }
    }

Save the file and execute composer install. This will install the following packages:

  • geometry-library - as mentioned earlier, this allows us to determine whether a specific coordinate lies within a set of coordinates. We will be using this library to determine if the directions returned by the Google Directions API covers the hiker’s pick-up location (origin).
  • elasticsearch - this library allows us to query the Elasticsearch index so we can add, search, update, or delete documents.
  • pusher-php-server - this is the official Pusher PHP library for communicating with Pusher’s server. We will be using it to authenticate requests coming from the app.
  • vlucas/phpdotenv - for loading environment variables from .env files. The .env file is where we put the Elasticsearch, Google, and Pusher config.

Adding environment variables

Inside the laradock-projects/ridesharer directory, create a .env file and add the following:

    PUSHER_APP_ID="YOUR PUSHER APP ID"
    PUSHER_APP_KEY="YOUR PUSHER APP KEY"
    PUSHER_APP_SECRET="YOUR PUSHER APP SECRET"
    PUSHER_APP_CLUSTER="YOUR PUSHER APP CLUSTER"

    GOOGLE_API_KEY="YOUR GOOGLE API KEY"

    ELASTICSEARCH_HOST="elasticsearch"

This file is where you will put the keys and configuration options that we will be using for the server.

Loader file

Since the majority of the files we will be creating will use either the configuration from the .env file or connect to the Elasticsearch server, we will be using this file to do those task for us. That way, we simply need to include this file on each of the files instead of repeating the same code.

Start by importing the Elasticsearch\ClientBuilder class to the current scope. This allows us to use the ClientBuilder class without having to refer to its namespace Elasticsearch everytime we need to use it:

    // laradock-projects/ridesharer/loader.php
    use Elasticsearch\ClientBuilder;

Include the vendor autoload file. This allows us to include all the packages that we installed earlier:

    require 'vendor/autoload.php';

Load the .env file:

    $dotenv = new Dotenv\Dotenv(__DIR__);
    $dotenv->load();

    $elasticsearch_host = getenv('ELASTICSEARCH_HOST'); // get the elasticsearch config

After that, connect to Elasticsearch:

    $hosts = [
      [
        'host' => $elasticsearch_host
      ]
    ];

    $client = ClientBuilder::create()->setHosts($hosts)->build();

Setting the type mapping

Since we will be working with coordinates in this app, we need to tell Elasticsearch which of the fields we will be using are coordinates. That way, we can query them later using functions which are specifically created to query geo-point data. This is done through a process called Mapping.

Start by including the loader file:

    <?php 
    // laradock-projects/ridesharer/set-map.php
    require 'loader.php';

Next, we can now proceed with specifying the actual map. Note that an error might occur (for example, the index has already been created, or one of the datatypes we specified isn’t recognized by Elasticsearch) so we’re wrapping everything in a try..catch. This allows us to “catch” the error and present it in a friendly manner:

    try {
      $indexParams['index']  = 'places'; // the name of the index

      $myTypeMapping = [
        '_source' => [
          'enabled' => true
        ],
        'properties' => [
          'from_coords' => [
            'type' => 'geo_point'
          ],
          'to_coords' => [
            'type' => 'geo_point'
          ],
          'current_coords' => [
            'type' => 'geo_point'
          ],
          'from_bounds.top_left.coords' => [
            'type' => 'geo_point'
          ],
          'from_bounds.bottom_right.coords' => [
            'type' => 'geo_point'
          ],
          'to_bounds.top_left.coords' => [
            'type' => 'geo_point'
          ],
          'to_bounds.bottom_right.coords' => [
            'type' => 'geo_point'
          ]
        ]
      ];

      // next: add code for adding the map

    } catch(\Exception $e) {
      echo 'err: ' . $e->getMessage();
    }

Breaking down the code above, we first specify the name of the index we want to use. This shouldn’t already exist within Elasticsearch. If you’re coming from an RDBMS background, an index is synonymous to a database:

    $indexParams['index']  = 'places';

For the actual type mapping, we only need to specify two properties: _source and properties.

_source allows us to specify whether to enable returning of the source when getting documents. In Elasticsearch, the _source contains the fields (and their values) that we’ve indexed.

In a real-world app, you don’t really want this option to be enabled as it will affect the search performance. We’re only enabling it so that we don’t have to perform an additional step to fetch the source whenever where querying the index:

    '_source' => [
      'enabled' => true
    ],

The other property that we need to specify is the properties. This accepts the array of field names whose data type we want to specify. Of course, we don’t need to specify the data type of all the fields we plan on using. This is because the data type is already implied in most cases (for example, if it’s wrapped in double or single quotes then it’s a string). But for special data types such as the geo-point, that’s the time where we need to explicitly specify it:

    'from_coords' => [
      'type' => 'geo_point'
    ],

If the field that you want to work with is located deep within other fields, then you use the dot notation to specify the parent:

    'from_bounds.top_left.coords' => [
      'type' => 'geo_point'
    ]

Lastly, add the code for creating the index with the map that we specified:

    $indexParams['body']['mappings']['location'] = $myTypeMapping; // specify the map
    $response = $client->indices()->create($indexParams); // create the index
    print_r($response); // print the response

Access http://ridesharer.loc/set-map.php on your browser and it should print out a success response.

Note that if you have another local development environment that’s currently running, it might be the one that takes priority instead of Laradock. So be sure to disable them if you can’t access the URL above.

Creating users

When someone uses the app, they need to login first. If the username they used doesn’t already exist then it’s created.

Start by getting the data passed from the app, in PHP this is commonly done by extracting the field name from the $_POST global variable. But in this case, we’re using the PHP input stream to read the raw POST data from the request body. This is because this is how Axios (the library that we’ll be using in the app later on) submits the data when sending requests to the server:

    <?php 
    // laradock-projects/ridesharer/create-user.php
    require 'loader.php';

    $data = json_decode(file_get_contents("php://input"), true);
    $username = $data['username']; // get the value from the username field

Construct the parameters to be supplied to Elasticsearch. This includes the index and the type. You can think of the type as the table or collection that you want to query.

    $params = [
      'index' => 'places', // the index 
      'type' => 'users' // the table or collection
    ];

Specify the query. In this case, we’re telling Elasticsearch to look for an exact match for the username supplied:

    $params['body']['query']['match']['username'] = $username; // look for the username specified

Execute the search query, if it doesn’t return any “hits” then we create a new user using the username that was supplied:

    try {
      $search_response = $client->search($params); // execute the search query

      if($search_response['hits']['total'] == 0){ // if the username doesn't already exist
        // create the user
        $index_response = $client->index([
          'index' => 'places',
          'type' => 'users',
          'id' => $username,
          'body' => [
            'username' => $username
          ]
        ]);
      }

      echo 'ok';

    } catch(\Exception $e) {
      echo 'err: ' . $e->getMessage();
    }

Saving routes

Whenever a rider shares a ride, the following information needs to be stored in the index:

  • username
  • origin
  • destination
  • origin coordinates
  • destination coordinates
  • the steps from the origin to destination

Start by getting the data submitted from the app:

    <?php 
    // laradock-projects/ridesharer/save-route.php
    require 'loader.php';

    $google_api_key = getenv('GOOGLE_API_KEY');

    $data = json_decode(file_get_contents("php://input"), true);
    $start_location = $data['start_location']; // an array containing the coordinates (latitude and longitude) of the rider's origin
    $end_location = $data['end_location']; // the coordinates of the rider's destination

    $username = $data['username']; // the rider's username
    $from = $data['from']; // the descriptive name of the rider's origin
    $to = $data['to']; // the descriptive name of the rider's destination
    $id = generateRandomString(); // unique ID used for identifying the document

Make a request to the Google Directions API using the file_get_contents() function. The directions endpoint expects the origin and destination to be passed as a query parameter. These two contains the latitude and longitude value pairs (separated by a comma). We simply pass the values supplied from the app.

The file_get_contents() function returns a JSON string so we use the json_decode() function to convert it to an array. Specifying true as the second argument tells PHP to convert it to an array instead of an object (when the second argument is omitted or set to false):

    $steps_data = [];

    $contents = file_get_contents("https://maps.googleapis.com/maps/api/directions/json?origin={$start_location['latitude']},{$start_location['longitude']}&destination={$end_location['latitude']},{$end_location['longitude']}&key={$google_api_key}");

    $directions_data = json_decode($contents, true);

Loop through the array of steps and construct an array ($steps_data) that only contains the data that we want to store. In this case, it’s only the latitude and longitude values for each of the steps:

    if(!empty($directions_data['routes'])){
      $steps = $directions_data['routes'][0]['legs'][0]['steps'];
      foreach($steps as $step){
        $steps_data[] = [
          'lat' => $step['start_location']['lat'],
          'lng' => $step['start_location']['lng']
        ];

        $steps_data[] = [
          'lat' => $step['end_location']['lat'],
          'lng' => $step['end_location']['lng']
        ];
      }
    }

Next, construct the data that we’ll save to the Elasticsearch index:

    if(!empty($steps_data)){

      $params = [
        'index' => 'places',
        'type' => 'location',
        'id' => $id,
        'body' => [
          'username' => $username, 
          'from' => $from, 
          'to' => $to,
          'from_coords' => [ // geo-point values needs to have lat and lon
            'lat' => $start_location['latitude'],
            'lon' => $start_location['longitude'],
          ],
          'current_coords' => [
            'lat' => $start_location['latitude'],
            'lon' => $start_location['longitude'],
          ],
          'to_coords' => [
            'lat' => $end_location['latitude'],
            'lon' => $end_location['longitude'],
          ],
          'steps' => $steps_data
        ]
      ];

    }

Make the request to index the data:

    try{
      $response = $client->index($params);
      $response_data = json_encode([
        'id' => $id
      ]);

      echo $response_data;
    }catch(\Exception $e){
      echo 'err: ' . $e->getMessage();
    }

Here’s the function for generating a unique ID:

    function generateRandomString($length = 10){
      $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
      $charactersLength = strlen($characters);
      $randomString = '';
      for($i = 0; $i < $length; $i++){
        $randomString .= $characters[rand(0, $charactersLength - 1)];
      }
      return $randomString;
    }

Searching routes

When a hiker searches for a ride, a request is made to this file. This expects the origin and destination of the hiker to be passed in the request body. That way, we can make a request to the Google Directions API with those data:

    <?php 
    // /laradock-projects/ridesharer/search-routes.php
    require 'loader.php';

    $google_api_key = getenv('GOOGLE_API_KEY');

    $params['index'] = 'places';
    $params['type'] = 'location';

    $data = json_decode(file_get_contents("php://input"), true);

    // the hiker's origin coordinates
    $hiker_origin_lat = $data['origin']['latitude'];
    $hiker_origin_lon = $data['origin']['longitude'];

    // the hiker's destination coordinates
    $hiker_dest_lat = $data['dest']['latitude'];
    $hiker_dest_lon = $data['dest']['longitude'];

    $hiker_directions_contents = file_get_contents("https://maps.googleapis.com/maps/api/directions/json?origin={$hiker_origin_lat},{$hiker_origin_lon}&destination={$hiker_dest_lat},{$hiker_dest_lon}&key={$google_api_key}");

    $hiker_directions_data = json_decode($hiker_directions_contents, true);

Store the hiker’s steps into an array. We will be using it later to determine whether the hiker and the rider have the same route. Note that we’re only storing the start_location for the first step. This is because the start_location of all the succeeding steps overlaps with the end_location of the step that follows:

    $hikers_steps = [];

    $steps = $hiker_directions_data['routes'][0]['legs'][0]['steps']; // extract the steps
    foreach($steps as $index => $s){
      if($index == 0){ 
        $hikers_steps[] = [
          'lat' => $s['start_location']['lat'],
          'lng' => $s['start_location']['lng']
        ];  
      }

      $hikers_steps[] = [
        'lat' => $s['end_location']['lat'],
        'lng' => $s['end_location']['lng']
      ];
    }

Next, we construct the query to be sent to Elasticsearch. Here we use a d``ecay function called gauss to assign a score to each of the routes that are currently saved in the index. This score is then used to determine the order in which the results are returned, or whether they will be returned at all.

Specifying the min_score means all the documents which don’t meet the supplied score won’t be returned in the response. In the code below, we’re querying for documents which are up to five kilometers away from the origin. But once the documents have a current_coords which are not within 100 meters, the score assigned to them is halved:

    $params['body'] = [
      "min_score" => 0.5, // the minimum score for the function to return the record
      'query' => [
        'function_score' => [
          'gauss' => [
            'current_coords' => [
              "origin" => ["lat" => $hiker_origin_lat, "lon" => $hiker_origin_lon], // where to begin the search
              "offset" => "100m", // only select documents that are up to 100 meters away from the origin
              "scale" => "5km" // (offset + scale = 5,100 meters) any document which are not within the 100 meter offset but are still within 5,100 meters gets a score of 0.5
            ]
          ]
        ]
      ]
    ];

If you want to know more about how the function works, check this article out: The Closer, The Better.

Next, construct the coordinates for the hiker’s origin and destination. We will use this to compute the distance between the hiker’s origin and destination, as well as the hiker’s origin and the rider’s destination. We will need these values later on to determine whether the routes returned from the query matches the hiker’s route:

    $hikers_origin = ['lat' => $hiker_origin_lat, 'lng' => $hiker_origin_lon];
    $hikers_dest = ['lat' => $hiker_dest_lat, 'lng' => $hiker_dest_lon];

Send the request and loop through all the results:

    try {
      $response = $client->search($params);

      if(!empty($response['hits']) && $response['hits']['total'] > 0){
        foreach($response['hits']['hits'] as $hit){

          $source = $hit['_source'];
          $riders_steps = $source['steps'];

          $current_coords = $source['current_coords'];
          $to_coords = $source['to_coords'];

          $riders_origin = [
            'lat' => $current_coords['lat'],
            'lng' => $current_coords['lon']
          ];

          $riders_dest = [
            'lat' => $to_coords['lat'],
            'lng' => $to_coords['lon']
          ];

          // check whether the rider's route matches the hiker's route
          if(isCoordsOnPath($hiker_origin_lat, $hiker_origin_lon, $riders_steps) && canDropoff($hikers_origin, $hikers_dest, $riders_origin, $riders_dest, $hikers_steps, $riders_steps)){
            // the rider's username, origin and destination
            $rider_details = [
              'username' => $source['username'],
              'from' => $source['from'],
              'to' => $source['to']
            ];

            echo json_encode($rider_details); // respond with the first match       
            break; // break out from the loop
          }
        }
      }

    } catch(\Exception $e) {
      echo 'err: ' . $e->getMessage();
    }

The isCoordsOnPath() function uses the isLocationOnPath() function from the php-geometry library. This accepts the following arguments:

  • An array containing the latitude and longitude of the coordinate we want to check.
  • An array of arrays containing the latitude and longitude of each of the steps.
  • The tolerance value in degrees. This is useful if the place specified isn’t near a road. Here, I’ve used a high value to cover for most cases. As long as the hiker’s origin is somewhat near to a road, then it should be fine.
    function isCoordsOnPath($lat, $lon, $path) {
      $response = \GeometryLibrary\PolyUtil::isLocationOnPath(['lat' => $lat, 'lng' => $lon], $path, 350); 
      return $response;
    }

The canDropoff() function determines whether the rider and the hiker are both treading the same route. This accepts the following arguments:

  • $hikers_origin - the coordinates of the hiker’s origin.
  • $hikers_dest - the coordinates of the hiker’s destination.
  • $riders_origin - the coordinates of the rider’s origin.
  • $riders_destination - the coordinates of the rider’s destination.
  • $hikers_steps - an array containing the hiker’s steps.
  • $riders_steps - an array containing the rider’s steps.

The way this function works is that it first determines who leaves the vehicle last: the rider or the hiker. The app works with the assumption that the rider has to ride the vehicle first, and that they should pick up the hiker before they get to leave the vehicle. Otherwise, the hiker won’t be able to track where the vehicle is. This means that there are only two possible scenarios when it comes to the order of leaving the vehicle:

  • rider rides vehicle → rider picks up hiker → rider leaves the vehicle → hiker leaves the vehicle
  • rider rides vehicle → rider picks up hiker → hiker leaves the vehicle → rider leaves the vehicle

The tracking starts once the rider picks up the hiker. So we measure the distance between the hiker’s origin and their destination, as well as the hiker’s origin and the rider’s destination. This then allows us to determine who will leave the vehicle last by comparing the distance between the two.

Once we know the order in which the two users leaves the vehicle, we can now use the isCoordsOnPath() function to determine if the destination of the person who will leave the vehicle first is within the route of the person who will leave the vehicle last:

    function canDropoff($hikers_origin, $hikers_dest, $riders_origin, $riders_dest, $hikers_steps, $riders_steps) {
      // get the distance from the hiker's origin to the hiker's destination
      $hiker_origin_to_hiker_dest = \GeometryLibrary\SphericalUtil::computeDistanceBetween($hikers_origin, $hikers_dest);

      // get the distance from the hiker's origin to the rider's destination
      $hiker_origin_to_rider_dest = \GeometryLibrary\SphericalUtil::computeDistanceBetween($hikers_origin, $riders_dest);

      $is_on_path = false; // whether the rider and hiker is on the same path or not

      if($hiker_origin_to_hiker_dest > $hiker_origin_to_rider_dest){ // hiker leaves the vehicle last
        // if the rider's destination is within the routes covered by the hiker
        $is_on_path = isCoordsOnPath($riders_dest['lat'], $riders_dest['lng'], $hikers_steps); 

      }else if($hiker_origin_to_rider_dest > $hiker_origin_to_hiker_dest){ // rider leaves the vehicle last
        // if hiker's destination is within the routes covered by the rider
        $is_on_path = isCoordsOnPath($hikers_dest['lat'], $hikers_dest['lng'], $riders_steps);

      }else{ // if the rider and hiker are both going the same place
        // check whether either of the conditions above returns true
        $is_on_path = isCoordsOnPath($hikers_dest['lat'], $hikers_dest['lng'], $riders_steps) || isCoordsOnPath($riders_dest['lat'], $riders_dest['lng'], $hikers_steps);
      }

      return $is_on_path;

    }

Update route

Every time the location changes, the app makes a request to this file. The app sends the unique ID that the server responded with when the route was created. This allows us to fetch the existing document from the index. We then update the source with the new coordinates:

    <?php 
    // laradock-projects/ridesharer/update-route.php
    require 'loader.php';

    $data = json_decode(file_get_contents("php://input"), true); // get the request body and convert it to an array

    $params['index'] = 'places';
    $params['type'] = 'location';
    $params['id'] = $data['id']; // the id submitted from the app

    // the latitude and longitude values submitted from the app
    $lat = $data['lat']; 
    $lon = $data['lon']; 

    $result = $client->get($params); // get the document based on the id used as the parameter
    $result['_source']['current_coords'] = [ // update the current coordinates with the latitude and longitude values submitted from the app
      'lat' => $lat,
      'lon' => $lon
    ];

    $params['body']['doc'] = $result['_source']; // replace the source with the updated data
    $result = $client->update($params); // update the document

    echo json_encode($result);

Delete route

Once the rider accepts a request from the hiker, the app makes a request to this file so that the existing route will be deleted. We need to do this because we don’t want other hikers to make another request to the same rider (remember the 1:1 ratio of the rider to hiker?). Also, note that we’re using the rider’s username to query the index. We haven’t really put any security measures to only allow a username to be used on a single app instance, but this tells us that a user can only save one route at a time:

    <?php 
    // laradock-projects/ridesharer/delete-route.php
    require 'loader.php';

    $data = json_decode(file_get_contents("php://input"), true);

    $params['index'] = 'places';
    $params['type'] = 'location';
    $params['body']['query']['match']['username'] = $data['username']; // find the rider's username

    $result = $client->search($params); // search the index
    $id = $result['hits']['hits'][0]['_id']; // only get the first result

    unset($params['body']);
    $params['id'] = $id;
    $result = $client->delete($params);

    echo json_encode($result);

Delete index

Deleting the index (delete-index.php ) isn’t really required for the app to work. Though it will be useful when testing the app. This allows you to reset the Elasticsearch index so you can control the results that are returned when you search for riders:

    <?php 
    // laradock-projects/ridesharer/delete-index.php
    require 'loader.php';

    try {
      $params = ['index' => 'places'];
      $response = $client->indices()->delete($params);
      print_r($response);
    } catch(\Exception $e) {
      echo 'err: ' . $e->getMessage();
    }

Authenticating requests

Below is the code for authenticating requests so that Pusher will allow the user to use the Channels service. This requires the keys from the App keys tab earlier. Be sure to replace the placeholders with your keys:

    <?php 
    // laradock-projects/ridesharer/pusher-auth.php
    require 'vendor/autoload.php';

    // load the .env file located on the same directory as this file
    $dotenv = new Dotenv\Dotenv(__DIR__); 
    $dotenv->load();

    // get the individual config from the .env file. This should be the same as the one's you have on the .env file
    $app_id = getenv('PUSHER_APP_ID');
    $app_key = getenv('PUSHER_APP_KEY');
    $app_secret = getenv('PUSHER_APP_SECRET');
    $app_cluster = getenv('PUSHER_APP_CLUSTER');

Set the content type to application/json as this is what the Pusher client expects in the client side:

    header('Content-Type: application/json');

Connect to the Pusher app using the keys and options. The options include the cluster where the app is running from, and whether to encrypt the connection or not:

    $options = ['cluster' => $app_cluster, 'encrypted' => true]; 
    $pusher = new Pusher\Pusher($app_key, $app_secret, $app_id, $options);

Lastly, get the data sent by the Pusher client and use it as an argument for the socket_auth() method. This method returns the success token required by the Pusher client:

    $channel = $_POST['channel_name'];
    $socket_id = $_POST['socket_id'];

    echo $pusher->socket_auth($channel, $socket_id);

As you can see, we didn’t really apply any form of authentication in the code above. In a real-world scenario, you want to have some form of authentication before returning the success token. This can be a unique ID that’s only assigned to the users of your app, it can also be a key which is then decrypted to come up with a token used for authenticating the request. This unique ID or key is sent from the client side so the server can verify it.

You can test if the server is working by accessing any of the files you created earlier.

Exposing the server with ngrok

So that you can access the virtual host http://ridesharer.loc from the app, you need to setup ngrok. This allows you to expose your virtual host to the internet.

  1. Sign up for an ngrok account.
  2. Go to your dashboard and download ngrok.
  3. Unzip the archive.
  4. Authenticate ngrok using your auth token:
    .\ngrok authtoken YOUR_AUTH_TOKEN
  1. Expose the virtual host:
    ngrok http -host-header=ridesharer.loc 80

This will give you an output similar to the following:

Copy the HTTPS URL as that’s what we’re going to use in the app later on.

Conclusion

That’s it! In this tutorial, we’ve set up the server to be used by the app. You can find the code used in this tutorial on this Github repo. In the second part of this series, we’ll be covering how to create the frontend of the app.

  • 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.