Network Discovery with Titanium

by | Dec 22, 2015 | 1 comment

So you have 2+ devices that need to find each other on a local network. At least one of them is a mobile device running iOS or Android (sorry Windows users!). One device needs to be hosting a server to handle connections. How do they go about finding each other? You could display a local IP address on the server and have the client devices manually type in the address, but that is terrible UX. There has to be a better way!

Network Discovery is the answer to this problem. Specifically Multicast DNS (mDNS) over Zero-configuration networking (zeroconf). Fortunately these are open technologies that have some implementation available on most platforms. Unfortunately these implementations come with their own names and rarely do they offer any indication that they support each other. Zeroconf, Bonjour, Avahi, or mDNS can be used mostly interchangeably. They’re all implementations of the same technology.

The most well known zeroconf implementation is Apple’s Bonjour. Bonjour is an excellent implementation of zeroconf, but it is only available on iOS, OSX, and Windows (not Windows Phone!). Appcelerator Titanium provides support and documentation for both the BonjourService and the BonjourBrowser.

We’ve recently been working on a project that has to connect iOS and Android devices to a desktop application running on a Windows machine. Our approach was to host an HTTP server on the Windows machine and broadcast its presence over zeroconf, specifically using Bonjour for Windows. On the mobile device we will search for this service, find an address that we can use to reach it, and then communicate over a standard HTTP connection. All examples below will follow this implementation strategy.

Using the provided Bonjour Browser we can easily find our service:


// Create the Bonjour Browser (looking for http)
// See more Bonjour Types here: https://developer.apple.com/library/mac/qa/qa1312/_index.html
httpBonjourBrowser = Ti.Network.createBonjourBrowser({
    serviceType : '_http._tcp',
    domain : 'local'
});

