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" })));