diff --git a/F-Droid/res/layout-v14/simple_list_item_3.xml b/F-Droid/res/layout-v14/simple_list_item_3.xml
new file mode 100644
index 000000000..c1cb020cf
--- /dev/null
+++ b/F-Droid/res/layout-v14/simple_list_item_3.xml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/F-Droid/res/layout/simple_list_item_3.xml b/F-Droid/res/layout/simple_list_item_3.xml
new file mode 100644
index 000000000..fdde261f8
--- /dev/null
+++ b/F-Droid/res/layout/simple_list_item_3.xml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/F-Droid/res/layout/swap_bluetooth_devices.xml b/F-Droid/res/layout/swap_bluetooth_devices.xml
new file mode 100644
index 000000000..fd16a39ed
--- /dev/null
+++ b/F-Droid/res/layout/swap_bluetooth_devices.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/F-Droid/res/layout/swap_bluetooth_header.xml b/F-Droid/res/layout/swap_bluetooth_header.xml
new file mode 100644
index 000000000..e74261ef9
--- /dev/null
+++ b/F-Droid/res/layout/swap_bluetooth_header.xml
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/F-Droid/res/layout/swap_join_wifi.xml b/F-Droid/res/layout/swap_join_wifi.xml
index a07230bb1..2b579ca14 100644
--- a/F-Droid/res/layout/swap_join_wifi.xml
+++ b/F-Droid/res/layout/swap_join_wifi.xml
@@ -26,12 +26,12 @@
android:layout_below="@+id/text_description"
android:layout_centerHorizontal="true" />
-
+
+
diff --git a/F-Droid/src/org/apache/commons/io/input/BoundedInputStream.java b/F-Droid/src/org/apache/commons/io/input/BoundedInputStream.java
new file mode 100644
index 000000000..f80c1730f
--- /dev/null
+++ b/F-Droid/src/org/apache/commons/io/input/BoundedInputStream.java
@@ -0,0 +1,230 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.io.input;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * This is a stream that will only supply bytes up to a certain length - if its
+ * position goes above that, it will stop.
+ *
+ * This is useful to wrap ServletInputStreams. The ServletInputStream will block
+ * if you try to read content from it that isn't there, because it doesn't know
+ * whether the content hasn't arrived yet or whether the content has finished.
+ * So, one of these, initialized with the Content-length sent in the
+ * ServletInputStream's header, will stop it blocking, providing it's been sent
+ * with a correct content length.
+ *
+ * @version $Id: BoundedInputStream.java 1307462 2012-03-30 15:13:11Z ggregory $
+ * @since 2.0
+ */
+public class BoundedInputStream extends InputStream {
+
+ /** the wrapped input stream */
+ private final InputStream in;
+
+ /** the max length to provide */
+ private final long max;
+
+ /** the number of bytes already returned */
+ private long pos = 0;
+
+ /** the marked position */
+ private long mark = -1;
+
+ /** flag if close shoud be propagated */
+ private boolean propagateClose = true;
+
+ /**
+ * Creates a new BoundedInputStream
that wraps the given input
+ * stream and limits it to a certain size.
+ *
+ * @param in The wrapped input stream
+ * @param size The maximum number of bytes to return
+ */
+ public BoundedInputStream(InputStream in, long size) {
+ // Some badly designed methods - eg the servlet API - overload length
+ // such that "-1" means stream finished
+ this.max = size;
+ this.in = in;
+ }
+
+ /**
+ * Creates a new BoundedInputStream
that wraps the given input
+ * stream and is unlimited.
+ *
+ * @param in The wrapped input stream
+ */
+ public BoundedInputStream(InputStream in) {
+ this(in, -1);
+ }
+
+ /**
+ * Invokes the delegate's read()
method if
+ * the current position is less than the limit.
+ * @return the byte read or -1 if the end of stream or
+ * the limit has been reached.
+ * @throws IOException if an I/O error occurs
+ */
+ @Override
+ public int read() throws IOException {
+ if (max >= 0 && pos >= max) {
+ return -1;
+ }
+ int result = in.read();
+ pos++;
+ return result;
+ }
+
+ /**
+ * Invokes the delegate's read(byte[])
method.
+ * @param b the buffer to read the bytes into
+ * @return the number of bytes read or -1 if the end of stream or
+ * the limit has been reached.
+ * @throws IOException if an I/O error occurs
+ */
+ @Override
+ public int read(byte[] b) throws IOException {
+ return this.read(b, 0, b.length);
+ }
+
+ /**
+ * Invokes the delegate's read(byte[], int, int)
method.
+ * @param b the buffer to read the bytes into
+ * @param off The start offset
+ * @param len The number of bytes to read
+ * @return the number of bytes read or -1 if the end of stream or
+ * the limit has been reached.
+ * @throws IOException if an I/O error occurs
+ */
+ @Override
+ public int read(byte[] b, int off, int len) throws IOException {
+ if (max>=0 && pos>=max) {
+ return -1;
+ }
+ long maxRead = max>=0 ? Math.min(len, max-pos) : len;
+ int bytesRead = in.read(b, off, (int)maxRead);
+
+ if (bytesRead==-1) {
+ return -1;
+ }
+
+ pos+=bytesRead;
+ return bytesRead;
+ }
+
+ /**
+ * Invokes the delegate's skip(long)
method.
+ * @param n the number of bytes to skip
+ * @return the actual number of bytes skipped
+ * @throws IOException if an I/O error occurs
+ */
+ @Override
+ public long skip(long n) throws IOException {
+ long toSkip = max>=0 ? Math.min(n, max-pos) : n;
+ long skippedBytes = in.skip(toSkip);
+ pos+=skippedBytes;
+ return skippedBytes;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public int available() throws IOException {
+ if (max>=0 && pos>=max) {
+ return 0;
+ }
+ return in.available();
+ }
+
+ /**
+ * Invokes the delegate's toString()
method.
+ * @return the delegate's toString()
+ */
+ @Override
+ public String toString() {
+ return in.toString();
+ }
+
+ /**
+ * Invokes the delegate's close()
method
+ * if {@link #isPropagateClose()} is {@code true}.
+ * @throws IOException if an I/O error occurs
+ */
+ @Override
+ public void close() throws IOException {
+ if (propagateClose) {
+ in.close();
+ }
+ }
+
+ /**
+ * Invokes the delegate's reset()
method.
+ * @throws IOException if an I/O error occurs
+ */
+ @Override
+ public synchronized void reset() throws IOException {
+ in.reset();
+ pos = mark;
+ }
+
+ /**
+ * Invokes the delegate's mark(int)
method.
+ * @param readlimit read ahead limit
+ */
+ @Override
+ public synchronized void mark(int readlimit) {
+ in.mark(readlimit);
+ mark = pos;
+ }
+
+ /**
+ * Invokes the delegate's markSupported()
method.
+ * @return true if mark is supported, otherwise false
+ */
+ @Override
+ public boolean markSupported() {
+ return in.markSupported();
+ }
+
+ /**
+ * Indicates whether the {@link #close()} method
+ * should propagate to the underling {@link InputStream}.
+ *
+ * @return {@code true} if calling {@link #close()}
+ * propagates to the close()
method of the
+ * underlying stream or {@code false} if it does not.
+ */
+ public boolean isPropagateClose() {
+ return propagateClose;
+ }
+
+ /**
+ * Set whether the {@link #close()} method
+ * should propagate to the underling {@link InputStream}.
+ *
+ * @param propagateClose {@code true} if calling
+ * {@link #close()} propagates to the close()
+ * method of the underlying stream or
+ * {@code false} if it does not.
+ */
+ public void setPropagateClose(boolean propagateClose) {
+ this.propagateClose = propagateClose;
+ }
+}
diff --git a/F-Droid/src/org/fdroid/fdroid/localrepo/SwapService.java b/F-Droid/src/org/fdroid/fdroid/localrepo/SwapService.java
index bee1d4ebd..8a54b0f5e 100644
--- a/F-Droid/src/org/fdroid/fdroid/localrepo/SwapService.java
+++ b/F-Droid/src/org/fdroid/fdroid/localrepo/SwapService.java
@@ -115,6 +115,8 @@ public class SwapService extends Service {
public static final int STEP_SUCCESS = 7;
public static final int STEP_CONFIRM_SWAP = 8;
+ public static final int STEP_BLUETOOTH = 1000; // TODO: Remove this once nathans code is merged and the UI is migrated to the nearby peers screen.
+
/**
* Special view, that we don't really want to actually store against the
* {@link SwapService#step}. Rather, we use it for the purpose of specifying
@@ -260,7 +262,7 @@ public class SwapService extends Service {
* This is the same as, e.g. {@link Context#getSystemService(String)}
*/
@IntDef({STEP_INTRO, STEP_SELECT_APPS, STEP_JOIN_WIFI, STEP_SHOW_NFC, STEP_WIFI_QR,
- STEP_CONNECTING, STEP_SUCCESS, STEP_CONFIRM_SWAP, STEP_INITIAL_LOADING})
+ STEP_CONNECTING, STEP_SUCCESS, STEP_CONFIRM_SWAP, STEP_INITIAL_LOADING, STEP_BLUETOOTH})
@Retention(RetentionPolicy.SOURCE)
public @interface SwapStep {}
diff --git a/F-Droid/src/org/fdroid/fdroid/localrepo/type/WebServerType.java b/F-Droid/src/org/fdroid/fdroid/localrepo/type/WebServerType.java
index 3131bdee1..997d5f647 100644
--- a/F-Droid/src/org/fdroid/fdroid/localrepo/type/WebServerType.java
+++ b/F-Droid/src/org/fdroid/fdroid/localrepo/type/WebServerType.java
@@ -39,6 +39,8 @@ public class WebServerType extends SwapType {
public void run() {
localHttpd = new LocalHTTPD(
context,
+ FDroidApp.ipAddressString,
+ FDroidApp.port,
context.getFilesDir(),
Preferences.get().isLocalRepoHttpsEnabled());
diff --git a/F-Droid/src/org/fdroid/fdroid/net/BluetoothDownloader.java b/F-Droid/src/org/fdroid/fdroid/net/BluetoothDownloader.java
new file mode 100644
index 000000000..a860e2aa2
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/net/BluetoothDownloader.java
@@ -0,0 +1,105 @@
+package org.fdroid.fdroid.net;
+
+import android.content.Context;
+import android.util.Log;
+import org.apache.commons.io.input.BoundedInputStream;
+import org.fdroid.fdroid.net.bluetooth.BluetoothConnection;
+import org.fdroid.fdroid.net.bluetooth.FileDetails;
+import org.fdroid.fdroid.net.bluetooth.httpish.Request;
+import org.fdroid.fdroid.net.bluetooth.httpish.Response;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.MalformedURLException;
+
+public class BluetoothDownloader extends Downloader {
+
+ private static final String TAG = "BluetoothDownloader";
+
+ private final BluetoothConnection connection;
+ private FileDetails fileDetails;
+ private final String sourcePath;
+
+ public BluetoothDownloader(BluetoothConnection connection, String sourcePath, Context ctx) throws IOException {
+ super(ctx);
+ this.connection = connection;
+ this.sourcePath = sourcePath;
+ }
+
+ public BluetoothDownloader(BluetoothConnection connection, String sourcePath, File destFile) throws FileNotFoundException, MalformedURLException {
+ super(destFile);
+ this.connection = connection;
+ this.sourcePath = sourcePath;
+ }
+
+ public BluetoothDownloader(BluetoothConnection connection, String sourcePath, OutputStream output) throws MalformedURLException {
+ super(output);
+ this.connection = connection;
+ this.sourcePath = sourcePath;
+ }
+
+ @Override
+ public InputStream getInputStream() throws IOException {
+ Response response = Request.createGET(sourcePath, connection).send();
+ fileDetails = response.toFileDetails();
+
+ // TODO: Manage the dependency which includes this class better?
+ // Right now, I only needed the one class from apache commons.
+ // There are countless classes online which provide this functionality,
+ // including some which are available from the Android SDK - the only
+ // problem is that they have a funky API which doesn't just wrap a
+ // plain old InputStream (the class is ContentLengthInputStream -
+ // whereas this BoundedInputStream is much more generic and useful
+ // to us).
+ BoundedInputStream stream = new BoundedInputStream(response.toContentStream(), fileDetails.getFileSize());
+ stream.setPropagateClose(false);
+ return stream;
+ }
+
+ /**
+ * May return null if an error occurred while getting file details.
+ * TODO: Should we throw an exception? Everywhere else in this blue package throws IO exceptions weelx`x`xy-neely.
+ * Will probably require some thought as to how the API looks, with regards to all of the public methods
+ * and their signatures.
+ */
+ public FileDetails getFileDetails() {
+ if (fileDetails == null) {
+ Log.d(TAG, "Going to Bluetooth \"server\" to get file details.");
+ try {
+ fileDetails = Request.createHEAD(sourceUrl.getPath(), connection).send().toFileDetails();
+ } catch (IOException e) {
+ Log.e(TAG, "Error getting file details from Bluetooth \"server\": " + e.getMessage());
+ }
+ }
+ return fileDetails;
+ }
+
+ @Override
+ public boolean hasChanged() {
+ return getFileDetails().getCacheTag() == null || getFileDetails().getCacheTag().equals(getCacheTag());
+ }
+
+ @Override
+ public int totalDownloadSize() {
+ return getFileDetails().getFileSize();
+ }
+
+ @Override
+ public void download() throws IOException, InterruptedException {
+ downloadFromStream();
+ }
+
+ @Override
+ public boolean isCached() {
+ FileDetails details = getFileDetails();
+ return (
+ details != null &&
+ details.getCacheTag() != null &&
+ details.getCacheTag().equals(getCacheTag())
+ );
+ }
+
+}
diff --git a/F-Droid/src/org/fdroid/fdroid/net/Downloader.java b/F-Droid/src/org/fdroid/fdroid/net/Downloader.java
index 746fcc1c7..af1c6325c 100644
--- a/F-Droid/src/org/fdroid/fdroid/net/Downloader.java
+++ b/F-Droid/src/org/fdroid/fdroid/net/Downloader.java
@@ -127,7 +127,8 @@ public abstract class Downloader {
// we were interrupted before proceeding to the download.
throwExceptionIfInterrupted();
- copyInputToOutputStream(getInputStream());
+ // TODO: Check side effects of changing this second getInputStream() to input.
+ copyInputToOutputStream(input);
} finally {
Utils.closeQuietly(outputStream);
Utils.closeQuietly(input);
@@ -173,12 +174,13 @@ public abstract class Downloader {
int count = input.read(buffer);
throwExceptionIfInterrupted();
- bytesRead += count;
- sendProgress(bytesRead, totalBytes);
if (count == -1) {
Log.d(TAG, "Finished downloading from stream");
break;
}
+
+ bytesRead += count;
+ sendProgress(bytesRead, totalBytes);
outputStream.write(buffer, 0, count);
}
outputStream.flush();
diff --git a/F-Droid/src/org/fdroid/fdroid/net/DownloaderFactory.java b/F-Droid/src/org/fdroid/fdroid/net/DownloaderFactory.java
index a6cae1438..59192b932 100644
--- a/F-Droid/src/org/fdroid/fdroid/net/DownloaderFactory.java
+++ b/F-Droid/src/org/fdroid/fdroid/net/DownloaderFactory.java
@@ -1,26 +1,37 @@
package org.fdroid.fdroid.net;
import android.content.Context;
+import android.net.Uri;
import java.io.File;
import java.io.IOException;
public class DownloaderFactory {
- public static Downloader create(String url, Context context)
- throws IOException {
- if (isOnionAddress(url)) {
+ public static Downloader create(String url, Context context) throws IOException {
+ Uri uri = Uri.parse(url);
+ if (isBluetoothAddress(uri)) {
+ return new BluetoothDownloader(null, uri.getPath(), context);
+ } else if (isOnionAddress(url)) {
return new TorHttpDownloader(url, context);
+ } else {
+ return new HttpDownloader(url, context);
}
- return new HttpDownloader(url, context);
}
- public static Downloader create(String url, File destFile)
- throws IOException {
- if (isOnionAddress(url)) {
+ public static Downloader create(String url, File destFile) throws IOException {
+ Uri uri = Uri.parse(url);
+ if (isBluetoothAddress(uri)) {
+ return new BluetoothDownloader(null, uri.getPath(), destFile);
+ } else if (isOnionAddress(url)) {
return new TorHttpDownloader(url, destFile);
+ } else {
+ return new HttpDownloader(url, destFile);
}
- return new HttpDownloader(url, destFile);
+ }
+
+ private static boolean isBluetoothAddress(Uri uri) {
+ return "bluetooth".equalsIgnoreCase(uri.getScheme());
}
private static boolean isOnionAddress(String url) {
diff --git a/F-Droid/src/org/fdroid/fdroid/net/HttpDownloader.java b/F-Droid/src/org/fdroid/fdroid/net/HttpDownloader.java
index c1a6a3650..0387da110 100644
--- a/F-Droid/src/org/fdroid/fdroid/net/HttpDownloader.java
+++ b/F-Droid/src/org/fdroid/fdroid/net/HttpDownloader.java
@@ -2,9 +2,9 @@ package org.fdroid.fdroid.net;
import android.content.Context;
import android.util.Log;
-
import org.fdroid.fdroid.Preferences;
+import javax.net.ssl.SSLHandshakeException;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
@@ -16,8 +16,6 @@ import java.net.Proxy;
import java.net.SocketAddress;
import java.net.URL;
-import javax.net.ssl.SSLHandshakeException;
-
public class HttpDownloader extends Downloader {
private static final String TAG = "HttpDownloader";
@@ -26,6 +24,7 @@ public class HttpDownloader extends Downloader {
protected HttpURLConnection connection;
private int statusCode = -1;
+ private boolean onlyStream = false;
// The context is required for opening the file to write to.
HttpDownloader(String source, File destFile)
@@ -39,11 +38,22 @@ public class HttpDownloader extends Downloader {
* you are done*.
* @see org.fdroid.fdroid.net.Downloader#getFile()
*/
- HttpDownloader(String source, Context ctx) throws IOException {
+ public HttpDownloader(String source, Context ctx) throws IOException {
super(ctx);
sourceUrl = new URL(source);
}
+ /**
+ * Calling this makes this downloader not download a file. Instead, it will
+ * only stream the file through the {@link HttpDownloader#getInputStream()}
+ * @return
+ */
+ public HttpDownloader streamDontDownload()
+ {
+ onlyStream = true;
+ return this;
+ }
+
@Override
public InputStream getInputStream() throws IOException {
setupConnection();
@@ -134,4 +144,8 @@ public class HttpDownloader extends Downloader {
return this.statusCode != 304;
}
+ public int getStatusCode() {
+ return statusCode;
+ }
+
}
diff --git a/F-Droid/src/org/fdroid/fdroid/net/LocalHTTPD.java b/F-Droid/src/org/fdroid/fdroid/net/LocalHTTPD.java
index f7fb8d7c6..874707af8 100644
--- a/F-Droid/src/org/fdroid/fdroid/net/LocalHTTPD.java
+++ b/F-Droid/src/org/fdroid/fdroid/net/LocalHTTPD.java
@@ -36,8 +36,8 @@ public class LocalHTTPD extends NanoHTTPD {
private final File webRoot;
private final boolean logRequests;
- public LocalHTTPD(Context context, File webRoot, boolean useHttps) {
- super(FDroidApp.ipAddressString, FDroidApp.port);
+ public LocalHTTPD(Context context, String hostname, int port, File webRoot, boolean useHttps) {
+ super(hostname, port);
this.logRequests = false;
this.webRoot = webRoot;
this.context = context.getApplicationContext();
diff --git a/F-Droid/src/org/fdroid/fdroid/net/bluetooth/BluetoothClient.java b/F-Droid/src/org/fdroid/fdroid/net/bluetooth/BluetoothClient.java
new file mode 100644
index 000000000..3179c259e
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/net/bluetooth/BluetoothClient.java
@@ -0,0 +1,26 @@
+package org.fdroid.fdroid.net.bluetooth;
+
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothSocket;
+
+import java.io.IOException;
+
+public class BluetoothClient {
+
+ @SuppressWarnings("unused")
+ private static final String TAG = "BluetoothClient";
+
+ private BluetoothDevice device;
+
+ public BluetoothClient(BluetoothDevice device) {
+ this.device = device;
+ }
+
+ public BluetoothConnection openConnection() throws IOException {
+ BluetoothSocket socket = device.createInsecureRfcommSocketToServiceRecord(BluetoothConstants.fdroidUuid());
+ BluetoothConnection connection = new BluetoothConnection(socket);
+ connection.open();
+ return connection;
+ }
+
+}
diff --git a/F-Droid/src/org/fdroid/fdroid/net/bluetooth/BluetoothConnection.java b/F-Droid/src/org/fdroid/fdroid/net/bluetooth/BluetoothConnection.java
new file mode 100644
index 000000000..c74f1e3cd
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/net/bluetooth/BluetoothConnection.java
@@ -0,0 +1,61 @@
+package org.fdroid.fdroid.net.bluetooth;
+
+import android.annotation.TargetApi;
+import android.bluetooth.BluetoothSocket;
+import android.os.Build;
+import android.util.Log;
+import org.fdroid.fdroid.Utils;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+public class BluetoothConnection {
+
+ private static final String TAG = "BluetoothConnection";
+
+ private InputStream input = null;
+ private OutputStream output = null;
+ protected final BluetoothSocket socket;
+
+ public BluetoothConnection(BluetoothSocket socket) throws IOException {
+ this.socket = socket;
+ }
+
+ public InputStream getInputStream() {
+ return input;
+ }
+
+ public OutputStream getOutputStream() {
+ return output;
+ }
+
+ @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
+ public void open() throws IOException {
+ if (!socket.isConnected()) {
+ // Server sockets will already be connected when they are passed to us,
+ // client sockets require us to call connect().
+ socket.connect();
+ }
+
+ input = socket.getInputStream();
+ output = socket.getOutputStream();
+ Log.d(TAG, "Opened connection to Bluetooth device");
+ }
+
+ public void closeQuietly() {
+ Utils.closeQuietly(input);
+ Utils.closeQuietly(output);
+ Utils.closeQuietly(socket);
+ }
+
+ public void close() throws IOException {
+ if (input == null || output == null) {
+ throw new RuntimeException("Cannot close() a BluetoothConnection before calling open()" );
+ }
+
+ input.close();
+ output.close();
+ socket.close();
+ }
+}
\ No newline at end of file
diff --git a/F-Droid/src/org/fdroid/fdroid/net/bluetooth/BluetoothConstants.java b/F-Droid/src/org/fdroid/fdroid/net/bluetooth/BluetoothConstants.java
new file mode 100644
index 000000000..e0876462d
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/net/bluetooth/BluetoothConstants.java
@@ -0,0 +1,16 @@
+package org.fdroid.fdroid.net.bluetooth;
+
+import java.util.UUID;
+
+/**
+ * We need some shared information between the client and the server app.
+ */
+public class BluetoothConstants {
+
+ public static UUID fdroidUuid() {
+ // TODO: Generate a UUID deterministically from, e.g. "org.fdroid.fdroid.net.Bluetooth";
+ // This UUID is just from the first example at http://www.ietf.org/rfc/rfc4122.txt
+ return UUID.fromString("f81d4fae-7dec-11d0-a765-00a0c91e6bf6");
+ }
+
+}
diff --git a/F-Droid/src/org/fdroid/fdroid/net/bluetooth/BluetoothServer.java b/F-Droid/src/org/fdroid/fdroid/net/bluetooth/BluetoothServer.java
new file mode 100644
index 000000000..9066ba5f5
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/net/bluetooth/BluetoothServer.java
@@ -0,0 +1,370 @@
+package org.fdroid.fdroid.net.bluetooth;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothServerSocket;
+import android.bluetooth.BluetoothSocket;
+import android.content.Context;
+import android.os.Build;
+import android.util.Log;
+import android.webkit.MimeTypeMap;
+
+import org.fdroid.fdroid.FDroidApp;
+import org.fdroid.fdroid.Utils;
+import org.fdroid.fdroid.net.HttpDownloader;
+import org.fdroid.fdroid.net.bluetooth.httpish.Request;
+import org.fdroid.fdroid.net.bluetooth.httpish.Response;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FilenameFilter;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import fi.iki.elonen.NanoHTTPD;
+
+/**
+ * Act as a layer on top of LocalHTTPD server, by forwarding requests served
+ * over bluetooth to that server.
+ */
+public class BluetoothServer extends Thread {
+
+ private static final String TAG = "BluetoothServer";
+
+ private BluetoothServerSocket serverSocket;
+ private List clients = new ArrayList<>();
+
+ private final Context context;
+
+ private String deviceBluetoothName = null;
+ public final static String BLUETOOTH_NAME_TAG = "FDroid:";
+ private final File webRoot;
+
+ public BluetoothServer(Context context, File webRoot) {
+ this.context = context.getApplicationContext();
+ this.webRoot = webRoot;
+ }
+
+ public void close() {
+
+ for (Connection connection : clients) {
+ connection.interrupt();
+ }
+
+ if (serverSocket != null) {
+ Utils.closeQuietly(serverSocket);
+ }
+
+ BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
+ adapter.setName(deviceBluetoothName.replace(BLUETOOTH_NAME_TAG,""));
+
+ }
+
+ @Override
+ public void run() {
+
+ BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
+
+
+ //store the original bluetoothname, and update this one to be unique
+ deviceBluetoothName = adapter.getName();
+
+ if (!deviceBluetoothName.contains(BLUETOOTH_NAME_TAG))
+ adapter.setName(BLUETOOTH_NAME_TAG + deviceBluetoothName);
+
+
+ try {
+ serverSocket = adapter.listenUsingInsecureRfcommWithServiceRecord("FDroid App Swap", BluetoothConstants.fdroidUuid());
+ } catch (IOException e) {
+ Log.e(TAG, "Error starting Bluetooth server socket, will stop the server now - " + e.getMessage());
+ return;
+ }
+
+ while (true) {
+ try {
+ BluetoothSocket clientSocket = serverSocket.accept();
+ if (clientSocket != null && !isInterrupted()) {
+ Connection client = new Connection(context, clientSocket, webRoot);
+ client.start();
+ clients.add(client);
+ } else {
+ break;
+ }
+ } catch (IOException e) {
+ Log.e(TAG, "Error receiving client connection over Bluetooth server socket, will continue listening for other clients - " + e.getMessage());
+ }
+ }
+
+ }
+
+ private static class Connection extends Thread {
+
+ private final Context context;
+ private final BluetoothSocket socket;
+ private final File webRoot;
+
+ public Connection(Context context, BluetoothSocket socket, File webRoot) {
+ this.context = context.getApplicationContext();
+ this.socket = socket;
+ this.webRoot = webRoot;
+ }
+
+ @Override
+ public void run() {
+
+ Log.d(TAG, "Listening for incoming Bluetooth requests from client");
+
+ BluetoothConnection connection;
+ try {
+ connection = new BluetoothConnection(socket);
+ connection.open();
+ } catch (IOException e) {
+ Log.e(TAG, "Error listening for incoming connections over bluetooth - " + e.getMessage());
+ return;
+ }
+
+ while (true) {
+
+ try {
+ Log.d(TAG, "Listening for new Bluetooth request from client.");
+ Request incomingRequest = Request.listenForRequest(connection);
+ handleRequest(incomingRequest).send(connection);
+ } catch (IOException e) {
+ Log.e(TAG, "Error receiving incoming connection over bluetooth - " + e.getMessage());
+
+
+ }
+
+ if (isInterrupted())
+ break;
+
+ }
+
+ }
+
+ private Response handleRequest(Request request) throws IOException {
+
+ Log.d(TAG, "Received Bluetooth request from client, will process it now.");
+
+ Response.Builder builder = null;
+
+ try {
+// HttpDownloader downloader = new HttpDownloader("http://127.0.0.1:" + ( FDroidApp.port) + "/" + request.getPath(), context);
+ int statusCode = 404;
+ int totalSize = -1;
+
+ if (request.getMethod().equals(Request.Methods.HEAD)) {
+ builder = new Response.Builder();
+ } else {
+ HashMap headers = new HashMap();
+ Response resp = respond(headers, "/" + request.getPath());
+
+ builder = new Response.Builder(resp.toContentStream());
+ statusCode = resp.getStatusCode();
+ totalSize = resp.getFileSize();
+ }
+
+ // TODO: At this stage, will need to download the file to get this info.
+ // However, should be able to make totalDownloadSize and getCacheTag work without downloading.
+ return builder
+ .setStatusCode(statusCode)
+ .setFileSize(totalSize)
+ .build();
+
+ } catch (Exception e) {
+ /*
+ if (Build.VERSION.SDK_INT <= 9) {
+ // Would like to use the specific IOException below with a "cause", but it is
+ // only supported on SDK 9, so I guess this is the next most useful thing.
+ throw e;
+ } else {
+ throw new IOException("Error getting file " + request.getPath() + " from local repo proxy - " + e.getMessage(), e);
+ }*/
+
+ Log.e(TAG, "error processing request; sending 500 response", e);
+
+ if (builder == null)
+ builder = new Response.Builder();
+
+ return builder
+ .setStatusCode(500)
+ .setFileSize(0)
+ .build();
+
+ }
+
+ }
+
+
+ private Response respond(Map headers, String uri) {
+ // Remove URL arguments
+ uri = uri.trim().replace(File.separatorChar, '/');
+ if (uri.indexOf('?') >= 0) {
+ uri = uri.substring(0, uri.indexOf('?'));
+ }
+
+ // Prohibit getting out of current directory
+ if (uri.contains("../")) {
+ return createResponse(NanoHTTPD.Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT,
+ "FORBIDDEN: Won't serve ../ for security reasons.");
+ }
+
+ File f = new File(webRoot, uri);
+ if (!f.exists()) {
+ return createResponse(NanoHTTPD.Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT,
+ "Error 404, file not found.");
+ }
+
+ // Browsers get confused without '/' after the directory, send a
+ // redirect.
+ if (f.isDirectory() && !uri.endsWith("/")) {
+ uri += "/";
+ Response res = createResponse(NanoHTTPD.Response.Status.REDIRECT, NanoHTTPD.MIME_HTML,
+ "Redirected: " + uri + "");
+ res.addHeader("Location", uri);
+ return res;
+ }
+
+ if (f.isDirectory()) {
+ // First look for index files (index.html, index.htm, etc) and if
+ // none found, list the directory if readable.
+ String indexFile = findIndexFileInDirectory(f);
+ if (indexFile == null) {
+ if (f.canRead()) {
+ // No index file, list the directory if it is readable
+ return createResponse(NanoHTTPD.Response.Status.NOT_FOUND, NanoHTTPD.MIME_HTML, "");
+ } else {
+ return createResponse(NanoHTTPD.Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT,
+ "FORBIDDEN: No directory listing.");
+ }
+ } else {
+ return respond(headers, uri + indexFile);
+ }
+ }
+
+ Response response = serveFile(uri, headers, f, getMimeTypeForFile(uri));
+ return response != null ? response :
+ createResponse(NanoHTTPD.Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT,
+ "Error 404, file not found.");
+ }
+
+ /**
+ * Serves file from homeDir and its' subdirectories (only). Uses only URI,
+ * ignores all headers and HTTP parameters.
+ */
+ Response serveFile(String uri, Map header, File file, String mime) {
+ Response res;
+ try {
+ // Calculate etag
+ String etag = Integer
+ .toHexString((file.getAbsolutePath() + file.lastModified() + "" + file.length())
+ .hashCode());
+
+ // Support (simple) skipping:
+ long startFrom = 0;
+ long endAt = -1;
+ String range = header.get("range");
+ if (range != null) {
+ if (range.startsWith("bytes=")) {
+ range = range.substring("bytes=".length());
+ int minus = range.indexOf('-');
+ try {
+ if (minus > 0) {
+ startFrom = Long.parseLong(range.substring(0, minus));
+ endAt = Long.parseLong(range.substring(minus + 1));
+ }
+ } catch (NumberFormatException ignored) {
+ }
+ }
+ }
+
+ // Change return code and add Content-Range header when skipping is
+ // requested
+ long fileLen = file.length();
+ if (range != null && startFrom >= 0) {
+ if (startFrom >= fileLen) {
+ res = createResponse(NanoHTTPD.Response.Status.RANGE_NOT_SATISFIABLE,
+ NanoHTTPD.MIME_PLAINTEXT, "");
+ res.addHeader("Content-Range", "bytes 0-0/" + fileLen);
+ res.addHeader("ETag", etag);
+ } else {
+ if (endAt < 0) {
+ endAt = fileLen - 1;
+ }
+ long newLen = endAt - startFrom + 1;
+ if (newLen < 0) {
+ newLen = 0;
+ }
+
+ final long dataLen = newLen;
+ FileInputStream fis = new FileInputStream(file) {
+ @Override
+ public int available() throws IOException {
+ return (int) dataLen;
+ }
+ };
+ fis.skip(startFrom);
+
+ res = createResponse(NanoHTTPD.Response.Status.PARTIAL_CONTENT, mime, fis);
+ res.addHeader("Content-Length", "" + dataLen);
+ res.addHeader("Content-Range", "bytes " + startFrom + "-" + endAt + "/"
+ + fileLen);
+ res.addHeader("ETag", etag);
+ }
+ } else {
+ if (etag.equals(header.get("if-none-match")))
+ res = createResponse(NanoHTTPD.Response.Status.NOT_MODIFIED, mime, "");
+ else {
+ res = createResponse(NanoHTTPD.Response.Status.OK, mime, new FileInputStream(file));
+ res.addHeader("Content-Length", "" + fileLen);
+ res.addHeader("ETag", etag);
+ }
+ }
+ } catch (IOException ioe) {
+ res = createResponse(NanoHTTPD.Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT,
+ "FORBIDDEN: Reading file failed.");
+ }
+
+ return res;
+ }
+
+ // Announce that the file server accepts partial content requests
+ private Response createResponse(NanoHTTPD.Response.Status status, String mimeType, String content) {
+ Response res = new Response(status.getRequestStatus(), mimeType, content);
+ return res;
+ }
+
+ // Announce that the file server accepts partial content requests
+ private Response createResponse(NanoHTTPD.Response.Status status, String mimeType, InputStream content) {
+ Response res = new Response(status.getRequestStatus(), mimeType, content);
+ return res;
+ }
+
+ public static String getMimeTypeForFile(String uri) {
+ String type = null;
+ String extension = MimeTypeMap.getFileExtensionFromUrl(uri);
+ if (extension != null) {
+ MimeTypeMap mime = MimeTypeMap.getSingleton();
+ type = mime.getMimeTypeFromExtension(extension);
+ }
+ return type;
+ }
+
+ private String findIndexFileInDirectory(File directory) {
+ String indexFileName = "index.html";
+ File indexFile = new File(directory, indexFileName);
+ if (indexFile.exists()) {
+ return indexFileName;
+ }
+ return null;
+ }
+ }
+
+
+}
diff --git a/F-Droid/src/org/fdroid/fdroid/net/bluetooth/FileDetails.java b/F-Droid/src/org/fdroid/fdroid/net/bluetooth/FileDetails.java
new file mode 100644
index 000000000..f7148a91f
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/net/bluetooth/FileDetails.java
@@ -0,0 +1,23 @@
+package org.fdroid.fdroid.net.bluetooth;
+
+public class FileDetails {
+
+ private String cacheTag;
+ private int fileSize;
+
+ public String getCacheTag() {
+ return cacheTag;
+ }
+
+ public int getFileSize() {
+ return fileSize;
+ }
+
+ public void setFileSize(int fileSize) {
+ this.fileSize = fileSize;
+ }
+
+ public void setCacheTag(String cacheTag) {
+ this.cacheTag = cacheTag;
+ }
+}
diff --git a/F-Droid/src/org/fdroid/fdroid/net/bluetooth/UnexpectedResponseException.java b/F-Droid/src/org/fdroid/fdroid/net/bluetooth/UnexpectedResponseException.java
new file mode 100644
index 000000000..518b03dbd
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/net/bluetooth/UnexpectedResponseException.java
@@ -0,0 +1,12 @@
+package org.fdroid.fdroid.net.bluetooth;
+
+public class UnexpectedResponseException extends Exception {
+
+ public UnexpectedResponseException(String message) {
+ super(message);
+ }
+
+ public UnexpectedResponseException(String message, Throwable cause) {
+ super("Unexpected response from Bluetooth server: '" + message + "'", cause);
+ }
+}
\ No newline at end of file
diff --git a/F-Droid/src/org/fdroid/fdroid/net/bluetooth/httpish/Request.java b/F-Droid/src/org/fdroid/fdroid/net/bluetooth/httpish/Request.java
new file mode 100644
index 000000000..f78c981c4
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/net/bluetooth/httpish/Request.java
@@ -0,0 +1,170 @@
+package org.fdroid.fdroid.net.bluetooth.httpish;
+
+import android.util.Log;
+import org.fdroid.fdroid.net.bluetooth.BluetoothConnection;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStreamWriter;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+
+public class Request {
+
+
+ private static final String TAG = "bluetooth.Request";
+
+ public interface Methods {
+ String HEAD = "HEAD";
+ String GET = "GET";
+ }
+
+ private String method;
+ private String path;
+ private Map headers;
+
+ private BluetoothConnection connection;
+ private BufferedWriter output;
+ private BufferedReader input;
+
+ private Request(String method, String path, BluetoothConnection connection) {
+ this.method = method;
+ this.path = path;
+ this.connection = connection;
+
+ output = new BufferedWriter(new OutputStreamWriter(connection.getOutputStream()));
+ input = new BufferedReader(new InputStreamReader(connection.getInputStream()));
+ }
+
+ public static Request createHEAD(String path, BluetoothConnection connection)
+ {
+ return new Request(Methods.HEAD, path, connection);
+ }
+
+ public static Request createGET(String path, BluetoothConnection connection) {
+ return new Request(Methods.GET, path, connection);
+ }
+
+ public String getHeaderValue(String header) {
+ return headers.containsKey(header) ? headers.get(header) : null;
+ }
+
+ public Response send() throws IOException {
+
+ Log.d(TAG, "Sending request to server (" + path + ")");
+
+ output.write(method);
+ output.write(' ');
+ output.write(path);
+
+ output.write("\n\n");
+
+ output.flush();
+
+ Log.d(TAG, "Finished sending request, now attempting to read response status code...");
+
+ int responseCode = readResponseCode();
+
+ Log.d(TAG, "Read response code " + responseCode + " from server, now reading headers...");
+
+ Map headers = readHeaders();
+
+ Log.d(TAG, "Read " + headers.size() + " headers");
+
+ if (method.equals(Methods.HEAD)) {
+ Log.d(TAG, "Request was a " + Methods.HEAD + " request, not including anything other than headers and status...");
+ return new Response(responseCode, headers);
+ } else {
+ Log.d(TAG, "Request was a " + Methods.GET + " request, so including content stream in response...");
+ return new Response(responseCode, headers, connection.getInputStream());
+ }
+
+ }
+
+ /**
+ * Helper function used by listenForRequest().
+ * The reason it is here is because the listenForRequest() is a static function, which would
+ * need to instantiate it's own InputReaders from the bluetooth connection. However, we already
+ * have that happening in a Request, so it is in some ways simpler to delegate to a member
+ * method like this.
+ */
+ private boolean listen() throws IOException {
+
+ String requestLine = input.readLine();
+
+ if (requestLine == null || requestLine.trim().length() == 0)
+ return false;
+
+ String[] parts = requestLine.split("\\s+");
+
+ // First part is the method (GET/HEAD), second is the path (/fdroid/repo/index.jar)
+ if (parts.length < 2)
+ return false;
+
+ method = parts[0].toUpperCase(Locale.ENGLISH);
+ path = parts[1];
+ headers = readHeaders();
+ return true;
+ }
+
+ /**
+ * This is a blocking method, which will wait until a full Request is received.
+ */
+ public static Request listenForRequest(BluetoothConnection connection) throws IOException {
+ Request request = new Request("", "", connection);
+ return request.listen() ? request : null;
+ }
+
+ /**
+ * First line of a HTTP response is the status line:
+ * http://www.w3.org/Protocols/rfc2616/rfc2616-sec6.html#sec6.1
+ * The first part is the HTTP version, followed by a space, then the status code, then
+ * a space, and then the status label (which may contain spaces).
+ */
+ private int readResponseCode() throws IOException {
+ String line = input.readLine();
+ if (line == null) {
+ // TODO: What to do?
+ return -1;
+ }
+
+ // TODO: Error handling
+ int firstSpace = line.indexOf(' ');
+ int secondSpace = line.indexOf(' ', firstSpace + 1);
+
+ String status = line.substring(firstSpace + 1, secondSpace);
+ return Integer.parseInt(status);
+ }
+
+ /**
+ * Subsequent lines (after the status line) represent the headers, which are case
+ * insensitive and may be multi-line. We don't deal with multi-line headers in
+ * our HTTP-ish implementation.
+ */
+ private Map readHeaders() throws IOException {
+ Map headers = new HashMap<>();
+ String responseLine = input.readLine();
+ while (responseLine != null && responseLine.length() > 0) {
+
+ // TODO: Error handling
+ String[] parts = responseLine.split(":");
+ String header = parts[0].trim();
+ String value = parts[1].trim();
+ headers.put(header, value);
+ responseLine = input.readLine();
+ }
+ return headers;
+ }
+
+ public String getPath() {
+ return path;
+ }
+
+ public String getMethod() {
+ return method;
+ }
+
+}
diff --git a/F-Droid/src/org/fdroid/fdroid/net/bluetooth/httpish/Response.java b/F-Droid/src/org/fdroid/fdroid/net/bluetooth/httpish/Response.java
new file mode 100644
index 000000000..1f1fc5ad5
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/net/bluetooth/httpish/Response.java
@@ -0,0 +1,179 @@
+package org.fdroid.fdroid.net.bluetooth.httpish;
+
+import android.util.Log;
+import org.fdroid.fdroid.Utils;
+import org.fdroid.fdroid.net.bluetooth.BluetoothConnection;
+import org.fdroid.fdroid.net.bluetooth.FileDetails;
+import org.fdroid.fdroid.net.bluetooth.httpish.headers.Header;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStreamWriter;
+import java.io.StringBufferInputStream;
+import java.io.Writer;
+import java.util.HashMap;
+import java.util.Map;
+
+public class Response {
+
+ private static final String TAG = "bluetooth.Response";
+
+ private int statusCode;
+ private Map headers;
+ private final InputStream contentStream;
+
+ public Response(int statusCode, Map headers) {
+ this(statusCode, headers, null);
+ }
+
+ /**
+ * This class expects 'contentStream' to be open, and ready for use.
+ * It will not close it either. However it will block wile doing things
+ * so you can call a method, wait for it to finish, and then close
+ * it afterwards if you like.
+ */
+ public Response(int statusCode, Map headers, InputStream contentStream) {
+ this.statusCode = statusCode;
+ this.headers = headers;
+ this.contentStream = contentStream;
+ }
+
+ public Response(int statusCode, String mimeType, String content) {
+ this.statusCode = statusCode;
+ this.headers = new HashMap();
+ this.contentStream = new StringBufferInputStream(content);
+ }
+
+ public Response(int statusCode, String mimeType, InputStream contentStream) {
+ this.statusCode = statusCode;
+ this.headers = new HashMap();
+ this.contentStream = contentStream;
+ }
+
+ public void addHeader (String key, String value)
+ {
+ headers.put(key, value);
+ }
+
+ public int getStatusCode() {
+ return statusCode;
+ }
+
+ public int getFileSize() {
+ if (headers != null) {
+ for (Map.Entry entry : headers.entrySet()) {
+ if (entry.getKey().toLowerCase().equals("content-length")) {
+ return Integer.parseInt( entry.getValue()); // TODO: error handling.
+ }
+ }
+ }
+ return -1;
+ }
+
+ /**
+ * Extracts meaningful headers from the response into a more useful and safe
+ * {@link org.fdroid.fdroid.net.bluetooth.FileDetails} object.
+ */
+ public FileDetails toFileDetails() {
+ FileDetails details = new FileDetails();
+ for (Map.Entry entry : headers.entrySet()) {
+ Header.process(details, entry.getKey(), entry.getValue());
+ }
+ return details;
+ }
+
+ public InputStream toContentStream() throws UnsupportedOperationException {
+ if (contentStream == null) {
+ throw new UnsupportedOperationException("This kind of response doesn't have a content stream. Did you perform a HEAD request instead of a GET request?");
+ }
+ return contentStream;
+ }
+
+ public void send(BluetoothConnection connection) throws IOException {
+
+ Log.d(TAG, "Sending Bluetooth HTTP-ish response...");
+
+ Writer output = new OutputStreamWriter(connection.getOutputStream());
+ output.write("HTTP(ish)/0.1 200 OK\n");
+
+ for (Map.Entry entry : headers.entrySet()) {
+ output.write(entry.getKey());
+ output.write(": ");
+ output.write(entry.getValue());
+ output.write("\n");
+ }
+
+ output.write("\n");
+ output.flush();
+
+ if (contentStream != null) {
+ Utils.copy(contentStream, connection.getOutputStream());
+ }
+
+ output.flush();
+
+ }
+
+ public String readContents() throws IOException {
+ int size = getFileSize();
+ if (contentStream == null || getFileSize() <= 0) {
+ return null;
+ }
+
+ int pos = 0;
+ byte[] buffer = new byte[4096];
+ ByteArrayOutputStream contents = new ByteArrayOutputStream(size);
+ while (pos < size) {
+ int read = contentStream.read(buffer);
+ pos += read;
+ contents.write(buffer, 0, read);
+ }
+ return contents.toString();
+ }
+
+ public static class Builder {
+
+ private InputStream contentStream;
+ private int statusCode = 200;
+ private int fileSize = -1;
+ private String etag = null;
+
+ public Builder() {}
+
+ public Builder(InputStream contentStream) {
+ this.contentStream = contentStream;
+ }
+
+ public Builder setStatusCode(int statusCode) {
+ this.statusCode = statusCode;
+ return this;
+ }
+
+ public Builder setFileSize(int fileSize) {
+ this.fileSize = fileSize;
+ return this;
+ }
+
+ public Builder setETag(String etag) {
+ this.etag = etag;
+ return this;
+ }
+
+ public Response build() {
+
+ Map headers = new HashMap<>(3);
+
+ if (fileSize > 0) {
+ headers.put("Content-Length", Integer.toString(fileSize));
+ }
+
+ if (etag != null) {
+ headers.put( "ETag", etag);
+ }
+
+ return new Response(statusCode, headers, contentStream);
+ }
+
+ }
+}
diff --git a/F-Droid/src/org/fdroid/fdroid/net/bluetooth/httpish/headers/ContentLengthHeader.java b/F-Droid/src/org/fdroid/fdroid/net/bluetooth/httpish/headers/ContentLengthHeader.java
new file mode 100644
index 000000000..a2cc07c6c
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/net/bluetooth/httpish/headers/ContentLengthHeader.java
@@ -0,0 +1,16 @@
+package org.fdroid.fdroid.net.bluetooth.httpish.headers;
+
+import org.fdroid.fdroid.net.bluetooth.FileDetails;
+
+public class ContentLengthHeader extends Header {
+
+ @Override
+ public String getName() {
+ return "content-length";
+ }
+
+ public void handle(FileDetails details, String value) {
+ details.setFileSize(Integer.parseInt(value));
+ }
+
+}
\ No newline at end of file
diff --git a/F-Droid/src/org/fdroid/fdroid/net/bluetooth/httpish/headers/ETagHeader.java b/F-Droid/src/org/fdroid/fdroid/net/bluetooth/httpish/headers/ETagHeader.java
new file mode 100644
index 000000000..81eb41dc3
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/net/bluetooth/httpish/headers/ETagHeader.java
@@ -0,0 +1,16 @@
+package org.fdroid.fdroid.net.bluetooth.httpish.headers;
+
+import org.fdroid.fdroid.net.bluetooth.FileDetails;
+
+public class ETagHeader extends Header {
+
+ @Override
+ public String getName() {
+ return "etag";
+ }
+
+ public void handle(FileDetails details, String value) {
+ details.setCacheTag(value);
+ }
+
+}
diff --git a/F-Droid/src/org/fdroid/fdroid/net/bluetooth/httpish/headers/Header.java b/F-Droid/src/org/fdroid/fdroid/net/bluetooth/httpish/headers/Header.java
new file mode 100644
index 000000000..30327021c
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/net/bluetooth/httpish/headers/Header.java
@@ -0,0 +1,25 @@
+package org.fdroid.fdroid.net.bluetooth.httpish.headers;
+
+import org.fdroid.fdroid.net.bluetooth.FileDetails;
+
+public abstract class Header {
+
+ private static Header[] VALID_HEADERS = {
+ new ContentLengthHeader(),
+ new ETagHeader(),
+ };
+
+ protected abstract String getName();
+ protected abstract void handle(FileDetails details, String value);
+
+ public static void process(FileDetails details, String header, String value) {
+ header = header.toLowerCase();
+ for (Header potentialHeader : VALID_HEADERS) {
+ if (potentialHeader.getName().equals(header)) {
+ potentialHeader.handle(details, value);
+ break;
+ }
+ }
+ }
+
+}
diff --git a/F-Droid/src/org/fdroid/fdroid/views/fragments/ThemeableListFragment.java b/F-Droid/src/org/fdroid/fdroid/views/fragments/ThemeableListFragment.java
index 324c2996a..f2bb8c786 100644
--- a/F-Droid/src/org/fdroid/fdroid/views/fragments/ThemeableListFragment.java
+++ b/F-Droid/src/org/fdroid/fdroid/views/fragments/ThemeableListFragment.java
@@ -21,9 +21,18 @@ public abstract class ThemeableListFragment extends ListFragment {
return 0;
}
- protected View getHeaderView(LayoutInflater inflater, ViewGroup container) {
+ protected View getHeaderView() {
+ return headerView;
+ }
+
+ private View headerView = null;
+
+ private View getHeaderView(LayoutInflater inflater, ViewGroup container) {
if (getHeaderLayout() > 0) {
- return inflater.inflate(getHeaderLayout(), null, false);
+ if (headerView == null) {
+ headerView = inflater.inflate(getHeaderLayout(), null, false);
+ }
+ return headerView;
} else {
return null;
}
diff --git a/F-Droid/src/org/fdroid/fdroid/views/swap/BluetoothDeviceListView.java b/F-Droid/src/org/fdroid/fdroid/views/swap/BluetoothDeviceListView.java
new file mode 100644
index 000000000..ef0d06888
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/views/swap/BluetoothDeviceListView.java
@@ -0,0 +1,376 @@
+package org.fdroid.fdroid.views.swap;
+
+import android.annotation.TargetApi;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Build;
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.support.v4.view.MenuItemCompat;
+import android.support.v4.widget.ContentLoadingProgressBar;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.ContextThemeWrapper;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.ListView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import org.fdroid.fdroid.R;
+import org.fdroid.fdroid.localrepo.SwapService;
+import org.fdroid.fdroid.net.BluetoothDownloader;
+import org.fdroid.fdroid.net.bluetooth.BluetoothClient;
+import org.fdroid.fdroid.net.bluetooth.BluetoothConnection;
+import org.fdroid.fdroid.net.bluetooth.BluetoothServer;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.List;
+
+public class BluetoothDeviceListView extends ListView implements
+ SwapWorkflowActivity.InnerView,
+ ListView.OnItemClickListener {
+
+ private static final String TAG = "BluetoothDeviceListView";
+
+ private Adapter adapter = null;
+
+ private MenuItem scanMenuItem;
+ private MenuItem cancelMenuItem;
+
+ private boolean firstScan = true;
+
+ public BluetoothDeviceListView(Context context) {
+ super(context);
+ }
+
+ public BluetoothDeviceListView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public BluetoothDeviceListView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ @TargetApi(Build.VERSION_CODES.LOLLIPOP)
+ public BluetoothDeviceListView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ @Override
+ public boolean buildMenu(Menu menu, @NonNull MenuInflater menuInflater) {
+ menuInflater.inflate(R.menu.swap_scan, menu);
+
+ final int flags = MenuItemCompat.SHOW_AS_ACTION_ALWAYS | MenuItemCompat.SHOW_AS_ACTION_WITH_TEXT;
+
+ scanMenuItem = menu.findItem(R.id.action_scan);
+ scanMenuItem.setVisible(true);
+ MenuItemCompat.setShowAsAction(scanMenuItem, flags);
+
+ scanMenuItem.setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() {
+ @Override
+ public boolean onMenuItemClick(MenuItem item) {
+ initiateBluetoothScan();
+ return true;
+ }
+ });
+
+ cancelMenuItem = menu.findItem(R.id.action_cancel);
+ cancelMenuItem.setVisible(false);
+ MenuItemCompat.setShowAsAction(cancelMenuItem, flags);
+
+ cancelMenuItem.setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() {
+ @Override
+ public boolean onMenuItemClick(MenuItem item) {
+ cancelBluetoothScan();
+ return true;
+ }
+ });
+
+ return true;
+ }
+
+ @Override
+ public int getStep() {
+ return SwapService.STEP_BLUETOOTH;
+ }
+
+ @Override
+ public int getPreviousStep() {
+ return SwapService.STEP_JOIN_WIFI;
+ }
+
+ @Override
+ public int getToolbarColour() {
+ return R.color.swap_blue;
+ }
+
+ @Override
+ public String getToolbarTitle() {
+ return getContext().getString(R.string.swap_use_bluetooth);
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ super.onFinishInflate();
+
+ adapter = new Adapter(
+ getContext(),
+ R.layout.select_local_apps_list_item
+ );
+
+ LayoutInflater inflater = (LayoutInflater)getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ View headerView = inflater.inflate(R.layout.swap_bluetooth_header, this, false);
+ addHeaderView(headerView);
+
+ setAdapter(adapter);
+ setOnItemClickListener(this);
+
+ final BluetoothAdapter bluetooth = BluetoothAdapter.getDefaultAdapter();
+
+ final TextView deviceName = (TextView) headerView.findViewById(R.id.device_name);
+ deviceName.setText(bluetooth.getName());
+
+ final TextView address = (TextView) headerView.findViewById(R.id.device_address);
+ address.setText(bluetooth.getAddress());
+
+ initiateBluetoothScan();
+
+ // populateBondedDevices();
+
+ }
+
+
+
+ private void cancelBluetoothScan() {
+
+ Log.d(TAG, "Cancelling bluetooth scan.");
+
+ cancelMenuItem.setVisible(false);
+ scanMenuItem.setVisible(true);
+
+ final BluetoothAdapter bluetooth = BluetoothAdapter.getDefaultAdapter();
+ bluetooth.cancelDiscovery();
+
+ getLoadingIndicator().hide();
+
+ }
+
+ private ContentLoadingProgressBar getLoadingIndicator() {
+ return ((ContentLoadingProgressBar)findViewById(R.id.loading_indicator));
+ }
+
+ private void initiateBluetoothScan()
+ {
+ Log.d(TAG, "Starting bluetooth scan...");
+
+ if (cancelMenuItem != null) {
+ cancelMenuItem.setVisible(true);
+ scanMenuItem.setVisible(false);
+ }
+
+ final ContentLoadingProgressBar loadingBar = getLoadingIndicator();
+
+ loadingBar.show();
+
+ final BluetoothAdapter bluetooth = BluetoothAdapter.getDefaultAdapter();
+
+ if (firstScan) {
+ final BroadcastReceiver deviceFoundReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+
+ if (BluetoothDevice.ACTION_FOUND.equals(intent.getAction())) {
+ BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
+ Log.d(TAG, "Found bluetooth device: " + device.toString());
+
+ if (device != null && device.getName() != null)
+ if (device.getName().contains(BluetoothServer.BLUETOOTH_NAME_TAG)) {
+ boolean exists = false;
+ for (int i = 0; i < adapter.getCount(); i++) {
+ if (adapter.getItem(i).getAddress().equals(device.getAddress())) {
+ exists = true;
+ break;
+ }
+ }
+
+ if (!exists) {
+ adapter.add(device);
+ }
+ }
+ }
+ }
+ };
+
+ final BroadcastReceiver scanCompleteReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ Log.d(TAG, "Scan complete: " + intent.getAction());
+ loadingBar.hide();
+ cancelMenuItem.setVisible(false);
+ scanMenuItem.setVisible(true);
+ }
+ };
+
+ getContext().registerReceiver(deviceFoundReceiver, new IntentFilter(BluetoothDevice.ACTION_FOUND));
+ getContext().registerReceiver(scanCompleteReceiver, new IntentFilter(BluetoothAdapter.ACTION_DISCOVERY_FINISHED));
+
+ firstScan = false;
+ }
+ else
+ {
+ if (bluetooth.isDiscovering())
+ {
+ bluetooth.cancelDiscovery();
+ }
+ }
+
+ if (!bluetooth.startDiscovery()) {
+ // TODO: Discovery did not start for some reason :(
+ Log.e(TAG, "Could not start bluetooth discovery, but am not sure why :(");
+ Toast.makeText(getContext(),"There was a problem looking for Bluetooth devices",Toast.LENGTH_SHORT).show();
+ }
+ }
+
+ private void populateBondedDevices()
+ {
+ for (BluetoothDevice device : BluetoothAdapter.getDefaultAdapter().getBondedDevices()) {
+ adapter.add(device);
+ }
+ }
+
+
+
+ @Override
+ public void onItemClick(AdapterView> parent, View view, int position, long id) {
+
+ // "position" includes the header view, so ignore that.
+ if (position == 0) {
+ return;
+ }
+
+ BluetoothDevice device = adapter.getItem(position - 1);
+
+ // TODO: I think that I can connect regardless of the bond state.
+ // It sounds like when I attempt to connect to a non-bonded peer, then
+ // Android initiates the pairing dialog on our behalf.
+
+ BluetoothClient client = new BluetoothClient(device);
+
+ try {
+ Log.d(TAG, "Testing bluetooth connection (opening connection first).");
+ BluetoothConnection connection = client.openConnection();
+
+ ByteArrayOutputStream stream = new ByteArrayOutputStream(4096);
+ BluetoothDownloader downloader = new BluetoothDownloader(connection, "/", stream);
+ downloader.downloadUninterrupted();
+ String result = stream.toString();
+ Log.d(TAG, "Download complete.");
+ Log.d(TAG, result);
+
+ Log.d(TAG, "Downloading again...");
+ downloader = new BluetoothDownloader(connection, "/fdroid/repo/index.xml", stream);
+ downloader.downloadUninterrupted();
+ result = stream.toString();
+ Log.d(TAG, "Download complete.");
+ Log.d(TAG, result);
+
+ /*Log.d(TAG, "Creating HEAD request for resource at \"/\"...");
+ Request head = Request.createGET("/", connection);
+ Log.d(TAG, "Sending request...");
+ Response response = head.send();
+ Log.d(TAG, "Response from bluetooth: " + response.getStatusCode());
+ String contents = response.readContents();
+ Log.d(TAG, contents);*/
+ } catch (IOException e) {
+ Log.e(TAG, "Error: " + e.getMessage());
+ }
+
+ /*if (device.getBondState() == BluetoothDevice.BOND_NONE) {
+ // attempt to bond
+
+ } else if (device.getBondState() == BluetoothDevice.BOND_BONDING) {
+ // wait for bonding to finish
+
+ } else if (device.getBondState() == BluetoothDevice.BOND_BONDED) {
+ // connect
+ BluetoothClient client = new BluetoothClient(device);
+ }*/
+ }
+
+ private class Adapter extends ArrayAdapter {
+
+ public Adapter(Context context, int resource) {
+ super(context, resource);
+ }
+
+ public Adapter(Context context, int resource, int textViewResourceId) {
+ super(context, resource, textViewResourceId);
+ }
+
+ public Adapter(Context context, int resource, BluetoothDevice[] objects) {
+ super(context, resource, objects);
+ }
+
+ public Adapter(Context context, int resource, int textViewResourceId, BluetoothDevice[] objects) {
+ super(context, resource, textViewResourceId, objects);
+ }
+
+ public Adapter(Context context, int resource, List objects) {
+ super(context, resource, objects);
+ }
+
+ public Adapter(Context context, int resource, int textViewResourceId, List objects) {
+ super(context, resource, textViewResourceId, objects);
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ View view;
+ if (convertView == null) {
+ LayoutInflater inflater = (LayoutInflater)getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ view = inflater.inflate(R.layout.simple_list_item_3, null);
+ } else {
+ view = convertView;
+ }
+
+ BluetoothDevice device = getItem(position);
+ TextView nameView = (TextView)view.findViewById(android.R.id.text1);
+ TextView addressView = (TextView)view.findViewById(android.R.id.text2);
+ //TextView descriptionView = (TextView)view.findViewById(R.id.text3);
+
+ nameView.setText(device.getName() == null ? getContext().getString(R.string.unknown) : device.getName());
+ addressView.setText(device.getAddress());
+ //descriptionView.setText(bondStateToLabel(device.getBondState()));
+
+ return view;
+ }
+
+ private String bondStateToLabel(int deviceBondState)
+ {
+ if (deviceBondState == BluetoothDevice.BOND_BONDED) {
+ // TODO: Is the term "Bonded device" common parlance among phone users?
+ // It sounds a bit technical to me, maybe something more lay like "Previously connected".
+ // Although it is technically not as accurate, it would make sense to more people...
+ return getContext().getString(R.string.swap_bluetooth_bonded_device);
+ } else if (deviceBondState == BluetoothDevice.BOND_BONDING) {
+ return getContext().getString(R.string.swap_bluetooth_bonding_device);
+ } else {
+ // TODO: Might be a little bit harsh, makes it sound more malicious than it should.
+ return getContext().getString(R.string.swap_bluetooth_unknown_device);
+ }
+ }
+ }
+
+}
diff --git a/F-Droid/src/org/fdroid/fdroid/views/swap/JoinWifiView.java b/F-Droid/src/org/fdroid/fdroid/views/swap/JoinWifiView.java
index 7f352fae2..b1530d97f 100644
--- a/F-Droid/src/org/fdroid/fdroid/views/swap/JoinWifiView.java
+++ b/F-Droid/src/org/fdroid/fdroid/views/swap/JoinWifiView.java
@@ -17,6 +17,7 @@ import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
+import android.widget.Button;
import android.widget.ImageView;
import android.widget.RelativeLayout;
import android.widget.TextView;
@@ -72,6 +73,15 @@ public class JoinWifiView extends RelativeLayout implements SwapWorkflowActivity
new IntentFilter(WifiStateChangeService.BROADCAST)
);
+ Button bluetooth = (Button)findViewById(R.id.btn_bluetooth);
+ bluetooth.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ getActivity().connectWithBluetooth();
+ }
+ }
+ );
+
}
// TODO: Listen for "Connecting..." state and reflect that in the view too.
@@ -117,7 +127,7 @@ public class JoinWifiView extends RelativeLayout implements SwapWorkflowActivity
return true;
}
});
- return true;
+ return true;
}
@Override
diff --git a/F-Droid/src/org/fdroid/fdroid/views/swap/SwapWorkflowActivity.java b/F-Droid/src/org/fdroid/fdroid/views/swap/SwapWorkflowActivity.java
index 0eb3b1850..2fad9a8a4 100644
--- a/F-Droid/src/org/fdroid/fdroid/views/swap/SwapWorkflowActivity.java
+++ b/F-Droid/src/org/fdroid/fdroid/views/swap/SwapWorkflowActivity.java
@@ -3,6 +3,7 @@ package org.fdroid.fdroid.views.swap;
import android.app.Activity;
import android.app.ProgressDialog;
import android.content.ComponentName;
+import android.bluetooth.BluetoothAdapter;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
@@ -38,6 +39,7 @@ import org.fdroid.fdroid.data.NewRepoConfig;
import org.fdroid.fdroid.localrepo.LocalRepoManager;
import org.fdroid.fdroid.localrepo.SwapService;
import org.fdroid.fdroid.localrepo.peers.Peer;
+import org.fdroid.fdroid.net.bluetooth.BluetoothServer;
import java.util.Arrays;
import java.util.HashSet;
@@ -84,7 +86,10 @@ public class SwapWorkflowActivity extends AppCompatActivity {
}
private static final String TAG = "SwapWorkflowActivity";
+
private static final int CONNECT_TO_SWAP = 1;
+ private static final int REQUEST_BLUETOOTH_ENABLE = 2;
+ private static final int REQUEST_BLUETOOTH_DISCOVERABLE = 3;
private Toolbar toolbar;
private InnerView currentView;
@@ -344,6 +349,10 @@ public class SwapWorkflowActivity extends AppCompatActivity {
inflateInnerView(R.layout.swap_join_wifi);
}
+ private void showBluetoothDeviceList() {
+ inflateInnerView(R.layout.swap_bluetooth_devices);
+ }
+
public void showWifiQr() {
inflateInnerView(R.layout.swap_wifi_qr);
}
@@ -408,9 +417,88 @@ public class SwapWorkflowActivity extends AppCompatActivity {
}
} else if (requestCode == CONNECT_TO_SWAP && resultCode == Activity.RESULT_OK) {
finish();
+ } else if (requestCode == REQUEST_BLUETOOTH_ENABLE) {
+
+ if (resultCode == RESULT_OK) {
+ Log.d(TAG, "User enabled Bluetooth, will make sure we are discoverable.");
+ ensureBluetoothDiscoverable();
+ } else {
+ // Didn't enable bluetooth
+ Log.d(TAG, "User chose not to enable Bluetooth, so doing nothing (i.e. sticking with wifi).");
+ }
+
+ } else if (requestCode == REQUEST_BLUETOOTH_DISCOVERABLE) {
+
+ if (resultCode != RESULT_CANCELED) {
+ Log.d(TAG, "User made Bluetooth discoverable, will proceed to start bluetooth server.");
+ startBluetoothServer();
+ } else {
+ Log.d(TAG, "User chose not to make Bluetooth discoverable, so doing nothing (i.e. sticking with wifi).");
+ }
+
}
}
+ /**
+ * The process for setting up bluetooth is as follows:
+ * * Assume we have bluetooth available (otherwise the button which allowed us to start
+ * the bluetooth process should not have been available). TODO: Remove button if bluetooth unavailable.
+ * * Ask user to enable (if not enabled yet).
+ * * Start bluetooth server socket.
+ * * Enable bluetooth discoverability, so that people can connect to our server socket.
+ *
+ * Note that this is a little different than the usual process for bluetooth _clients_, which
+ * involves pairing and connecting with other devices.
+ */
+ public void connectWithBluetooth() {
+
+ Log.d(TAG, "Initiating Bluetooth swap instead of wifi.");
+ BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
+
+ if (adapter != null)
+ if (adapter.isEnabled()) {
+ Log.d(TAG, "Bluetooth enabled, will pair with device.");
+ ensureBluetoothDiscoverable();
+ } else {
+ Log.d(TAG, "Bluetooth disabled, asking user to enable it.");
+ Intent enableBtIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE);
+ startActivityForResult(enableBtIntent, REQUEST_BLUETOOTH_ENABLE);
+ }
+ }
+
+ private void ensureBluetoothDiscoverable() {
+ Log.d(TAG, "Ensuring Bluetooth is in discoverable mode.");
+ if (BluetoothAdapter.getDefaultAdapter().getScanMode() != BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE) {
+
+ // TODO: Listen for BluetoothAdapter.ACTION_SCAN_MODE_CHANGED and respond if discovery
+ // is cancelled prematurely.
+
+ Log.d(TAG, "Not currently in discoverable mode, so prompting user to enable.");
+ Intent intent = new Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE);
+ intent.putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, 300);
+ startActivityForResult(intent, REQUEST_BLUETOOTH_DISCOVERABLE);
+ }
+
+ Log.d(TAG, "Staring the Bluetooth Server whether we are discoverable or not, since paired devices can still connect.");
+ startBluetoothServer();
+
+ }
+
+ private void startBluetoothServer() {
+ Log.d(TAG, "Starting bluetooth server.");
+ if (service == null) {
+ throw new IllegalStateException("We are attempting to do bluetooth stuff, but the service is not ready.");
+ }
+
+ if (!service.isEnabled()) {
+ service.enableSwapping();
+ }
+
+ new BluetoothServer(this,getFilesDir()).start();
+ showBluetoothDeviceList();
+ }
+
+
class PrepareInitialSwapRepo extends PrepareSwapRepo {
public PrepareInitialSwapRepo() {
super(new HashSet<>(Arrays.asList(new String[] { "org.fdroid.fdroid" })));