IN THIS ARTICLE

    Hello, and welcome back to our series on Android development with Fabric and PubNub! In previous articles, we’ve shown how to create a powerful chat app with Android, Fabric, and Digits. In this blog entry, we highlight 2 key technologies, Twitter Fabric (mobile development toolkit) and MapBox Kit for Fabric (a world-class open source mapping toolkit). With these technologies, we can accelerate mobile app development and build an app with several realtime data features that you will be able to use as-is or employ easily in your own data streaming applications:

    PubNub provides a global realtime Data Stream Network with extremely high availability and low latency. With PubNub, it’s possible for data exchange between devices (and/or sensors, servers, you name it – essentially anything that can talk TCP) in less than a quarter of a second worldwide. And of that 250ms, a large part comes from the last hop – the data carrier itself! As 4G LTE (5G won’t be far away) and cloud computing gain traction, those latencies will decrease even further.

    Twitter Fabric is a development toolkit for Android (as well as iOS and some Web capabilities) that gives developers a powerful array of options:

    • Familiar dev toolkit for iOS, Android and Web Applications : a unified developer experience that focuses on ease of development and maintenance.
    • Brings best-of-breed SDKs all in one place : taking the pain out of third-party SDK provisioning and using new services in your application.
    • Streamlined dependency management : Fabric plugin kits are managed together to consolidate dependencies and avoid “dependency hell”.
    • Rapid application development with task-based sample code onboarding : You can access and integrate sample code use cases right from the IDE.
    • Automated Key Provisioning : sick of creating and managing yet another account? So were we! Fabric will provision those API keys for you.
    • Open Source, allowing easier understanding, extension and fixes.

    MapBox is a high-quality SDK on the Fabric platform enabling easy integration of mapping capabilities (iOS, Python, and HTML/JavaScript implementations are also available). In our application, this will allow us to create a new UI that displays all online users on a dynamic map in realtime.

    This may seem like a lot to digest. How do all these things fit together exactly?

    • We are building an Android app because we want to reach the most devices worldwide.
    • We use the Fabric plugin for Android Studio, giving us our “mission control” for plugin adoption and app releases.
    • We adopt Best-of-Breed services (like PubNub) rapidly by quickly integrating plugin kits and sample code in Fabric.
    • We use PubNub as our Global Realtime Data Stream Network to power the Chat and Presence features.
    • In addition, we’ll use MapBox kit for Fabric to provide the best mapping capabilities possible.

     

    map_video

    As you can see in the animated GIF above, once everything is together, we have built an application very quickly that provides a great feature set with relatively little code and integration pain. This includes:

    • Log in with Digits phone-based authentication (or your own alternative login mechanism).
    • Send & receive chat messages (or whatever structured realtime data you like).
    • Show a list of users online (or devices/sensors/vehicles, etc.).
    • Display users on a dynamic world map.

    This all seems pretty sweet, so let’s move on to the development side…

    Sign up with Fabric

    If you haven’t already, you’ll want to create a Fabric account like this:

    android-studio-fabric-signup

    You should be on your way in 60 seconds or less!

    Android Studio

    In Android studio, as you know, everything starts out by creating a new Project.

    android-studio-create-project

    In our case, we’ve done much of the work for you – you can jumpstart development with the sample app by downloading it from GitHub, or the “clone project from GitHub” feature in Android Studio if prefer. The Git url for the sample app is:

    https://github.com/sunnygleason/pubnub-android-fabric-chat-ext.git
    

    Once you have the code, you’ll want to create a Fabric Account if you haven’t already.

    Then, you can integrate the Fabric Plugin according to the instructions you’re given. The interface in Android Studio should look something like this, under Preferences > Plugins > Browse Repositories:

    install-fabric-plugin

    Once everything’s set, you’ll see the happy Fabric Plugin on the right-hand panel:

    fabric-plugin-view

    Click the “power button” to get started, and you’re on your way!

    MapBox SDK Integration

    Adding MapBox is an easy 4-step process:

    • Click to Install from the list of Fabric kits
    • Enter your MapBox keys or have Fabric create a new account
    • Integrate any Sample Code you need to get started
    • Launch the App to verify successful integration… and that’s it!

    Here’s a visual overview of what that looks like:

    mapbox_install

     

    PubNub SDK Integration

    Adding PubNub is just as easy:

    • Click to Install from the list of Fabric kits
    • Enter your PubNub keys or have Fabric create a new account
    • Integrate any Sample Code you need to get started
    • Launch the App to verify successful integration

    Look familiar? That’s the beauty of Fabric!

    fabric-plugin-add-pubnub

     

    Using this same process, you can integrate over a dozen different toolkits and services with Fabric.

    Additional Background Information

    This article builds on the sample application described in a previous article in the series. If you would like more information about the core features and implementation, please feel free to check it out! There is also a pre-recorded training webinar available, as well as ongoing live webinars!

    Navigating the Code

    Once you’ve set up the sample application, you’ll want to update the publish and subscribe keys in the Constants class, your Twitter API keys in the MainActivity class, your Fabric API key in the AndroidManifest.xml, and MapBox API key in strings.xml. These are the keys you created when you made a new PubNub and MapBox accounts and PubNub application in previous steps. Make sure to update these keys, or the app won’t work!

    Here’s what we’re talking about in the Constants class:

    package com.pubnub.example.android.fabric.pnfabricchat;
    public class Constants {
        ...
        public static final String PUBLISH_KEY = "YOUR_PUBLISH_KEY";            // replace with your PN PUB KEY
        public static final String SUBSCRIBE_KEY = "YOUR_SUBSCRIBE_KEY";        // replace with your PN SUB KEY
        ...
    }
    

    These values are used to initialize the connection to PubNub when the user logs in.

    And in the MainActivity:

    public class MainActivity extends AppCompatActivity {
        private static final String TWITTER_KEY = "YOUR_TWITTER_KEY";
        private static final String TWITTER_SECRET = "YOUR_TWITTER_SECRET";
        ...
    }
    

    These values are necessary for the user authentication feature in the sample application.

    And in the AndroidManifest.xml:

    <?xml version="1.0" encoding="utf-8"?>
    <manifest xmlns:android="http://schemas.android.com/apk/res/android"
        package="com.pubnub.example.android.fabric.pnfabricchat">
        ...
        <application ...>
            ...
            <meta-data
                android:name="io.fabric.ApiKey"
                android:value="YOUR_API_KEY" />
            ...
        </application>
        ...
    </manifest>
    

    This is used by the Fabric toolkit to integrate features into the application.

    Here’s where to integrate MapBox in the strings.xml:

    <resources>
        ...
        <string name="com.mapbox.mapboxsdk.accessToken" translatable="false">YOUR_MAPBOX_TOKEN</string>
        ...
    </resources>
    

    As with any Android app, there are 2 main portions of the project – the Android code (written in Java), and the resource files (written in XML).

    The Java code contains 2 Activities, plus packages for each major feature: chat, presence, and mapbox. (The speech package is for another article on dictation and text-to-speech features – check it out!)

    pubnub_fabric_mapbox_java_code

     

    The resource XML files include layouts for each activity, fragments for the 2 tabs, list row layouts for each data type, and a menu definition with a single option for “logout”.

    pubnub_fabric_ext_resources

    Whatever you need to do to modify this app, chances are you’ll just need to tweak some Java code or resources. In rare cases, you might add some additional dependencies in the build.gradle file, or modify permissions or behavior in the AndroidManifest.xml.

    In the Java code, there is a package for each of the main features:

    • chat : code related to implementing the realtime chat feature.
    • presence : code related to implementing the online presence list of users.
    • mapbox : helper code for working with the dynamic map UI.

    For ease of understanding, there is a common structure to each of these packages that we’ll dive into shortly.

    Android Manifest

    The Android manifest is very straightforward – we need 3 permissions (INTERNET, ACCESS_FINE_LOCATION, and ACCESS_NETWORK_STATE), and have 2 activities: LoginActivity (for login), and MainActivity (for the main application).

    You’ll also need to enable the Telemetry service for MapBox to work.

    The XML is available here and described in the previous article.

    Layouts

    Our application uses several layouts to render the application:

    • Activity : the top-level layouts for LoginActivity and MainActivity.
    • Fragment : layouts for our the tabs, Chat, Presence, and PresenceMap.
    • Row Item : layouts for the the types of ListView, Chat and Presence.

    These are all standard layouts that we pieced together from the Android developer guide, but we’ll go over them all just for the sake of completeness.

    Activity Layouts

    The login activity layout is pretty simple – it’s just one button for the Twitter login, and one button for the super-awesome Digits auth.

    The XML is available here and described in the previous article.

    It results in a layout that looks like this:

    digits-click-to-auth

    The Main Activity features a tab bar and view pager – this is pretty much the standard layout suggested by the Android developer docs for a tab-based, swipe-enabled view:

    The XML is available here and described in the previous article.

    It results in a layout that looks like this:

    presence_map

    Fragment Layouts

    Ok, now that we have our top-level views, let’s dive into the tab fragments.

    The presence map tab layout features a dynamic map using the MapBox Map View.

    <?xml version="1.0" encoding="utf-8"?>
    <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">
        <com.mapbox.mapboxsdk.maps.MapView
            android:id="@+id/mapboxMarkerMapView"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />
    </RelativeLayout>
    

    Pretty easy indeed! It creates a UI that looks like this:

    presence_map

     

    Java Code

    In the code that follows, we’ve categorized things into a few areas for ease of explanation. Some of these are standard Java/Android patterns, and some of them are just tricks we used to follow PubNub or other APIs more easily.

    • Activity : these are the high-level views of the application, the Java code provides initialization and UI element event handling.
    • Pojo : these are Plain Old Java Objects representing the “pure data” that flows from the network into our application.
    • Fragment : these are the Java classes that handle instantiation of the UI tabs.
    • RowUi : these are the corresponding UI element views of the Pojo classes (for example, the sender field is represented by an TextView in the UI).
    • PnCallback : these classes handle incoming PubNub data events (for publish/subscribe messaging and presence).
    • Adapter : these classes accept the data from inbound data events and translate them into a form that is useful to the UI.

    That might seem like a lot to take in, but hopefully as we go into the code it should feel a lot easier.

    LoginActivity

    The LoginActivity is pretty basic – we just include code for instantiating the view and setting up Digits login callbacks. If you look at the actual source code, you’ll also notice code to support Twitter auth as well.

    The Java code is available here and described in the previous article.

    We attach the login event to a callback with two outcomes: the success callback, which extracts the phone number and moves on to the MainActivity to display a Toast message; and the error callback, which does nothing but Log (for now).

    In a real application, you’d probably want to use the Digits user ID from the digitsSession to link it to a user account in the backend.

    MainActivity

    There’s a lot going on in the MainActivity. This makes sense, since it’s the place where the application is initialized and where UI event handlers live. Take a moment to glance through the code and we’ll talk about it below. We’ve removed a bunch of code to highlight the portions that are used for our dynamic location and mapping services.

    public class MainActivity extends AppCompatActivity {
        ...
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            ...
            this.mLocationHelper = new LocationHelper(this, getLocationListener());
            ...
            this.mPresenceMapAdapter = new PresenceMapAdapter(this);
            ...
            this.mPresenceCallback = new PresencePnCallback(this.mPresenceListAdapter, this.mPresenceMapAdapter);
            ...
            adapter.setPresenceMapAdapter(this.mPresenceMapAdapter);
            viewPager.setAdapter(adapter);
            viewPager.addOnPageChangeListener(new TabLayout.TabLayoutOnPageChangeListener(tabLayout));
            ...
            initPubNub();
            initChannels();
        }
        ...
        @Override
        protected void onStart() {
            super.onStart();
            if (this.mLocationHelper != null) {
                this.mLocationHelper.connect();
            }
        }
        @Override
        protected void onStop() {
            super.onStop();
            if (this.mLocationHelper != null) {
                this.mLocationHelper.disconnect();
            }
        }
        ...
        private final LocationListener getLocationListener() {
            return new LocationListener() {
                @Override
                public void onLocationChanged(final Location newLocation) {
                    JSONObject location = new JSONObject();
                    if (newLocation != null) {
                        location.tryPut("lat", String.valueOf(newLocation.getLatitude()));
                        location.tryPut("lon", String.valueOf(newLocation.getLongitude()));
                    }
                    MainActivity.this.mPubnub.setState(Constants.CHANNEL_NAME, MainActivity.this.mUsername, location, new Callback() {
                        @Override
                        public void successCallback(String channel, Object message) {
                            Log.v("setState", channel + ":" + message);
                            mPresenceMapAdapter.update(new PresenceMapPojo(mUsername, newLocation.getLatitude(), newLocation.getLongitude(), DateTimeUtil.getTimeStampUtc()));
                        }
                    });
                }
            };
        }
        ...
        private final void initChannels() {
            ...
            this.mPubnub.hereNow(Constants.CHANNEL_NAME, true, true, this.mPresenceCallback);
            ...
        }
        ...
    }
    

    The first thing you’ll notice in this code is that we create a LocationHelper instance, which is our helper code to bridge between Google Play Location Services and our dynamic mapping feature. We instantiate the LocationHelper with a reference to the Activity context, as well as a LocationListener instance to receive location update events.

    The most important things happening in the onCreate() method with respect to the speech features are as follows:

    • Create a PresenceMapAdapter, which will be responsible for translating location and presence events into map update events.
    • Pass the PresenceMapAdapter into the PresencePnCallback so that it can receive location state change events from PubNub.
    • Set the PresenceMapAdapter within the PresenceMapTabFragment via the fragment manager (since it’s in charge of instantiating the fragments itself).

    In addition, we’ll need to add code to:

    • Start/stop location services during those Android application events.
    • Publish location updates via PubNub setState() method and update the PresenceMapAdapter accordingly.
    • Modify our usual PubNub hereNow() call to ask for uuid and state information (the 2 true booleans in the hereNow() call).

    Stay tuned for more description of the location and mapping helpers below.

    Chat and Presence Features

    The Java code for the chat and presence features is available here and here and described in the previous article.

    The Pojo classes are the most straightforward of the entire app – they are just immutable objects that hold data values as they come in. We make sure to give them toString(), hashCode(), and equals() methods so they play nicely with Java collections.

    The RowUi object just aggregates the UI elements in a list row. Right now, these just happen to be TextView instances.

    The TabFragment object takes care of instantiating the tab and hooking up the ListAdapter.

    The PnCallback is the bridge between the PubNub client and our application logic. It takes an inbound messageObject object and turns it into a Pojo value that is forwarded on to the ListAdapter instance.

    PresenceMapPojo

    The PresenceMapPojo is very similar to the PresencePojo, except that it contains Double instances for latitude and longitude instead of a presence state.

    public class PresenceMapPojo {
        private final String sender;
        private final Double lat;
        private final Double lon;
        private final String timestamp;
        ...
    }
    

    PresenceMapTabFragment

    The PresenceMapTabFragment class is a little bigger than usual because we’re initializing the MapBox Map View. We create references to the MapView and MapboxMap so we can initialize the PresenceMapAdapter at the appropriate time. The MapView is the overall Map view implementation that integrates into the Android UI. The MapboxMap is the object we will interact with to add location markers for each user.

    public class PresenceMapTabFragment extends Fragment {
        private PresenceMapAdapter presenceMapAdapter;
        private AtomicReference<MapView> mapViewRef = new AtomicReference<MapView>();
        private AtomicReference<MapboxMap> mapboxMapRef = new AtomicReference<MapboxMap>();
        @Override
        public View onCreateView(LayoutInflater inflater, ViewGroup container,
                                 Bundle savedInstanceState) {
            View view = inflater.inflate(R.layout.fragment_presence_map, container, false);
            MapView mapView = (MapView) view.findViewById(R.id.mapboxMarkerMapView);
            mapViewRef.set(mapView);
            mapView.setAccessToken(getString(R.string.com_mapbox_mapboxsdk_accessToken));
            mapView.onCreate(savedInstanceState);
            mapView.getMapAsync(new OnMapReadyCallback() {
                @Override
                public void onMapReady(MapboxMap mapboxMap) {
                    mapboxMap.setStyle(Style.MAPBOX_STREETS);
                    mapboxMapRef.set(mapboxMap);
                    if (presenceMapAdapter != null) {
                        presenceMapAdapter.refreshAll();
                    }
                }
            });
            return view;
        }
        public void setAdapter(PresenceMapAdapter presenceMapAdapter) {
            this.presenceMapAdapter = presenceMapAdapter;
            presenceMapAdapter.setMapView(mapViewRef, mapboxMapRef);
        }
        ...
    }
    

    PresenceMapAdapter

    The PresenceMapAdapter follows the Android Adapter pattern, which is used to bridge data between Java data collections and user interfaces (although in this case, we’re bridging to a map view instead of a list view). Since we’re using PubNub, messages are coming in all the time, unexpected from the point of view of the UI. This adapter is invoked from the PresencePnCallback class: when a presence event comes in, the callback invokes PresenceMapAdapter.update() with a PresenceMapPojo object containing the relevant data.

    In the case of the PresenceMapAdapter, the backing collections are maps of uuid to PresenceMapPojo and Map MarkerView instances, so the update() and refresh() calls need to:

    • Update the items in the collection (actually prepend by doing put(uuid, value)).
    • Create, update, or remove the map marker view accordingly (this should happen on the UI thread).

    We use AtomicReference instances since the Map objects are initialized at different times in the application. The MapboxMap instance is created asynchronously in the Tab Fragment class when the MapView is initialized.

    Not too bad!

    public class PresenceMapAdapter {
        private final Context context;
        private final Map<String, MarkerView> latestMarker = new LinkedHashMap<>();
        private final Map<String, PresenceMapPojo> latestPresence = new LinkedHashMap<>();
        private AtomicReference<MapView> mapViewRef;
        private AtomicReference<MapboxMap> mapboxMapRef;
        public PresenceMapAdapter(Context context) {
            this.context = context;
        }
        public void setMapView(AtomicReference<MapView> mapViewRef, AtomicReference<MapboxMap> mapboxMapRef) {
            this.mapViewRef = mapViewRef;
            this.mapboxMapRef = mapboxMapRef;
        }
        public void update(final PresenceMapPojo message) {
            ...
            latestPresence.put(message.getSender(), message);
            if (mapboxMapRef.get() == null) {
                return;
            }
            ((Activity) this.context).runOnUiThread(new Runnable() {
                @Override
                public void run() {
                    if (latestMarker.containsKey(message.getSender())) {
                        mapboxMapRef.get().removeMarker(latestMarker.get(message.getSender()));
                        latestMarker.remove(message.getSender());
                    }
                    MarkerViewOptions markerOptions = new MarkerViewOptions()
                            .position(new LatLng(message.getLat(), message.getLon()))
                            .title(message.getSender())
                            .snippet(message.getTimestamp());
                    MarkerView marker = mapboxMapRef.get().addMarker(markerOptions);
                    latestMarker.put(message.getSender(), marker);
                }
            });
        }
        public void refresh(final PresencePojo message) {
            ...
            String presence = message.getPresence();
            if ("timeout".equals(presence) || "leave".equals(presence)) {
                latestPresence.remove(message.getSender());
                if (mapboxMapRef.get() == null) {
                    return;
                }
                if (latestMarker.containsKey(message.getSender())) {
                    ((Activity) this.context).runOnUiThread(new Runnable() {
                        @Override
                        public void run() {
                            mapboxMapRef.get().removeMarker(latestMarker.get(message.getSender()));
                            latestMarker.remove(message.getSender());
                        }
                    });
                }
            }
        }
        public void refreshAll() {
            for (PresenceMapPojo message : latestPresence.values()) {
                update(message);
            }
        }
    }
    

    PresencePnCallback

    The PresencePnCallback features a bunch of changes from the version in the previous article. The main difference is that we’re using the custom state API for propagating user location information. When presence events come in, we look for the “state” attribute (sometimes also called “data”) and update the corresponding user location accordingly. When “leave” or “timeout” events occur, we propagate null location events to remove the user location marker from the map.

    public class PresencePnCallback extends Callback {
        ...
        @Override
        public void successCallback(String channel, Object message) {
            ...
            try {
                Map<String, Object> presence = JsonUtil.fromJSONObject((JSONObject) message, LinkedHashMap.class);
                List<Map<String, Object>> uuids;
                if (presence.containsKey("uuids")) {
                    uuids = (List<Map<String, Object>>) presence.get("uuids");
                } else {
                    uuids = ImmutableList.<Map<String, Object>>of(presence);
                }
                for (Map<String, Object> object : uuids) {
                    ...
                    if (object.containsKey("data") || object.containsKey("state")) {
                        // we have a state change
                        if (presenceMapAdapter != null) {
                            Log.v(TAG, "presenceStateChange(" + JsonUtil.asJson(presence) + ")");
                            if ("timeout".equals(presenceString) || "leave".equals(presenceString)) {
                                presenceMapAdapter.refresh(pm);
                            } else {
                                Map<String, Object> state = object.containsKey("data") ? (Map<String, Object>) object.get("data") : (Map<String, Object>) object.get("state");
                                ;
                                if (state.containsKey("lat") && state.containsKey("lon")) {
                                    Double lat = Double.parseDouble((String) state.get("lat").toString());
                                    Double lon = Double.parseDouble((String) state.get("lon").toString());
                                    presenceMapAdapter.update(new PresenceMapPojo(sender, lat, lon, timestamp));
                                }
                            }
                        }
                    }
                    ...
                }
            } catch (Exception e) {
                throw Throwables.propagate(e);
            }
        }
        ...
    }
    

    The code is a little trickier than necessary because we’re using the same callback to send events to the PresenceListAdapter and PresenceMapAdapter instances. All in all though, it’s not too tough to wire everything together!

    Location Helper

    The location update feature uses Google Play Location Services, which has a friendly API to work with. There are a multitude of callbacks to implement for these APIs, which is the main reason why we broke out a LocationHelper class instead of implementing them in the MainActivity class.

    public class LocationHelper implements GoogleApiClient.ConnectionCallbacks, GoogleApiClient.OnConnectionFailedListener, LocationListener {
        private GoogleApiClient mGoogleApiClient;
        private LocationListener mLocationListener;
        public LocationHelper(Context context, LocationListener mLocationListener) {
            this.mGoogleApiClient = new GoogleApiClient.Builder(context)
                    .addConnectionCallbacks(this)
                    .addOnConnectionFailedListener(this)
                    .addApi(LocationServices.API)
                    .build();
            this.mGoogleApiClient.connect();
            this.mLocationListener = mLocationListener;
        }
        public void connect() {
            this.mGoogleApiClient.connect();
        }
        public void disconnect() {
            this.mGoogleApiClient.disconnect();
        }
        @Override
        public void onConnected(@Nullable Bundle bundle) {
            try {
                Location lastLocation = LocationServices.FusedLocationApi.getLastLocation(
                        mGoogleApiClient);
                if (lastLocation != null) {
                    onLocationChanged(lastLocation);
                }
            } catch (SecurityException e) {
                Log.v("locationDenied", e.getMessage());
            }
            try {
                LocationRequest locationRequest = LocationRequest.create().setInterval(5000);
                LocationServices.FusedLocationApi.requestLocationUpdates(mGoogleApiClient, locationRequest, this);
            } catch (SecurityException e) {
                Log.v("locationDenied", e.getMessage());
            }
        }
        ...
        @Override
        public void onLocationChanged(Location location) {
            try {
                Log.v("locationChanged", JsonUtil.asJson(location));
            } catch (Exception e) {
                throw Throwables.propagate(e);
            }
            mLocationListener.onLocationChanged(location);
        }
        @Override
        public void onConnectionSuspended(int i) {
            mLocationListener.onLocationChanged(null);
        }
    }
    

    The implementation initializes Location Services, asks for the last known location, and requests dynamic location updates from the Google Play Location API. When we receive location change events, we forward them on to the LocationListener instance that we were initialized with. In this case, it’s the LocationListener created in the MainActivity that calls PubNub.setState() with the new location. Not too shabby!

    In a real-world implementation, you’ll probably want to pay close attention to your location accuracy and power utilization. More updates equals more battery waste, so be frugal!

    Conclusion

    Thank you so much for staying with us this far! Hopefully it’s been a useful experience. The goal was to convey our experience in how to build an app that can:

    • Authenticate with Twitter or Digits auth.
    • Send chat messages to a PubNub channel.
    • Display current and historical chat messages.
    • Display a list of what users are online.
    • Display a dynamic map of online users.

    If you’ve been successful thus far, you shouldn’t have any trouble extending the app to any of your realtime data processing needs.

    Stay tuned, and please reach out anytime if you feel especially inspired or need any help!

    Resources

    Try PubNub today!

    Build realtime applications that perform reliably and securely, at global scale.
    Try Our APIs
    Try PubNub today!
    More From PubNub