create BonjourManager to manage jmdns in HandlerThread, with tests

This commit is contained in:
Hans-Christoph Steiner 2019-05-17 22:17:16 +02:00
parent 79e7e78e7f
commit 46472ba7a4
8 changed files with 411 additions and 206 deletions

View File

@ -0,0 +1,125 @@
package org.fdroid.fdroid.localrepo;
import android.content.Context;
import android.support.test.InstrumentationRegistry;
import android.support.test.runner.AndroidJUnit4;
import org.fdroid.fdroid.FDroidApp;
import org.junit.Test;
import org.junit.runner.RunWith;
import javax.jmdns.ServiceEvent;
import javax.jmdns.ServiceListener;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import static org.junit.Assert.assertTrue;
@RunWith(AndroidJUnit4.class)
public class BonjourManagerTest {
private static final String NAME = "Robolectric-test";
private static final String LOCALHOST = "localhost";
private static final int PORT = 8888;
@Test
public void testStartStop() throws InterruptedException {
Context context = InstrumentationRegistry.getTargetContext();
FDroidApp.ipAddressString = LOCALHOST;
FDroidApp.port = PORT;
final CountDownLatch addedLatch = new CountDownLatch(1);
final CountDownLatch resolvedLatch = new CountDownLatch(1);
final CountDownLatch removedLatch = new CountDownLatch(1);
BonjourManager.start(context, NAME, false,
new ServiceListener() {
@Override
public void serviceAdded(ServiceEvent serviceEvent) {
System.out.println("Service added: " + serviceEvent.getInfo());
if (NAME.equals(serviceEvent.getName())) {
addedLatch.countDown();
}
}
@Override
public void serviceRemoved(ServiceEvent serviceEvent) {
System.out.println("Service removed: " + serviceEvent.getInfo());
removedLatch.countDown();
}
@Override
public void serviceResolved(ServiceEvent serviceEvent) {
System.out.println("Service resolved: " + serviceEvent.getInfo());
if (NAME.equals(serviceEvent.getName())) {
resolvedLatch.countDown();
}
}
}, getBlankServiceListener());
BonjourManager.setVisible(context, true);
assertTrue(addedLatch.await(30, TimeUnit.SECONDS));
assertTrue(resolvedLatch.await(30, TimeUnit.SECONDS));
BonjourManager.setVisible(context, false);
assertTrue(removedLatch.await(30, TimeUnit.SECONDS));
BonjourManager.stop(context);
}
@Test
public void testRestart() throws InterruptedException {
Context context = InstrumentationRegistry.getTargetContext();
FDroidApp.ipAddressString = LOCALHOST;
FDroidApp.port = PORT;
BonjourManager.start(context, NAME, false, getBlankServiceListener(), getBlankServiceListener());
final CountDownLatch addedLatch = new CountDownLatch(1);
final CountDownLatch resolvedLatch = new CountDownLatch(1);
final CountDownLatch removedLatch = new CountDownLatch(1);
BonjourManager.restart(context, NAME, false,
new ServiceListener() {
@Override
public void serviceAdded(ServiceEvent serviceEvent) {
System.out.println("Service added: " + serviceEvent.getInfo());
if (NAME.equals(serviceEvent.getName())) {
addedLatch.countDown();
}
}
@Override
public void serviceRemoved(ServiceEvent serviceEvent) {
System.out.println("Service removed: " + serviceEvent.getInfo());
removedLatch.countDown();
}
@Override
public void serviceResolved(ServiceEvent serviceEvent) {
System.out.println("Service resolved: " + serviceEvent.getInfo());
if (NAME.equals(serviceEvent.getName())) {
resolvedLatch.countDown();
}
}
}, getBlankServiceListener());
BonjourManager.setVisible(context, true);
assertTrue(addedLatch.await(30, TimeUnit.SECONDS));
assertTrue(resolvedLatch.await(30, TimeUnit.SECONDS));
BonjourManager.setVisible(context, false);
assertTrue(removedLatch.await(30, TimeUnit.SECONDS));
BonjourManager.stop(context);
}
private ServiceListener getBlankServiceListener() {
return new ServiceListener() {
@Override
public void serviceAdded(ServiceEvent serviceEvent) {
}
@Override
public void serviceRemoved(ServiceEvent serviceEvent) {
}
@Override
public void serviceResolved(ServiceEvent serviceEvent) {
}
};
}
}

