/* The MIT License (MIT) * Copyright (c) 2015 YouView Ltd * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in * the Software without restriction, including without limitation the rights to * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of * the Software, and to permit persons to whom the Software is furnished to do so, * subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package com.ruesga.android.wallpapers.photophase.cast.mdsn; import android.annotation.TargetApi; import android.content.Context; import android.net.nsd.NsdManager; import android.net.nsd.NsdServiceInfo; import android.os.AsyncTask; import android.os.Build; import android.os.Handler; import android.os.Looper; import android.util.Log; import java.io.IOException; import java.util.HashMap; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.Map; /** * <p>Uses Android's {@link NsdManager} to perform mDNS Service Discovery. Additionally makes use of * {@link MDNSDiscover} to query the TXT record of discovered services (something which is missing * from {@link NsdManager} due to a <a href="https://code.google.com/p/android/issues/detail?id=136099">bug</a>)</p> * * <p>This class presents a simplified client API compared with accessing {@link NsdManager} * directly.</p> * * <p>Another feature is <em>service visibility debouncing</em>: sometimes * {@link NsdManager.DiscoveryListener#onServiceLost(NsdServiceInfo)} occurs * then shortly afterwards the same service is reported again in * {@link NsdManager.DiscoveryListener#onServiceFound(NsdServiceInfo)}. Use the * {@code debounceMillis} value in the constructor to configure a tolerance to this - removed * services are not notified to the listener until this time elapses without the service * reappearing.</p> */ @TargetApi(value= Build.VERSION_CODES.JELLY_BEAN) public class DiscoverResolver { private static final String TAG = DiscoverResolver.class.getSimpleName(); private static final int RESOLVE_TIMEOUT = 1000; public interface Listener { void onServicesChanged(Map<String, MDNSDiscover.Result> services); } private final MapDebouncer<String, Object> mDebouncer; private final Context mContext; private final String mServiceType; private final HashMap<String, MDNSDiscover.Result> mServices = new HashMap<>(); private final Handler mHandler = new Handler(Looper.getMainLooper()); private final Listener mListener; private boolean mStarted; private boolean mTransitioning; private ResolveTask mResolveTask; private final Map<String, NsdServiceInfo> mResolveQueue = new LinkedHashMap<>(); /** * Equivalent to {@link #DiscoverResolver(Context, String, Listener, int)} with a * {@code debounceMillis} of 0. */ public DiscoverResolver(Context context, String serviceType, Listener listener) { this(context, serviceType, listener, 0); } /** * @param context the Context to run in * @param serviceType mDNS service type such as {@code "_example._tcp"} * @param listener to receive updates to visible services * @param debounceMillis time to delay service signalling of services that may quickly disappear * then reappear. See {@link DiscoverResolver} for details. */ public DiscoverResolver(Context context, String serviceType, Listener listener, int debounceMillis) { if (context == null) throw new NullPointerException("context was null"); if (serviceType == null) throw new NullPointerException("serviceType was null"); if (listener == null) throw new NullPointerException("listener was null"); mContext = context; mServiceType = serviceType; mListener = listener; mDebouncer = new MapDebouncer<>(debounceMillis, new MapDebouncer.Listener<String, Object>() { @Override public void put(String name, Object o) { if (o != null) { Log.d(TAG, "add: " + name); synchronized (mResolveQueue) { mResolveQueue.put(name, null); } startResolveTaskIfNeeded(); } else { Log.d(TAG, "remove: " + name); synchronized (DiscoverResolver.this) { synchronized (mResolveQueue) { mResolveQueue.remove(name); } if (mStarted) { if (mServices.remove(name) != null) { dispatchServicesChanged(); } } } } } }); } public synchronized void start() { if (mStarted) { throw new IllegalStateException(); } if (!mTransitioning) { discoverServices(mServiceType, NsdManager.PROTOCOL_DNS_SD, mDiscoveryListener); mTransitioning = true; } mStarted = true; } public synchronized void stop() { if (!mStarted) { throw new IllegalStateException(); } if (!mTransitioning) { stopServiceDiscovery(mDiscoveryListener); mTransitioning = true; } synchronized (mResolveQueue) { mResolveQueue.clear(); } mDebouncer.clear(); mServices.clear(); mServicesChanged = false; mStarted = false; } private NsdManager.DiscoveryListener mDiscoveryListener = new NsdManager.DiscoveryListener() { @Override public void onStartDiscoveryFailed(String serviceType, int errorCode) { Log.d(TAG, "onStartDiscoveryFailed() serviceType = [" + serviceType + "], errorCode = [" + errorCode + "]"); } @Override public void onStopDiscoveryFailed(String serviceType, int errorCode) { Log.d(TAG, "onStopDiscoveryFailed() serviceType = [" + serviceType + "], errorCode = [" + errorCode + "]"); } @Override public void onDiscoveryStarted(String serviceType) { Log.d(TAG, "onDiscoveryStarted() serviceType = [" + serviceType + "]"); synchronized (DiscoverResolver.this) { if (!mStarted) { stopServiceDiscovery(this); } else { mTransitioning = false; } } } @Override public void onDiscoveryStopped(String serviceType) { Log.d(TAG, "onDiscoveryStopped() serviceType = [" + serviceType + "]"); if (mStarted) { discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, this); } else { mTransitioning = false; } } @Override public void onServiceFound(final NsdServiceInfo serviceInfo) { Log.d(TAG, "onServiceFound() serviceInfo = [" + serviceInfo + "]"); synchronized (DiscoverResolver.this) { if (mStarted) { String name = serviceInfo.getServiceName() + "." + serviceInfo.getServiceType() + "local"; mDebouncer.put(name, DUMMY); } } } @Override public void onServiceLost(final NsdServiceInfo serviceInfo) { Log.d(TAG, "onServiceLost() serviceInfo = [" + serviceInfo + "]"); synchronized (DiscoverResolver.this) { if (mStarted) { String name = serviceInfo.getServiceName() + "." + serviceInfo.getServiceType() + "local"; mDebouncer.put(name, null); } } } }; /** * A non-null value that indicates membership in the MapDebouncer, null indicates non-membership */ private Object DUMMY = new Object(); private boolean mServicesChanged; private void dispatchServicesChanged() { if (!mStarted) { throw new IllegalStateException(); } // Multiple calls to this method are possible before mServicesChangedRunnable executes. // We don't post the runnable every time this method is called, instead we set a flag and // post only if the flag was previously unset. The runnable clears the flag. // In this way, the main thread can coalesce several updates into a single call to // onServicesChanged(). if (!mServicesChanged) { mServicesChanged = true; mHandler.post(mServicesChangedRunnable); } } private Runnable mServicesChangedRunnable = new Runnable() { @Override public void run() { synchronized (DiscoverResolver.this) { if (mStarted && mServicesChanged) { @SuppressWarnings("unchecked") Map<String, MDNSDiscover.Result> services = (Map) mServices.clone(); mListener.onServicesChanged(services); } mServicesChanged = false; } } }; private class ResolveTask extends AsyncTask<Void, Void, Void> { @Override protected Void doInBackground(Void... params) { while (!isCancelled()) { String serviceName; synchronized (mResolveQueue) { Iterator<String> it = mResolveQueue.keySet().iterator(); if (!it.hasNext()) { break; } serviceName = it.next(); it.remove(); } try { MDNSDiscover.Result result = resolve(serviceName, RESOLVE_TIMEOUT); synchronized (DiscoverResolver.this) { if (mStarted) { mServices.put(serviceName, result); dispatchServicesChanged(); } } } catch(IOException e) { e.printStackTrace(); } } return null; } @Override protected void onPostExecute(Void aVoid) { mResolveTask = null; startResolveTaskIfNeeded(); } } private void startResolveTaskIfNeeded() { if (mResolveTask == null) { synchronized (mResolveQueue) { if (!mResolveQueue.isEmpty()) { mResolveTask = new ResolveTask(); mResolveTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); } } } } // default implementation is to delegate to NsdManager // tests can stub this to mock the NsdManager protected void discoverServices(String serviceType, int protocol, NsdManager.DiscoveryListener listener) { ((NsdManager) mContext.getSystemService(Context.NSD_SERVICE)).discoverServices(serviceType, protocol, listener); } // default implementation is to delegate to NsdManager // tests can stub this to mock the NsdManager protected void stopServiceDiscovery(NsdManager.DiscoveryListener listener) { ((NsdManager) mContext.getSystemService(Context.NSD_SERVICE)).stopServiceDiscovery(listener); } // default implementation is to delegate to MDNSDiscover // tests can stub this to mock it protected MDNSDiscover.Result resolve(String serviceName, int resolveTimeout) throws IOException { return MDNSDiscover.resolve(serviceName, resolveTimeout); } }