// Handle updated services
httpBonjourBrowser.addEventListener('updatedservices', function(e){
    _.each(e.services, function(service, i){
        // Look for our "Shockoe" service
        if(service && service.name && service.name == 'Shockoe'){
            // We have found the Desktop Application!

            // Stop the search
            httpBonjourBrowser.stopSearch();

            // Resolve the service (required to get socket information)
            service.resolve();

            // Do something with the service
	});
});

// Start searching
httpBonjourBrowser.search();

Perfect! We can find our service in iOS! What about Android? Uh oh. There is nothing provided in the Titanium docs for utilizing zeroconf on Android. Unfortunately the answer is going with a native module. I wasn’t able to find a free solution that is up to date, so I had to write my own.

Because we are just doing discovery I have not packaged my module and made it public. This is a very limited implementation that will only work for our specific use case. I share code snipets below, as well as my notes, which should help anyone trying to implement this.

Network Discovery on Android is challenging for several reasons. Apple likes to present Bonjour like it’s a unique technology, but it’s not. This make it difficult to search for. Instead of just searching for “android bonjour” it may be more useful to search for “android network discovery”, “android mdns”, “android zeroconf”, or “android avahi”. Once you realize what you need to be searching for it becomes clear there is a solution! The first solution to explore is the built in Android NsdManager. Google even offers a decent tutorial on how to use it. Here’s what I did with it:


/**
 * Uses zeroconf to discover specific http servers by name
 * 
 * This file was auto-generated by the Titanium Module SDK helper for Android
 * Appcelerator Titanium Mobile
 * Copyright (c) 2009-2010 by Appcelerator, Inc. All Rights Reserved.
 * Licensed under the terms of the Apache Public License
 * Please see the LICENSE included with this distribution for details.
 *
 */
package com.shockoe.zeroconf;

import java.util.HashMap;

import org.appcelerator.kroll.KrollDict;
import org.appcelerator.kroll.KrollProxy;
import org.appcelerator.kroll.annotations.Kroll;
import org.appcelerator.titanium.TiC;
import org.appcelerator.titanium.util.Log;
import org.appcelerator.titanium.util.TiConfig;
import org.appcelerator.titanium.util.TiConvert;
import org.appcelerator.titanium.TiApplication;

import android.content.Context;

// Network Service Discovery Packages
import android.net.nsd.NsdServiceInfo;
import android.net.nsd.NsdManager;

import android.app.Activity;


// KrollProxy
@Kroll.proxy(creatableInModule=ZeroconfModule.class)
public class HTTPServiceLocatorProxy extends KrollProxy
{
    // Standard Debugging variables
    private static final String LCAT = "HTTPServiceLocatorProxy";
    private static final boolean DBG = TiConfig.LOGD;

    // NsdManager and its listeners
    private NsdManager mNsdManager;
    NsdManager.ResolveListener mResolveListener;
    NsdManager.DiscoveryListener mDiscoveryListener;
    NsdManager.RegistrationListener mRegistrationListener;

    // The service type we will be searching for
    private String _serviceType;

    // The name of the service we will be searching for
    private String _serviceName;

    NsdServiceInfo mService;

    // Constructor
    public HTTPServiceLocatorProxy()
    {
        super();

        // Store a reference to the NSD Service of the current activity.
        TiApplication appContext = TiApplication.getInstance();
        Activity activity = appContext.getCurrentActivity();
        mNsdManager = (NsdManager) activity.getSystemService(Context.NSD_SERVICE);
    }

    // Handle creation options
    @Override
    public void handleCreationDict(KrollDict options)
    {
        super.handleCreationDict(options);

        if(options.containsKey("serviceType")){
            _serviceType = options.get("serviceType").toString();
        } else {
            _serviceType = "_http._tcp";
        }

        if(options.containsKey("serviceName")){
            _serviceName = options.get("serviceName").toString();
        } else{
            _serviceName = "ShockoeService";
        }
    }

    // Accessors
    @Kroll.getProperty @Kroll.method
    public String getServiceType(){
        return _serviceType;
    }

    @Kroll.setProperty @Kroll.method
    public void setServiceType(String serviceType){
        _serviceType = serviceType;
    }

    @Kroll.getProperty @Kroll.method
    public String getServiceName(){
        return _serviceName;
    }

    @Kroll.setProperty @Kroll.method
    public void setServiceName(String serviceName){
        _serviceName = serviceName;
    }

    // Methods
    @Kroll.method
    public void search(){
        // Instantiate a new DiscoveryListener
        mDiscoveryListener = new NsdManager.DiscoveryListener() {

            @Override
            public void onDiscoveryStarted(String regType) {
                Log.d(LCAT, "Service discovery started");
            }

            @Override
            public void onServiceFound(NsdServiceInfo service) {
                // A service was found!  Do something with it.
                Log.d(LCAT, "Service discovery success" + service);

                if(service.getServiceName().equals(_serviceName)){
                    // This is the service we are looking for, resolve it.
                    mNsdManager.resolveService(service, mResolveListener);
                }
            }

            @Override
            public void onServiceLost(NsdServiceInfo service) {
                Log.e(LCAT, "service lost" + service);
            }

            @Override
            public void onDiscoveryStopped(String serviceType) {
                Log.d(LCAT, "Discovery stopped: " + serviceType);
            }

            @Override
            public void onStartDiscoveryFailed(String serviceType, int errorCode) {
                Log.e(LCAT, "Discovery failed: Error code:" + errorCode);
            }

            @Override
            public void onStopDiscoveryFailed(String serviceType, int errorCode) {
                Log.e(LCAT, "Discovery failed: Error code:" + errorCode);
                mNsdManager.stopServiceDiscovery(this);
            }
        };

        // Instantiate a new ResolveListener
        mResolveListener = new NsdManager.ResolveListener() {

            @Override
            public void onResolveFailed(NsdServiceInfo service, int errorCode) {
                Log.e(LCAT, "Resolve failed" + errorCode);
            }

            @Override
            public void onServiceResolved(NsdServiceInfo service) {
                Log.d(LCAT, "Resolve Succeeded. " + service);

                // If JavaScript land is listening, report back our findings
                if(hasListeners("servicefound")){
                    HashMap event = new HashMap();
                    event.put("name", service.getServiceName());
                    event.put("type", service.getServiceType());
                    event.put("host", service.getHost().toString().substring(1)); //Host returns has a garbage leading "/"
                    event.put("port", service.getPort());
                    fireEvent("servicefound", event);
                }
            }
        };

        // Start discovering services
        mNsdManager.discoverServices(_serviceType, NsdManager.PROTOCOL_DNS_SD, mDiscoveryListener);
    }

    @Kroll.method
    public void stopSearch(){
        if(mDiscoveryListener != null){
            Log.d(LCAT, "Discovery Listener IS initialized!");
            mNsdManager.stopServiceDiscovery(mDiscoveryListener);
            mDiscoveryListener = null;
        } else{
            Log.d(LCAT, "Discovery Listener not initialized!");
        }
    }

}

This exposes two methods, one that starts the search and one that stops it. The idea is to use it like this:


httpServiceLocator = require('com.shockoe.zeroconf').createHTTPServiceLocator({
    serviceType : '_http._tcp.',
    serviceName : 'ShockoeService'
});

// Handle our service being found
httpServiceLocator.addEventListener('servicefound', function(e){
    // Stop the search
    httpServiceLocator.stopSearch();

    // We have found our service, do something with it

    // Build url based off of service information
    var baseUrl = 'http://'+e.host+':'+e.port;
});

httpServiceLocator.search();

Note that this is different than the built in BonjourBrowser. For our use we wanted to find a single service, retrieve the url, and stop searching once we have found it.

This code does exactly that. Unfortunately it has some issues. First of all, the NsdManager was added in Android 4.1. If you need to support devices lower than that you will need a different library. Second, NsdManager has issues finding services. In my testing I had three services broadcasting over zeroconf on my network. One was the service I was looking for running on a Windows 8 laptop, another was a test service in NodeJS using the mdns package from npm running on a Macbook, and finally a Mac Mini broadcasting our internal CI system portal. Using NsdManager on devices running Android 4.4 or lower I could only find the node server. I tried many scenarios and it was the only server I could find. On Android 5.0.1 or higher I could find all the services without an issue. If your user is on Android 5.0.1 or higher I would recommend the NsdManager approach as seen above. If your user is on Android 4.x or lower I would recommend using another library, jmdns.


/**
 * Uses zeroconf to discover specific http servers by name
 * 
 * This file was auto-generated by the Titanium Module SDK helper for Android
 * Appcelerator Titanium Mobile
 * Copyright (c) 2009-2010 by Appcelerator, Inc. All Rights Reserved.
 * Licensed under the terms of the Apache Public License
 * Please see the LICENSE included with this distribution for details.
 *
 */
package com.shockoe.zeroconf;

import java.util.HashMap;

import org.appcelerator.kroll.KrollDict;
import org.appcelerator.kroll.KrollProxy;
import org.appcelerator.kroll.annotations.Kroll;
import org.appcelerator.titanium.TiC;
import org.appcelerator.titanium.util.Log;
import org.appcelerator.titanium.util.TiConfig;
import org.appcelerator.titanium.util.TiConvert;
import org.appcelerator.titanium.TiApplication;

import android.content.Context;

// Network Service Discovery Packages
import java.io.IOException;

import javax.jmdns.JmDNS;
import javax.jmdns.ServiceEvent;
import javax.jmdns.ServiceListener;

import android.net.wifi.*;
import java.net.InetAddress;
import java.net.UnknownHostException;

import android.app.Activity;


// KrollProxy
@Kroll.proxy(creatableInModule=ZeroconfModule.class)
public class JmdnsHTTPServiceLocatorProxy extends KrollProxy
{
    // Standard Debugging variables
    private static final String LCAT = "JmdnsHTTPServiceLocatorProxy";
    private static final boolean DBG = TiConfig.LOGD;

    // Jmdns and its listeners
    private JmDNS jmdns = null;
    private ServiceListener listener = null;

    WifiManager wifi;
    WifiManager.MulticastLock lock;

    // The service type we will be searching for
    private String _serviceType;

    // The name of the service we will be searching for
    private String _serviceName;

    // Constructor
    public JmdnsHTTPServiceLocatorProxy()
    {
        super();

        // Store a reference to the NSD Service of the current activity.
        TiApplication appContext = TiApplication.getInstance();
        Activity activity = appContext.getCurrentActivity();
        wifi = (android.net.wifi.WifiManager) activity.getSystemService(Context.WIFI_SERVICE);
    }

    // Handle creation options
    @Override
    public void handleCreationDict(KrollDict options)
    {
        super.handleCreationDict(options);

        if(options.containsKey("serviceType")){
            _serviceType = options.get("serviceType").toString() + ".local.";
        } else {
            _serviceType = "_http._tcp" + ".local.";
        }

        if(options.containsKey("serviceName")){
            _serviceName = options.get("serviceName").toString();
        } else{
            _serviceName = "ShockoeService";
        }
    }

    // Accessors
    @Kroll.getProperty @Kroll.method
    public String getServiceType(){
        return _serviceType;
    }

    @Kroll.setProperty @Kroll.method
    public void setServiceType(String serviceType){
        _serviceType = serviceType + ".local.";
    }

    @Kroll.getProperty @Kroll.method
    public String getServiceName(){
        return _serviceName;
    }

    @Kroll.setProperty @Kroll.method
    public void setServiceName(String serviceName){
        _serviceName = serviceName;
    }

    // Methods
    @Kroll.method
    public void search(){
        WifiInfo wifiinfo = wifi.getConnectionInfo();
        int intaddr = wifiinfo.getIpAddress();

        byte[] byteaddr = new byte[] { (byte) (intaddr & 0xff), (byte) (intaddr >> 8 & 0xff), (byte) (intaddr >> 16 & 0xff), (byte) (intaddr >> 24 & 0xff) };
        try{
            InetAddress addr=InetAddress.getByAddress(byteaddr);
            lock = wifi.createMulticastLock("com.shockoe.zeroconf");
            lock.setReferenceCounted(true);
            lock.acquire();
                
            try {
                Log.d(LCAT, "_serviceType: "+_serviceType);
                jmdns = JmDNS.create(addr); 
                
                jmdns.addServiceListener(_serviceType, listener = new ServiceListener() {

                    @Override
                    public void serviceResolved(ServiceEvent ev) {
                        if(ev.getName().equals(_serviceName)){

                            // If JavaScript land is listening, report back our findings
                            if(hasListeners("servicefound")){
                                HashMap event = new HashMap();
                                event.put("name", ev.getName());
                                event.put("type", ev.getType());
                                event.put("host", ev.getInfo().getInetAddresses()[0].getHostAddress());
                                event.put("port", ev.getInfo().getPort());
                                fireEvent("servicefound", event);
                            }
                        }
                    }

                    @Override
                    public void serviceRemoved(ServiceEvent ev) {
                        Log.d(LCAT, "Service removed: " + ev.getName());
                    }

                    @Override
                    public void serviceAdded(ServiceEvent event) {
                        Log.d(LCAT, "service added: "+event);
                        if(event.getName().equals(_serviceName)){
                            Log.d(LCAT, "this is the service we are looking for!");
                            // This is the service we are looking for, resolve it.
                            jmdns.requestServiceInfo(event.getType(), event.getName(), 1);  
                        }
                    }
                });
            } catch (IOException e) {
                e.printStackTrace();
                return;
            }
        } catch(UnknownHostException e){
            Log.d(LCAT, "UNKNOWN HOST EXCEPTION");
        }


        
    }

    @Kroll.method
    public void stopSearch(){
        if(jmdns != null){
            Log.d(LCAT, "Stopping search!");
            lock.release();
            jmdns.removeServiceListener(_serviceType, listener);
            try{
                jmdns.close();
                jmdns = null;
            } catch(IOException e){
                Log.e(LCAT, "Error closing jmdns!");
            }
        } else {
            Log.d(LCAT, "Search not active!");
        }
    }
}

The module is used in the exact same way, but relies on jmdns instead.


httpServiceLocator = require('com.shockoe.zeroconf').createJmdnsHTTPServiceLocator({
    serviceType : '_http._tcp.',
    serviceName : 'ShockoeService'
});

// Handle our service being found
httpServiceLocator.addEventListener('servicefound', function(e){
    // Stop the search
    httpServiceLocator.stopSearch();

    // We have found our service, do something with it

    // Build url based off of service information
    var baseUrl = 'http://'+e.host+':'+e.port;
});

httpServiceLocator.search();

Jmdns is a little buggy and will not find the service every time. Use NsdManager when you can, but verify that it works for your usage.

These examples have only been for discovering the service and don’t even handle communicating over a socket directly through zeroconf like the BonjourBrowser does. I have also not included code for broadcasting a service on the mobile device itself. Hopefully this code points you in the right direction if those are things you need to do from an Android device with Appcelerator Titanium!

Now that I’ve had a chance to work with zeroconf I love what it brings to the table. It’s great to have devices just find each other on a network with no input from the user. Unfortunately the way Bonjour is presented makes it difficult to find information on how to implement zeroconf on devices that Apple does not directly support.