View File

@ -0,0 +1,269 @@
package org.fdroid.fdroid.localrepo;
import android.content.Context;
import android.content.Intent;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.Message;
import android.os.Process;
import android.support.v4.content.LocalBroadcastManager;
import android.text.TextUtils;
import android.util.Log;
import org.fdroid.fdroid.FDroidApp;
import org.fdroid.fdroid.Preferences;
import org.fdroid.fdroid.Utils;
import javax.jmdns.JmDNS;
import javax.jmdns.ServiceEvent;
import javax.jmdns.ServiceInfo;
import javax.jmdns.ServiceListener;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.net.InetAddress;
import java.util.HashMap;
/**
* Manage {@link JmDNS} in a {@link HandlerThread}. The start process is in
* {@link HandlerThread#onLooperPrepared()} so that it is always started before
* any messages get delivered from the queue.
*/
public class BonjourManager {
private static final String TAG = "BonjourManager";
public static final String ACTION_ADDED = "BonjourAdded";
public static final String ACTION_RESOLVED = "BonjourResolved";
public static final String ACTION_REMOVED = "BonjourRemoved";
public static final String ACTION_STATUS = "BonjourStatus";
public static final String EXTRA_STATUS = "BonjourStatusExtra";
public static final int STATUS_STARTING = 0;
public static final int STATUS_STARTED = 1;
public static final int STATUS_STOPPING = 2;
public static final int STATUS_STOPPED = 3;
public static final int STATUS_VISIBLE = 4;
public static final int STATUS_NOT_VISIBLE = 5;
public static final int STATUS_ERROR = 0xffff;
public static final String HTTP_SERVICE_TYPE = "_http._tcp.local.";
public static final String HTTPS_SERVICE_TYPE = "_https._tcp.local.";
private static final int STOP = 5709;
private static final int VISIBLE = 4151873;
private static final int NOT_VISIBLE = 144151873;
private static WeakReference<Context> context;
private static Handler handler;
private static volatile HandlerThread handlerThread;
private static ServiceInfo pairService;
private static JmDNS jmdns;
public static boolean isAlive() {
return handlerThread != null && handlerThread.isAlive();
}
/**
* Stops the Bonjour/mDNS, triggering a status broadcast via {@link #ACTION_STATUS}.
* {@link #STATUS_STOPPED} can be broadcast multiple times for the same session,
* so make sure {@link android.content.BroadcastReceiver}s handle duplicates.
*/
public static void stop(Context context) {
BonjourManager.context = new WeakReference<>(context);
if (handler == null || handlerThread == null || !handlerThread.isAlive()) {
sendBroadcast(STATUS_STOPPED, null);
return;
}
sendBroadcast(STATUS_STOPPING, null);
handler.sendEmptyMessage(STOP);
}
public static void setVisible(Context context, boolean visible) {
BonjourManager.context = new WeakReference<>(context);
if (handler == null || handlerThread == null || !handlerThread.isAlive()) {
Log.e(TAG, "handlerThread is stopped, not changing visibility!");
return;
}
if (visible) {
handler.sendEmptyMessage(VISIBLE);
} else {
handler.sendEmptyMessage(NOT_VISIBLE);
}
}
/**
* Starts the service, triggering a status broadcast via {@link #ACTION_STATUS}.
* {@link #STATUS_STARTED} can be broadcast multiple times for the same session,
* so make sure {@link android.content.BroadcastReceiver}s handle duplicates.
*/
public static void start(Context context) {
start(context,
Preferences.get().getLocalRepoName(),
Preferences.get().isLocalRepoHttpsEnabled(),
httpServiceListener, httpsServiceListener);
}
/**
* Testable version, not for regular use.
*
* @see #start(Context)
*/
static void start(final Context context,
final String localRepoName, final boolean useHttps,
final ServiceListener httpServiceListener, final ServiceListener httpsServiceListener) {
BonjourManager.context = new WeakReference<>(context);
if (handlerThread != null && handlerThread.isAlive()) {
sendBroadcast(STATUS_STARTED, null);
return;
}
sendBroadcast(STATUS_STARTING, null);
handlerThread = new HandlerThread("BonjourManager", Process.THREAD_PRIORITY_LESS_FAVORABLE) {
@Override
protected void onLooperPrepared() {
try {
InetAddress address = InetAddress.getByName(FDroidApp.ipAddressString);
jmdns = JmDNS.create(address);
jmdns.addServiceListener(HTTP_SERVICE_TYPE, httpServiceListener);
jmdns.addServiceListener(HTTPS_SERVICE_TYPE, httpsServiceListener);
sendBroadcast(STATUS_STARTED, null);
} catch (IOException e) {
if (handler != null) {
handler.removeMessages(VISIBLE);
handler.sendMessageAtFrontOfQueue(handler.obtainMessage(STOP));
}
Log.e(TAG, "Error while registering jmdns service", e);
sendBroadcast(STATUS_ERROR, e.getLocalizedMessage());
}
}
};
handlerThread.start();
handler = new Handler(handlerThread.getLooper()) {
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case VISIBLE:
handleVisible(localRepoName, useHttps);
break;
case NOT_VISIBLE:
handleNotVisible();
break;
case STOP:
handleStop();
break;
}
}
private void handleVisible(String localRepoName, boolean useHttps) {
HashMap<String, String> values = new HashMap<>();
values.put("path", "/fdroid/repo");
values.put("name", localRepoName);
values.put("fingerprint", FDroidApp.repo.fingerprint);
String type;
if (useHttps) {
values.put("type", "fdroidrepos");
type = HTTPS_SERVICE_TYPE;
} else {
values.put("type", "fdroidrepo");
type = HTTP_SERVICE_TYPE;
}
ServiceInfo newPairService = ServiceInfo.create(type, localRepoName, FDroidApp.port, 0, 0, values);
if (!newPairService.equals(pairService)) try {
if (pairService != null) {
jmdns.unregisterService(pairService);
}
jmdns.registerService(newPairService);
pairService = newPairService;
} catch (IOException e) {
e.printStackTrace();
sendBroadcast(STATUS_ERROR, e.getLocalizedMessage());
return;
}
sendBroadcast(STATUS_VISIBLE, null);
}
private void handleNotVisible() {
if (pairService != null) {
jmdns.unregisterService(pairService);
pairService = null;
}
sendBroadcast(STATUS_NOT_VISIBLE, null);
}
private void handleStop() {
if (jmdns != null) {
jmdns.unregisterAllServices();
Utils.closeQuietly(jmdns);
pairService = null;
jmdns = null;
}
handlerThread.quit();
handlerThread = null;
sendBroadcast(STATUS_STOPPED, null);
}
};
}
public static void restart(Context context) {
restart(context,
Preferences.get().getLocalRepoName(),
Preferences.get().isLocalRepoHttpsEnabled(),
httpServiceListener, httpsServiceListener);
}
/**
* Testable version, not for regular use.
*
* @see #restart(Context)
*/
static void restart(final Context context,
final String localRepoName, final boolean useHttps,
final ServiceListener httpServiceListener, final ServiceListener httpsServiceListener) {
stop(context);
try {
handlerThread.join(10000);
} catch (InterruptedException | NullPointerException e) {
// ignored
}
start(context, localRepoName, useHttps, httpServiceListener, httpsServiceListener);
}
private static void sendBroadcast(String action, String message) {
Intent intent = new Intent(action);
intent.putExtra(Intent.EXTRA_TEXT, message);
LocalBroadcastManager.getInstance(context.get()).sendBroadcast(intent);
}
private static void sendBroadcast(int status, String message) {
Intent intent = new Intent(ACTION_STATUS);
intent.putExtra(EXTRA_STATUS, status);
if (!TextUtils.isEmpty(message)) {
intent.putExtra(Intent.EXTRA_TEXT, message);
}
LocalBroadcastManager.getInstance(context.get()).sendBroadcast(intent);
}
private static final ServiceListener httpServiceListener = new SwapServiceListener();
private static final ServiceListener httpsServiceListener = new SwapServiceListener();
private static class SwapServiceListener implements ServiceListener {
@Override
public void serviceAdded(ServiceEvent serviceEvent) {
Utils.debugLog(TAG, "Service added: " + serviceEvent.getInfo());
sendBroadcast(ACTION_ADDED, serviceEvent.getInfo().toString());
}
@Override
public void serviceRemoved(ServiceEvent serviceEvent) {
Utils.debugLog(TAG, "Service removed: " + serviceEvent.getInfo());
sendBroadcast(ACTION_REMOVED, serviceEvent.getInfo().toString());
}
@Override
public void serviceResolved(ServiceEvent serviceEvent) {
Utils.debugLog(TAG, "Service resolved: " + serviceEvent.getInfo());
sendBroadcast(ACTION_RESOLVED, serviceEvent.getInfo().toString());
}
}
}

