From 79e7e78e7f69fc7dbe47684ee102c930e2c3a13f Mon Sep 17 00:00:00 2001 From: Hans-Christoph Steiner Date: Fri, 17 May 2019 14:36:25 +0200 Subject: [PATCH] create testable LocalHTTPDManager for controlling the webserver The RxJava tricks were a nightmare... --- .../java/org/fdroid/fdroid/Netstat.java | 372 ++++++++++++++++++ .../localrepo/LocalHTTPDManagerTest.java | 187 +++++++++ app/src/full/AndroidManifest.xml | 1 + .../fdroid/localrepo/LocalHTTPDManager.java | 129 ++++++ .../fdroid/fdroid/localrepo/SwapService.java | 25 +- .../fdroid/localrepo/type/WifiSwap.java | 76 +--- .../org/fdroid/fdroid/net/LocalHTTPD.java | 9 +- .../main/java/org/fdroid/fdroid/Utils.java | 25 ++ .../localrepo/LocalHTTPDManagerTest.java | 72 ++++ 9 files changed, 810 insertions(+), 86 deletions(-) create mode 100644 app/src/androidTest/java/org/fdroid/fdroid/Netstat.java create mode 100644 app/src/androidTest/java/org/fdroid/fdroid/localrepo/LocalHTTPDManagerTest.java create mode 100644 app/src/full/java/org/fdroid/fdroid/localrepo/LocalHTTPDManager.java create mode 100644 app/src/testFull/java/org/fdroid/fdroid/localrepo/LocalHTTPDManagerTest.java diff --git a/app/src/androidTest/java/org/fdroid/fdroid/Netstat.java b/app/src/androidTest/java/org/fdroid/fdroid/Netstat.java new file mode 100644 index 000000000..7354e6843 --- /dev/null +++ b/app/src/androidTest/java/org/fdroid/fdroid/Netstat.java @@ -0,0 +1,372 @@ +package org.fdroid.fdroid; + +import java.io.BufferedReader; +import java.io.FileReader; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Replacer for the netstat utility, by reading the /proc filesystem it can find out the + * open connections of the system + * From http://www.ussg.iu.edu/hypermail/linux/kernel/0409.1/2166.html : + * It will first list all listening TCP sockets, and next list all established + * TCP connections. A typical entry of /proc/net/tcp would look like this (split + * up into 3 parts because of the length of the line): + *

+ * 46: 010310AC:9C4C 030310AC:1770 01 + * | | | | | |--> connection state + * | | | | |------> remote TCP port number + * | | | |-------------> remote IPv4 address + * | | |--------------------> local TCP port number + * | |---------------------------> local IPv4 address + * |----------------------------------> number of entry + *

+ * 00000150:00000000 01:00000019 00000000 + * | | | | |--> number of unrecovered RTO timeouts + * | | | |----------> number of jiffies until timer expires + * | | |----------------> timer_active (see below) + * | |----------------------> receive-queue + * |-------------------------------> transmit-queue + *

+ * 1000 0 54165785 4 cd1e6040 25 4 27 3 -1 + * | | | | | | | | | |--> slow start size threshold, + * | | | | | | | | | or -1 if the treshold + * | | | | | | | | | is >= 0xFFFF + * | | | | | | | | |----> sending congestion window + * | | | | | | | |-------> (ack.quick<<1)|ack.pingpong + * | | | | | | |---------> Predicted tick of soft clock + * | | | | | | (delayed ACK control data) + * | | | | | |------------> retransmit timeout + * | | | | |------------------> location of socket in memory + * | | | |-----------------------> socket reference count + * | | |-----------------------------> inode + * | |----------------------------------> unanswered 0-window probes + * |---------------------------------------------> uid + * + * @author Ciprian Dobre + */ +public class Netstat { + + /** + * Possible values for states in /proc/net/tcp + */ + private static final String[] STATES = { + "ESTBLSH", "SYNSENT", "SYNRECV", "FWAIT1", "FWAIT2", "TMEWAIT", + "CLOSED", "CLSWAIT", "LASTACK", "LISTEN", "CLOSING", "UNKNOWN", + }; + /** + * Pattern used when parsing through /proc/net/tcp + */ + private static final Pattern NET_PATTERN = Pattern.compile( + "\\d+:\\s+([\\dA-F]+):([\\dA-F]+)\\s+([\\dA-F]+):([\\dA-F]+)\\s+([\\dA-F]+)\\s+" + + "[\\dA-F]+:[\\dA-F]+\\s+[\\dA-F]+:[\\dA-F]+\\s+[\\dA-F]+\\s+([\\d]+)\\s+[\\d]+\\s+([\\d]+)"); + + /** + * Utility method that converts an address from a hex representation as founded in /proc to String representation + */ + private static String getAddress(final String hexa) { + try { + // first let's convert the address to Integer + final long v = Long.parseLong(hexa, 16); + // in /proc the order is little endian and java uses big endian order we also need to invert the order + final long adr = (v >>> 24) | (v << 24) | + ((v << 8) & 0x00FF0000) | ((v >> 8) & 0x0000FF00); + // and now it's time to output the result + return ((adr >> 24) & 0xff) + "." + ((adr >> 16) & 0xff) + "." + ((adr >> 8) & 0xff) + "." + (adr & 0xff); + } catch (Exception ex) { + ex.printStackTrace(); + return "0.0.0.0"; // NOPMD + } + } + + private static int getInt16(final String hexa) { + try { + return Integer.parseInt(hexa, 16); + } catch (Exception ex) { + ex.printStackTrace(); + return -1; + } + } + + /* + private static String getPName(final int pid) { + final Pattern pattern = Pattern.compile("Name:\\s*(\\S+)"); + try { + BufferedReader in = new BufferedReader(new FileReader("/proc/" + pid + "/status")); + String line; + while ((line = in.readLine()) != null) { + final Matcher matcher = pattern.matcher(line); + if (matcher.find()) { + return matcher.group(1); + } + } + in.close(); + } catch (Throwable t) { + // ignored + } + return "UNKNOWN"; + } + */ + + /** + * Method used to question for the connections currently openned + * + * @return The list of connections (as Connection objects) + */ + public static List getConnections() { + + final ArrayList net = new ArrayList<>(); + + // read from /proc/net/tcp the list of currently openned socket connections + try { + BufferedReader in = new BufferedReader(new FileReader("/proc/net/tcp")); + String line; + while ((line = in.readLine()) != null) { + Matcher matcher = NET_PATTERN.matcher(line); + if (matcher.find()) { + final Connection c = new Connection(); + c.setProtocol(Connection.TCP_CONNECTION); + net.add(c); + final String localPortHexa = matcher.group(2); + final String remoteAddressHexa = matcher.group(3); + final String remotePortHexa = matcher.group(4); + final String statusHexa = matcher.group(5); + //final String uid = matcher.group(6); + //final String inode = matcher.group(7); + c.setLocalPort(getInt16(localPortHexa)); + c.setRemoteAddress(getAddress(remoteAddressHexa)); + c.setRemotePort(getInt16(remotePortHexa)); + try { + c.setStatus(STATES[Integer.parseInt(statusHexa, 16) - 1]); + } catch (Exception ex) { + c.setStatus(STATES[11]); // unknwon + } + c.setPID(-1); // unknown + c.setPName("UNKNOWN"); + } + } + in.close(); + } catch (Throwable t) { // NOPMD + // ignored + } + + // read from /proc/net/udp the list of currently openned socket connections + try { + BufferedReader in = new BufferedReader(new FileReader("/proc/net/udp")); + String line; + while ((line = in.readLine()) != null) { + Matcher matcher = NET_PATTERN.matcher(line); + if (matcher.find()) { + final Connection c = new Connection(); + c.setProtocol(Connection.UDP_CONNECTION); + net.add(c); + final String localPortHexa = matcher.group(2); + final String remoteAddressHexa = matcher.group(3); + final String remotePortHexa = matcher.group(4); + final String statusHexa = matcher.group(5); + //final String uid = matcher.group(6); + //final String inode = matcher.group(7); + c.setLocalPort(getInt16(localPortHexa)); + c.setRemoteAddress(getAddress(remoteAddressHexa)); + c.setRemotePort(getInt16(remotePortHexa)); + try { + c.setStatus(STATES[Integer.parseInt(statusHexa, 16) - 1]); + } catch (Exception ex) { + c.setStatus(STATES[11]); // unknwon + } + c.setPID(-1); // unknown + c.setPName("UNKNOWN"); + } + } + in.close(); + } catch (Throwable t) { // NOPMD + // ignored + } + + // read from /proc/net/raw the list of currently openned socket connections + try { + BufferedReader in = new BufferedReader(new FileReader("/proc/net/raw")); + String line; + while ((line = in.readLine()) != null) { + Matcher matcher = NET_PATTERN.matcher(line); + if (matcher.find()) { + final Connection c = new Connection(); + c.setProtocol(Connection.RAW_CONNECTION); + net.add(c); + //final String localAddressHexa = matcher.group(1); + final String localPortHexa = matcher.group(2); + final String remoteAddressHexa = matcher.group(3); + final String remotePortHexa = matcher.group(4); + final String statusHexa = matcher.group(5); + //final String uid = matcher.group(6); + //final String inode = matcher.group(7); + c.setLocalPort(getInt16(localPortHexa)); + c.setRemoteAddress(getAddress(remoteAddressHexa)); + c.setRemotePort(getInt16(remotePortHexa)); + try { + c.setStatus(STATES[Integer.parseInt(statusHexa, 16) - 1]); + } catch (Exception ex) { + c.setStatus(STATES[11]); // unknwon + } + c.setPID(-1); // unknown + c.setPName("UNKNOWN"); + } + } + in.close(); + } catch (Throwable t) { // NOPMD + // ignored + } + return net; + } + + /** + * Informations about a given connection + * + * @author Ciprian Dobre + */ + public static class Connection { + + /** + * Types of connection protocol + ***/ + public static final byte TCP_CONNECTION = 0; + public static final byte UDP_CONNECTION = 1; + public static final byte RAW_CONNECTION = 2; + /** + * serialVersionUID + */ + private static final long serialVersionUID = 1988671591829311032L; + /** + * The protocol of the connection (can be tcp, udp or raw) + */ + protected byte protocol; + + /** + * The owner of the connection (username) + */ + protected String powner; + + /** + * The pid of the owner process + */ + protected int pid; + + /** + * The name of the program owning the connection + */ + protected String pname; + + /** + * Local port + */ + protected int localPort; + + /** + * Remote address of the connection + */ + protected String remoteAddress; + + /** + * Remote port + */ + protected int remotePort; + + /** + * Status of the connection + */ + protected String status; + + public final byte getProtocol() { + return protocol; + } + + public final void setProtocol(final byte protocol) { + this.protocol = protocol; + } + + public final String getProtocolAsString() { + switch (protocol) { + case TCP_CONNECTION: + return "TCP"; + case UDP_CONNECTION: + return "UDP"; + case RAW_CONNECTION: + return "RAW"; + } + return "UNKNOWN"; + } + + public final String getPOwner() { + return powner; + } + + public final void setPOwner(final String owner) { + this.powner = owner; + } + + public final int getPID() { + return pid; + } + + public final void setPID(final int pid) { + this.pid = pid; + } + + public final String getPName() { + return pname; + } + + public final void setPName(final String pname) { + this.pname = pname; + } + + public final int getLocalPort() { + return localPort; + } + + public final void setLocalPort(final int localPort) { + this.localPort = localPort; + } + + public final String getRemoteAddress() { + return remoteAddress; + } + + public final void setRemoteAddress(final String remoteAddress) { + this.remoteAddress = remoteAddress; + } + + public final int getRemotePort() { + return remotePort; + } + + public final void setRemotePort(final int remotePort) { + this.remotePort = remotePort; + } + + public final String getStatus() { + return status; + } + + public final void setStatus(final String status) { + this.status = status; + } + + public String toString() { + StringBuffer buf = new StringBuffer(); + buf.append("[Prot=").append(getProtocolAsString()); + buf.append(",POwner=").append(powner); + buf.append(",PID=").append(pid); + buf.append(",PName=").append(pname); + buf.append(",LPort=").append(localPort); + buf.append(",RAddress=").append(remoteAddress); + buf.append(",RPort=").append(remotePort); + buf.append(",Status=").append(status); + buf.append("]"); + return buf.toString(); + } + + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/org/fdroid/fdroid/localrepo/LocalHTTPDManagerTest.java b/app/src/androidTest/java/org/fdroid/fdroid/localrepo/LocalHTTPDManagerTest.java new file mode 100644 index 000000000..8933ba73a --- /dev/null +++ b/app/src/androidTest/java/org/fdroid/fdroid/localrepo/LocalHTTPDManagerTest.java @@ -0,0 +1,187 @@ +package org.fdroid.fdroid.localrepo; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.support.test.InstrumentationRegistry; +import android.support.test.runner.AndroidJUnit4; +import android.support.v4.content.LocalBroadcastManager; +import android.util.Log; +import org.fdroid.fdroid.FDroidApp; +import org.fdroid.fdroid.Netstat; +import org.fdroid.fdroid.Utils; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.IOException; +import java.net.ServerSocket; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +@RunWith(AndroidJUnit4.class) +public class LocalHTTPDManagerTest { + private static final String TAG = "LocalHTTPDManagerTest"; + + private Context context; + private LocalBroadcastManager lbm; + + private static final String LOCALHOST = "localhost"; + private static final int PORT = 8888; + + @Before + public void setUp() { + context = InstrumentationRegistry.getTargetContext(); + lbm = LocalBroadcastManager.getInstance(context); + + FDroidApp.ipAddressString = LOCALHOST; + FDroidApp.port = PORT; + + for (Netstat.Connection connection : Netstat.getConnections()) { // NOPMD + Log.i("LocalHTTPDManagerTest", "connection: " + connection.toString()); + } + assertFalse(Utils.isServerSocketInUse(PORT)); + LocalHTTPDManager.stop(context); + + for (Netstat.Connection connection : Netstat.getConnections()) { // NOPMD + Log.i("LocalHTTPDManagerTest", "connection: " + connection.toString()); + } + } + + @After + public void tearDown() { + lbm.unregisterReceiver(startedReceiver); + lbm.unregisterReceiver(stoppedReceiver); + lbm.unregisterReceiver(errorReceiver); + } + + @Test + public void testStartStop() throws InterruptedException { + Log.i(TAG, "testStartStop"); + + final CountDownLatch startLatch = new CountDownLatch(1); + BroadcastReceiver latchReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + startLatch.countDown(); + } + }; + lbm.registerReceiver(latchReceiver, new IntentFilter(LocalHTTPDManager.ACTION_STARTED)); + lbm.registerReceiver(stoppedReceiver, new IntentFilter(LocalHTTPDManager.ACTION_STOPPED)); + lbm.registerReceiver(errorReceiver, new IntentFilter(LocalHTTPDManager.ACTION_ERROR)); + LocalHTTPDManager.start(context, false); + assertTrue(startLatch.await(30, TimeUnit.SECONDS)); + assertTrue(Utils.isServerSocketInUse(PORT)); + assertTrue(Utils.canConnectToSocket(LOCALHOST, PORT)); + lbm.unregisterReceiver(latchReceiver); + lbm.unregisterReceiver(stoppedReceiver); + lbm.unregisterReceiver(errorReceiver); + + final CountDownLatch stopLatch = new CountDownLatch(1); + latchReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + stopLatch.countDown(); + } + }; + lbm.registerReceiver(startedReceiver, new IntentFilter(LocalHTTPDManager.ACTION_STARTED)); + lbm.registerReceiver(latchReceiver, new IntentFilter(LocalHTTPDManager.ACTION_STOPPED)); + lbm.registerReceiver(errorReceiver, new IntentFilter(LocalHTTPDManager.ACTION_ERROR)); + LocalHTTPDManager.stop(context); + assertTrue(stopLatch.await(30, TimeUnit.SECONDS)); + assertFalse(Utils.isServerSocketInUse(PORT)); + assertFalse(Utils.canConnectToSocket(LOCALHOST, PORT)); // if this is flaky, just remove it + lbm.unregisterReceiver(latchReceiver); + } + + @Test + public void testError() throws InterruptedException, IOException { + Log.i("LocalHTTPDManagerTest", "testError"); + ServerSocket blockerSocket = new ServerSocket(PORT); + + final CountDownLatch latch = new CountDownLatch(1); + BroadcastReceiver latchReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + latch.countDown(); + } + }; + lbm.registerReceiver(startedReceiver, new IntentFilter(LocalHTTPDManager.ACTION_STARTED)); + lbm.registerReceiver(stoppedReceiver, new IntentFilter(LocalHTTPDManager.ACTION_STOPPED)); + lbm.registerReceiver(latchReceiver, new IntentFilter(LocalHTTPDManager.ACTION_ERROR)); + LocalHTTPDManager.start(context, false); + assertTrue(latch.await(30, TimeUnit.SECONDS)); + assertTrue(Utils.isServerSocketInUse(PORT)); + assertNotEquals(PORT, FDroidApp.port); + assertFalse(Utils.isServerSocketInUse(FDroidApp.port)); + lbm.unregisterReceiver(latchReceiver); + blockerSocket.close(); + } + + @Test + public void testRestart() throws InterruptedException, IOException { + Log.i("LocalHTTPDManagerTest", "testRestart"); + assertFalse(Utils.isServerSocketInUse(PORT)); + final CountDownLatch startLatch = new CountDownLatch(1); + BroadcastReceiver latchReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + startLatch.countDown(); + } + }; + lbm.registerReceiver(latchReceiver, new IntentFilter(LocalHTTPDManager.ACTION_STARTED)); + lbm.registerReceiver(stoppedReceiver, new IntentFilter(LocalHTTPDManager.ACTION_STOPPED)); + lbm.registerReceiver(errorReceiver, new IntentFilter(LocalHTTPDManager.ACTION_ERROR)); + LocalHTTPDManager.start(context, false); + assertTrue(startLatch.await(30, TimeUnit.SECONDS)); + assertTrue(Utils.isServerSocketInUse(PORT)); + lbm.unregisterReceiver(latchReceiver); + lbm.unregisterReceiver(stoppedReceiver); + + final CountDownLatch restartLatch = new CountDownLatch(1); + latchReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + restartLatch.countDown(); + } + }; + lbm.registerReceiver(latchReceiver, new IntentFilter(LocalHTTPDManager.ACTION_STARTED)); + LocalHTTPDManager.restart(context, false); + assertTrue(restartLatch.await(30, TimeUnit.SECONDS)); + lbm.unregisterReceiver(latchReceiver); + } + + private final BroadcastReceiver startedReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String message = intent.getStringExtra(Intent.EXTRA_TEXT); + Log.i(TAG, "startedReceiver: " + message); + fail(); + } + }; + + private final BroadcastReceiver stoppedReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String message = intent.getStringExtra(Intent.EXTRA_TEXT); + Log.i(TAG, "stoppedReceiver: " + message); + fail(); + } + }; + + private final BroadcastReceiver errorReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String message = intent.getStringExtra(Intent.EXTRA_TEXT); + Log.i(TAG, "errorReceiver: " + message); + fail(); + } + }; +} diff --git a/app/src/full/AndroidManifest.xml b/app/src/full/AndroidManifest.xml index 55ef16a6e..a39ec62e8 100644 --- a/app/src/full/AndroidManifest.xml +++ b/app/src/full/AndroidManifest.xml @@ -78,6 +78,7 @@ android:exported="false"/> + diff --git a/app/src/full/java/org/fdroid/fdroid/localrepo/LocalHTTPDManager.java b/app/src/full/java/org/fdroid/fdroid/localrepo/LocalHTTPDManager.java new file mode 100644 index 000000000..eabbd0a46 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/localrepo/LocalHTTPDManager.java @@ -0,0 +1,129 @@ +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.util.Log; +import org.fdroid.fdroid.FDroidApp; +import org.fdroid.fdroid.Preferences; +import org.fdroid.fdroid.net.LocalHTTPD; +import org.fdroid.fdroid.net.WifiStateChangeService; + +import java.io.IOException; +import java.net.BindException; +import java.util.Random; + +/** + * Manage {@link LocalHTTPD} in a {@link HandlerThread}; + */ +public class LocalHTTPDManager { + private static final String TAG = "LocalHTTPDManager"; + + public static final String ACTION_STARTED = "LocalHTTPDStarted"; + public static final String ACTION_STOPPED = "LocalHTTPDStopped"; + public static final String ACTION_ERROR = "LocalHTTPDError"; + + private static final int STOP = 5709; + + private static Handler handler; + private static volatile HandlerThread handlerThread; + private static LocalHTTPD localHttpd; + + public static void start(Context context) { + start(context, Preferences.get().isLocalRepoHttpsEnabled()); + } + + /** + * Testable version, not for regular use. + * + * @see #start(Context) + */ + static void start(final Context context, final boolean useHttps) { + if (handlerThread != null && handlerThread.isAlive()) { + Log.w(TAG, "handlerThread is already running, doing nothing!"); + return; + } + + handlerThread = new HandlerThread("LocalHTTPD", Process.THREAD_PRIORITY_LESS_FAVORABLE) { + @Override + protected void onLooperPrepared() { + localHttpd = new LocalHTTPD( + context, + FDroidApp.ipAddressString, + FDroidApp.port, + context.getFilesDir(), + useHttps); + try { + localHttpd.start(); + Intent intent = new Intent(ACTION_STARTED); + LocalBroadcastManager.getInstance(context).sendBroadcast(intent); + } catch (BindException e) { + int prev = FDroidApp.port; + FDroidApp.port = FDroidApp.port + new Random().nextInt(1111); + WifiStateChangeService.start(context, null); + Intent intent = new Intent(ACTION_ERROR); + intent.putExtra(Intent.EXTRA_TEXT, + "port " + prev + " occupied, trying on " + FDroidApp.port + ": (" + + e.getLocalizedMessage() + ")"); + LocalBroadcastManager.getInstance(context).sendBroadcast(intent); + } catch (IOException e) { + e.printStackTrace(); + Intent intent = new Intent(ACTION_ERROR); + intent.putExtra(Intent.EXTRA_TEXT, e.getLocalizedMessage()); + LocalBroadcastManager.getInstance(context).sendBroadcast(intent); + } + } + }; + handlerThread.start(); + handler = new Handler(handlerThread.getLooper()) { + @Override + public void handleMessage(Message msg) { + localHttpd.stop(); + handlerThread.quit(); + handlerThread = null; + } + }; + } + + public static void stop(Context context) { + if (handler == null || handlerThread == null || !handlerThread.isAlive()) { + Log.w(TAG, "handlerThread is already stopped, doing nothing!"); + handlerThread = null; + return; + } + handler.sendEmptyMessage(STOP); + Intent stoppedIntent = new Intent(ACTION_STOPPED); + LocalBroadcastManager.getInstance(context).sendBroadcast(stoppedIntent); + } + + /** + * Run {@link #stop(Context)}, wait for it to actually stop, then run + * {@link #start(Context)}. + */ + public static void restart(Context context) { + restart(context, Preferences.get().isLocalRepoHttpsEnabled()); + } + + /** + * Testable version, not for regular use. + * + * @see #restart(Context) + */ + static void restart(Context context, boolean useHttps) { + stop(context); + try { + handlerThread.join(10000); + } catch (InterruptedException | NullPointerException e) { + // ignored + } + start(context, useHttps); + } + + public static boolean isAlive() { + return handlerThread != null && handlerThread.isAlive(); + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/localrepo/SwapService.java b/app/src/full/java/org/fdroid/fdroid/localrepo/SwapService.java index f8675347c..36c0ee1e2 100644 --- a/app/src/full/java/org/fdroid/fdroid/localrepo/SwapService.java +++ b/app/src/full/java/org/fdroid/fdroid/localrepo/SwapService.java @@ -133,23 +133,17 @@ public class SwapService extends Service { if (getPeer() == null) { throw new IllegalStateException("Cannot connect to peer, no peer has been selected."); } - connectTo(getPeer(), getPeer().shouldPromptForSwapBack()); + connectTo(getPeer()); + if (isEnabled() && getPeer().shouldPromptForSwapBack()) { + askServerToSwapWithUs(peerRepo); + } } - public void connectTo(@NonNull Peer peer, boolean requestSwapBack) { + public void connectTo(@NonNull Peer peer) { if (peer != this.peer) { Log.e(TAG, "Oops, got a different peer to swap with than initially planned."); } - peerRepo = ensureRepoExists(peer); - - // Only ask server to swap with us, if we are actually running a local repo service. - // It is possible to have a swap initiated without first starting a swap, in which - // case swapping back is pointless. - if (isEnabled() && requestSwapBack) { - askServerToSwapWithUs(peerRepo); - } - UpdateService.updateRepoNow(this, peer.getRepoAddress()); } @@ -184,7 +178,9 @@ public class SwapService extends Service { intent.putExtra(Downloader.EXTRA_ERROR_MESSAGE, e.getLocalizedMessage()); LocalBroadcastManager.getInstance(getApplicationContext()).sendBroadcast(intent); } finally { - conn.disconnect(); + if (conn != null) { + conn.disconnect(); + } } return null; } @@ -362,7 +358,7 @@ public class SwapService extends Service { } public boolean isEnabled() { - return bluetoothSwap.isConnected() || wifiSwap.isConnected(); + return bluetoothSwap.isConnected() || LocalHTTPDManager.isAlive(); } // ========================================== @@ -492,6 +488,7 @@ public class SwapService extends Service { bluetoothAdapter.disable(); } + LocalHTTPDManager.stop(this); if (wifiManager != null && !wasWifiEnabledBeforeSwap()) { wifiManager.setWifiEnabled(false); } @@ -548,7 +545,7 @@ public class SwapService extends Service { @Override public void run() { if (peer != null) { - connectTo(peer, false); + connectTo(peer); } } }; diff --git a/app/src/full/java/org/fdroid/fdroid/localrepo/type/WifiSwap.java b/app/src/full/java/org/fdroid/fdroid/localrepo/type/WifiSwap.java index f76dbe042..efec3e91e 100644 --- a/app/src/full/java/org/fdroid/fdroid/localrepo/type/WifiSwap.java +++ b/app/src/full/java/org/fdroid/fdroid/localrepo/type/WifiSwap.java @@ -1,18 +1,12 @@ package org.fdroid.fdroid.localrepo.type; -import android.annotation.SuppressLint; import android.content.Context; import android.net.wifi.WifiManager; -import android.os.Handler; -import android.os.Looper; -import android.os.Message; import android.util.Log; import org.fdroid.fdroid.FDroidApp; -import org.fdroid.fdroid.Preferences; import org.fdroid.fdroid.Utils; +import org.fdroid.fdroid.localrepo.LocalHTTPDManager; import org.fdroid.fdroid.localrepo.SwapService; -import org.fdroid.fdroid.net.LocalHTTPD; -import org.fdroid.fdroid.net.WifiStateChangeService; import rx.Single; import rx.SingleSubscriber; import rx.android.schedulers.AndroidSchedulers; @@ -20,17 +14,11 @@ import rx.functions.Action1; import rx.functions.Func2; import rx.schedulers.Schedulers; -import java.io.IOException; -import java.net.BindException; -import java.util.Random; - @SuppressWarnings("LineLength") public class WifiSwap extends SwapType { private static final String TAG = "WifiSwap"; - private Handler webServerThreadHandler; - private LocalHTTPD localHttpd; private final BonjourBroadcast bonjourBroadcast; private final WifiManager wifiManager; @@ -50,10 +38,10 @@ public class WifiSwap extends SwapType { @Override public void start() { + sendBroadcast(SwapService.EXTRA_STARTING); wifiManager.setWifiEnabled(true); - Utils.debugLog(TAG, "Preparing swap webserver."); - sendBroadcast(SwapService.EXTRA_STARTING); + LocalHTTPDManager.start(context); if (FDroidApp.ipAddressString == null) { Log.e(TAG, "Not starting swap webserver, because we don't seem to be connected to a network."); @@ -110,65 +98,17 @@ public class WifiSwap extends SwapType { private Single.OnSubscribe getWebServerTask() { return new Single.OnSubscribe() { @Override - public void call(final SingleSubscriber singleSubscriber) { - new Thread(new Runnable() { - // Tell Eclipse this is not a leak because of Looper use. - @SuppressLint("HandlerLeak") - @Override - public void run() { - localHttpd = new LocalHTTPD( - context, - FDroidApp.ipAddressString, - FDroidApp.port, - context.getFilesDir(), - Preferences.get().isLocalRepoHttpsEnabled()); - - Looper.prepare(); // must be run before creating a Handler - webServerThreadHandler = new Handler() { - @Override - public void handleMessage(Message msg) { - Log.i(TAG, "we've been asked to stop the webserver: " + msg.obj); - localHttpd.stop(); - Looper looper = Looper.myLooper(); - if (looper == null) { - Log.e(TAG, "Looper.myLooper() was null for sum reason while shutting down the swap webserver."); - } else { - looper.quit(); - } - } - }; - try { - Utils.debugLog(TAG, "Starting swap webserver..."); - localHttpd.start(); - Utils.debugLog(TAG, "Swap webserver started."); - singleSubscriber.onSuccess(true); - } catch (BindException e) { - int prev = FDroidApp.port; - FDroidApp.port = FDroidApp.port + new Random().nextInt(1111); - WifiStateChangeService.start(context, null); - singleSubscriber.onError(new Exception("port " + prev + " occupied, trying on " + FDroidApp.port + "!")); - } catch (IOException e) { - Log.e(TAG, "Could not start local repo HTTP server", e); - singleSubscriber.onError(e); - } - Looper.loop(); // start the message receiving loop - } - }).start(); + public void call(SingleSubscriber singleSubscriber) { + singleSubscriber.onSuccess(true); } }; } @Override public void stop() { - sendBroadcast(SwapService.EXTRA_STOPPING); - if (webServerThreadHandler == null) { - Log.i(TAG, "null handler in stopWebServer"); - } else { - Utils.debugLog(TAG, "Sending message to swap webserver to stop it."); - Message msg = webServerThreadHandler.obtainMessage(); - msg.obj = webServerThreadHandler.getLooper().getThread().getName() + " says stop"; - webServerThreadHandler.sendMessage(msg); - } + 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 diff --git a/app/src/full/java/org/fdroid/fdroid/net/LocalHTTPD.java b/app/src/full/java/org/fdroid/fdroid/net/LocalHTTPD.java index 8315909ee..beaf2b4c0 100644 --- a/app/src/full/java/org/fdroid/fdroid/net/LocalHTTPD.java +++ b/app/src/full/java/org/fdroid/fdroid/net/LocalHTTPD.java @@ -49,6 +49,7 @@ import java.io.FilenameFilter; import java.io.IOException; import java.io.InputStream; import java.io.UnsupportedEncodingException; +import java.lang.ref.WeakReference; import java.net.URLEncoder; import java.text.DateFormat; import java.text.SimpleDateFormat; @@ -80,7 +81,7 @@ public class LocalHTTPD extends NanoHTTPD { */ public static final String[] INDEX_FILE_NAMES = {"index.html"}; - private final Context context; + private final WeakReference context; protected List rootDirs; @@ -101,7 +102,7 @@ public class LocalHTTPD extends NanoHTTPD { public LocalHTTPD(Context context, String hostname, int port, File webRoot, boolean useHttps) { super(hostname, port); rootDirs = Collections.singletonList(webRoot); - this.context = context.getApplicationContext(); + this.context = new WeakReference<>(context.getApplicationContext()); if (useHttps) { enableHTTPS(); } @@ -370,7 +371,7 @@ public class LocalHTTPD extends NanoHTTPD { return newFixedLengthResponse(Response.Status.BAD_REQUEST, MIME_PLAINTEXT, "Requires 'repo' parameter to be posted."); } - SwapWorkflowActivity.requestSwap(context, session.getParms().get("repo")); + SwapWorkflowActivity.requestSwap(context.get(), session.getParms().get("repo")); return newFixedLengthResponse(Response.Status.OK, MIME_PLAINTEXT, "Swap request received."); } return newFixedLengthResponse(""); @@ -491,7 +492,7 @@ public class LocalHTTPD extends NanoHTTPD { private void enableHTTPS() { try { - LocalRepoKeyStore localRepoKeyStore = LocalRepoKeyStore.get(context); + LocalRepoKeyStore localRepoKeyStore = LocalRepoKeyStore.get(context.get()); SSLServerSocketFactory factory = NanoHTTPD.makeSSLSocketFactory( localRepoKeyStore.getKeyStore(), localRepoKeyStore.getKeyManagers()); diff --git a/app/src/main/java/org/fdroid/fdroid/Utils.java b/app/src/main/java/org/fdroid/fdroid/Utils.java index b6121dd08..58611ba37 100644 --- a/app/src/main/java/org/fdroid/fdroid/Utils.java +++ b/app/src/main/java/org/fdroid/fdroid/Utils.java @@ -63,6 +63,9 @@ import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; import java.nio.charset.Charset; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -887,4 +890,26 @@ public final class Utils { theme.resolveAttribute(R.attr.colorPrimary, typedValue, true); swipeLayout.setColorSchemeColors(typedValue.data); } + + public static boolean canConnectToSocket(String host, int port) { + try { + Socket socket = new Socket(); + socket.connect(new InetSocketAddress(host, port), 5); + socket.close(); + return true; + } catch (IOException e) { + // Could not connect. + return false; + } + } + + public static boolean isServerSocketInUse(int port) { + try { + (new ServerSocket(port)).close(); + return false; + } catch (IOException e) { + // Could not connect. + return true; + } + } } diff --git a/app/src/testFull/java/org/fdroid/fdroid/localrepo/LocalHTTPDManagerTest.java b/app/src/testFull/java/org/fdroid/fdroid/localrepo/LocalHTTPDManagerTest.java new file mode 100644 index 000000000..6326e38ad --- /dev/null +++ b/app/src/testFull/java/org/fdroid/fdroid/localrepo/LocalHTTPDManagerTest.java @@ -0,0 +1,72 @@ +package org.fdroid.fdroid.localrepo; + +import android.content.Context; +import org.fdroid.fdroid.FDroidApp; +import org.fdroid.fdroid.Utils; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.shadows.ShadowLog; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + + +@RunWith(RobolectricTestRunner.class) +public class LocalHTTPDManagerTest { + + @Test + public void testStartStop() throws InterruptedException { + ShadowLog.stream = System.out; + Context context = RuntimeEnvironment.application; + + final String host = "localhost"; + final int port = 8888; + assertFalse(Utils.isServerSocketInUse(port)); + LocalHTTPDManager.stop(context); + + FDroidApp.ipAddressString = host; + FDroidApp.port = port; + + LocalHTTPDManager.start(context, false); + final CountDownLatch startLatch = new CountDownLatch(1); + new Thread(new Runnable() { + @Override + public void run() { + while (!Utils.isServerSocketInUse(port)) { + try { + Thread.sleep(500); + } catch (InterruptedException e) { + fail(); + } + } + startLatch.countDown(); + } + }).start(); + assertTrue(startLatch.await(30, TimeUnit.SECONDS)); + assertTrue(Utils.isServerSocketInUse(port)); + assertTrue(Utils.canConnectToSocket(host, port)); + + LocalHTTPDManager.stop(context); + final CountDownLatch stopLatch = new CountDownLatch(1); + new Thread(new Runnable() { + @Override + public void run() { + while (!Utils.isServerSocketInUse(port)) { + try { + Thread.sleep(500); + } catch (InterruptedException e) { + fail(); + } + } + stopLatch.countDown(); + } + }).start(); + assertTrue(stopLatch.await(10, TimeUnit.SECONDS)); + } +}