View File

@ -369,10 +369,6 @@ public class SwapService extends Service {
return bluetoothSwap.isDiscoverable();
}
public boolean isBonjourDiscoverable() {
return wifiSwap.isConnected() && wifiSwap.getBonjour().isConnected();
}
// ===============================================================
// Old SwapService stuff being merged into that.
// ===============================================================

View File

@ -16,6 +16,9 @@ import javax.jmdns.ServiceListener;
import java.io.IOException;
import java.net.InetAddress;
import static org.fdroid.fdroid.localrepo.BonjourManager.HTTPS_SERVICE_TYPE;
import static org.fdroid.fdroid.localrepo.BonjourManager.HTTP_SERVICE_TYPE;
@SuppressWarnings("LineLength")
final class BonjourFinder extends PeerFinder implements ServiceListener {
@ -39,9 +42,6 @@ final class BonjourFinder extends PeerFinder implements ServiceListener {
private static final String TAG = "BonjourFinder";
private static final String HTTP_SERVICE_TYPE = "_http._tcp.local.";
private static final String HTTPS_SERVICE_TYPE = "_https._tcp.local.";
private JmDNS jmdns;
private WifiManager wifiManager;
private WifiManager.MulticastLock multicastLock;

View File

@ -1,112 +0,0 @@
package org.fdroid.fdroid.localrepo.type;
import android.content.Context;
import android.support.annotation.Nullable;
import android.util.Log;
import org.fdroid.fdroid.FDroidApp;
import org.fdroid.fdroid.Preferences;
import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.localrepo.SwapService;
import javax.jmdns.JmDNS;
import javax.jmdns.ServiceInfo;
import java.io.IOException;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.HashMap;
/**
* Sends a {@link SwapService#BONJOUR_STATE_CHANGE} broadcasts when starting, started or stopped.
*/
public class BonjourBroadcast extends SwapType {
private static final String TAG = "BonjourBroadcast";
private JmDNS jmdns;
private ServiceInfo pairService;
public BonjourBroadcast(Context context) {
super(context);
}
@Override
public void start() {
Utils.debugLog(TAG, "Preparing to start Bonjour service.");
sendBroadcast(SwapService.EXTRA_STARTING);
InetAddress address = getDeviceAddress();
if (address == null) {
Log.e(TAG, "Starting Bonjour service, but couldn't ascertain IP address."
+ " Seems we are not connected to a network.");
return;
}
/*
* a ServiceInfo can only be registered with a single instance
* of JmDNS, and there is only ever a single LocalHTTPD port to
* advertise anyway.
*/
if (pairService != null || jmdns != null) {
clearCurrentMDNSService();
}
String repoName = Preferences.get().getLocalRepoName();
HashMap<String, String> values = new HashMap<>();
values.put("path", "/fdroid/repo");
values.put("name", repoName);
values.put("fingerprint", FDroidApp.repo.fingerprint);
String type;
if (Preferences.get().isLocalRepoHttpsEnabled()) {
values.put("type", "fdroidrepos");
type = "_https._tcp.local.";
} else {
values.put("type", "fdroidrepo");
type = "_http._tcp.local.";
}
try {
Utils.debugLog(TAG, "Starting bonjour service...");
pairService = ServiceInfo.create(type, repoName, FDroidApp.port, 0, 0, values);
jmdns = JmDNS.create(address);
jmdns.registerService(pairService);
setConnected(true);
Utils.debugLog(TAG, "... Bounjour service started.");
} catch (IOException e) {
Log.e(TAG, "Error while registering jmdns service", e);
setConnected(false);
}
}
@Override
public void stop() {
Utils.debugLog(TAG, "Unregistering MDNS service...");
clearCurrentMDNSService();
setConnected(false);
}
private void clearCurrentMDNSService() {
if (jmdns != null) {
jmdns.unregisterAllServices();
Utils.closeQuietly(jmdns);
pairService = null;
jmdns = null;
}
}
@Override
public String getBroadcastAction() {
return SwapService.BONJOUR_STATE_CHANGE;
}
@Nullable
private InetAddress getDeviceAddress() {
if (FDroidApp.ipAddressString != null) {
try {
return InetAddress.getByName(FDroidApp.ipAddressString);
} catch (UnknownHostException ignored) {
}
}
return null;
}
}

View File

@ -2,29 +2,20 @@ package org.fdroid.fdroid.localrepo.type;
import android.content.Context;
import android.net.wifi.WifiManager;
import android.util.Log;
import org.fdroid.fdroid.FDroidApp;
import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.localrepo.BonjourManager;
import org.fdroid.fdroid.localrepo.LocalHTTPDManager;
import org.fdroid.fdroid.localrepo.SwapService;
import rx.Single;
import rx.SingleSubscriber;
import rx.android.schedulers.AndroidSchedulers;
import rx.functions.Action1;
import rx.functions.Func2;
import rx.schedulers.Schedulers;
@SuppressWarnings("LineLength")
public class WifiSwap extends SwapType {
private static final String TAG = "WifiSwap";
private final BonjourBroadcast bonjourBroadcast;
private final WifiManager wifiManager;
public WifiSwap(Context context, WifiManager wifiManager) {
super(context);
bonjourBroadcast = new BonjourBroadcast(context);
this.wifiManager = wifiManager;
}
@ -32,89 +23,26 @@ public class WifiSwap extends SwapType {
return SwapService.WIFI_STATE_CHANGE;
}
public BonjourBroadcast getBonjour() {
return bonjourBroadcast;
}
@Override
public void start() {
sendBroadcast(SwapService.EXTRA_STARTING);
wifiManager.setWifiEnabled(true);
LocalHTTPDManager.start(context);
BonjourManager.start(context);
BonjourManager.setVisible(context, SwapService.getWifiVisibleUserPreference());
if (FDroidApp.ipAddressString == null) {
Log.e(TAG, "Not starting swap webserver, because we don't seem to be connected to a network.");
setConnected(false);
} else {
setConnected(true);
}
Single.zip(
Single.create(getWebServerTask()),
Single.create(getBonjourTask()),
new Func2<Boolean, Boolean, Boolean>() {
@Override
public Boolean call(Boolean webServerTask, Boolean bonjourServiceTask) {
return bonjourServiceTask && webServerTask;
}
})
.observeOn(AndroidSchedulers.mainThread())
.subscribeOn(Schedulers.newThread())
.subscribe(new Action1<Boolean>() {
@Override
public void call(Boolean success) {
setConnected(success);
}
},
new Action1<Throwable>() {
@Override
public void call(Throwable throwable) {
setConnected(false);
}
});
}
/**
* A task which starts the {@link WifiSwap#bonjourBroadcast} and then emits a `true` value at
* the end.
*/
private Single.OnSubscribe<Boolean> getBonjourTask() {
return new Single.OnSubscribe<Boolean>() {
@Override
public void call(SingleSubscriber<? super Boolean> singleSubscriber) {
bonjourBroadcast.start();
// TODO: Be more intelligent about failures here so that we can invoke
// singleSubscriber.onError() in the appropriate circumstances.
singleSubscriber.onSuccess(true);
}
};
}
/**
* Constructs a new {@link Thread} for the webserver to run on. If successful, it will also
* populate the webServerThreadHandler property and bind it to that particular thread. This
* allows messages to be sent to the webserver thread by posting messages to that handler.
*/
private Single.OnSubscribe<Boolean> getWebServerTask() {
return new Single.OnSubscribe<Boolean>() {
@Override
public void call(SingleSubscriber<? super Boolean> singleSubscriber) {
singleSubscriber.onSuccess(true);
}
};
}
@Override
public void stop() {
sendBroadcast(SwapService.EXTRA_STOPPING); // This needs to be per-SwapType
Utils.debugLog(TAG, "Sending message to swap webserver to stop it.");
LocalHTTPDManager.stop(context);
// Stop the Bonjour stuff after asking the webserver to stop. This is not required in this
// order, but it helps. In practice, the Bonjour stuff takes a second or two to stop. This
// should give enough time for the message we posted above to reach the web server thread
// and for the webserver to thus be stopped.
bonjourBroadcast.stop();
BonjourManager.stop(context);
setConnected(false);
}

View File

@ -26,6 +26,7 @@ import cc.mvdan.accesspoint.WifiApControl;
import org.fdroid.fdroid.FDroidApp;
import org.fdroid.fdroid.R;
import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.localrepo.BonjourManager;
import org.fdroid.fdroid.localrepo.SwapService;
import org.fdroid.fdroid.localrepo.SwapView;
import org.fdroid.fdroid.localrepo.peers.Peer;
@ -318,15 +319,15 @@ public class StartSwapView extends SwapView {
viewWifiNetwork = (TextView) findViewById(R.id.wifi_network);
wifiSwitch = (SwitchCompat) findViewById(R.id.switch_wifi);
wifiSwitch.setOnCheckedChangeListener(onWifiSwitchToggled);
setWifiSwitchState(getActivity().getSwapService().isBonjourDiscoverable(), true);
wifiSwitch.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
BonjourManager.setVisible(getContext(), isChecked);
SwapService.putWifiVisibleUserPreference(isChecked);
}
});
textWifiVisible = findViewById(R.id.wifi_visible);
if (getActivity().getSwapService().isBonjourDiscoverable()) {
textWifiVisible.setText(R.string.swap_visible_wifi);
} else {
textWifiVisible.setText(R.string.swap_not_visible_wifi);
}
// Note that this is only listening for the WifiSwap, whereas we start both the WifiSwap
// and the Bonjour service at the same time. Technically swap will work fine without

View File

@ -816,8 +816,6 @@ public class SwapWorkflowActivity extends AppCompatActivity {
} else {
String bluetooth = service.getBluetoothSwap().isConnected() ? "Y" : " N";
String wifi = service.getWifiSwap().isConnected() ? "Y" : " N";
String mdns = service.getWifiSwap().getBonjour().isConnected() ? "Y" : " N";
message += "Swap { BT: " + bluetooth + ", WiFi: " + wifi + ", mDNS: " + mdns + "}, ";
BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
bluetooth = "N/A";