Merge branch 'swap/bluetooth' into swap-demolition-material-mockups

This commit is contained in:
Peter Serwylo 2015-06-17 00:21:03 +10:00
commit b0b285bc1f
32 changed files with 1724 additions and 22 deletions

View File

@ -44,6 +44,7 @@
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" /> <uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" /> <uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="18" /> android:maxSdkVersion="18" />

View File

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2006 The Android Open Source Project
Licensed 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.
-->
<!--
This file was modified for F-Droid to include an additional text item beyond
simple_list_item_3. Thought we may as well make it as much as possible the
same as the original, and so should essentially build on the original one
from the Android SDK.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="?android:attr/listPreferredItemHeight"
android:orientation="vertical"
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
>
<TextView android:id="@android:id/text1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dip"
android:textAppearance="?android:attr/textAppearanceListItem"
/>
<TextView android:id="@android:id/text2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceSmall"
/>
<TextView android:id="@+id/text3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceSmall"
/>
</LinearLayout>

View File

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2006 The Android Open Source Project
Licensed 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.
-->
<!--
This file was modified for F-Droid to include an additional text item beyond
simple_list_item_3. Thought we may as well make it as much as possible the
same as the original, and so should essentially build on the original one
from the Android SDK.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="?android:attr/listPreferredItemHeight"
android:orientation="vertical"
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
>
<TextView android:id="@android:id/text1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dip"
android:textAppearance="?android:attr/textAppearanceLarge"
/>
<TextView android:id="@android:id/text2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceSmall"
/>
<TextView android:id="@+id/text3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceSmall"
/>
</LinearLayout>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<org.fdroid.fdroid.views.swap.BluetoothDeviceListView
xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/swap_blue">
</org.fdroid.fdroid.views.swap.BluetoothDeviceListView>

View File

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:gravity="center"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/device_name_prefix"
android:text="@string/swap_bluetooth_your_device"
android:gravity="center_horizontal"
style="@style/SwapTheme.BluetoothDeviceList.Heading"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:textSize="24sp"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/device_name"
tools:text="Phone v2.0"
android:gravity="center_horizontal"
style="@style/SwapTheme.BluetoothDeviceList.Heading"
android:paddingBottom="10dp"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/device_address"
tools:text="(01:02:03:ab:cd:ef)"
android:gravity="center_horizontal"
style="@style/SwapTheme.BluetoothDeviceList.Heading"
android:paddingBottom="20dp"
android:textSize="24sp"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/swap_bluetooth_select_or_scan"
android:gravity="center_horizontal"
style="@style/SwapTheme.BluetoothDeviceList.Text"
android:paddingLeft="20dp"
android:paddingRight="20dp"
android:paddingBottom="10dp"/>
<android.support.v4.widget.ContentLoadingProgressBar
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/loading_indicator"
android:paddingLeft="5dp"
android:paddingRight="5dp"
android:visibility="gone"
android:indeterminate="true"
style="?android:attr/android:progressBarStyleHorizontal"/>
</LinearLayout>

View File

@ -26,12 +26,12 @@
android:layout_below="@+id/text_description" android:layout_below="@+id/text_description"
android:layout_centerHorizontal="true" /> android:layout_centerHorizontal="true" />
<!--
<Button style="@style/SwapTheme.Wizard.OptionButton" <Button style="@style/SwapTheme.Wizard.OptionButton"
android:id="@+id/btn_bluetooth" android:id="@+id/btn_bluetooth"
android:text="@string/swap_use_bluetooth" android:text="@string/swap_use_bluetooth"
android:layout_alignParentBottom="true" /> android:layout_alignParentBottom="true" />
<!--
<Button style="@style/SwapTheme.Wizard.OptionButton" <Button style="@style/SwapTheme.Wizard.OptionButton"
android:text="@string/swap_wifi_help" android:text="@string/swap_wifi_help"
android:layout_above="@id/btn_bluetooth" android:layout_above="@id/btn_bluetooth"

View File

@ -0,0 +1,14 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android" >
<item
android:id="@+id/action_scan"
android:title="Scan for devices"
android:titleCondensed="Scan"/>
<item
android:id="@+id/action_cancel"
android:title="Cancel scan"
android:titleCondensed="@string/cancel"
android:visible="false"/>
</menu>

View File

@ -331,6 +331,11 @@
<string name="wifi_warning_public">May work</string> <string name="wifi_warning_public">May work</string>
<string name="wifi_warning_private">Promising</string> <string name="wifi_warning_private">Promising</string>
<string name="wifi_warning_personal">Best bet</string> <string name="wifi_warning_personal">Best bet</string>
<string name="swap_bluetooth_your_device">Your device is</string>
<string name="swap_bluetooth_select_or_scan">Select from devices below, or press \"Scan\" from the menu to find more devices.</string>
<string name="swap_bluetooth_bonded_device">Bonded</string>
<string name="swap_bluetooth_bonding_device">Currently bonding...</string>
<string name="swap_bluetooth_unknown_device">Unknown device</string>
<string name="swap_choose_apps">Choose Apps</string> <string name="swap_choose_apps">Choose Apps</string>
<string name="swap_scan_qr">Scan QR Code</string> <string name="swap_scan_qr">Scan QR Code</string>
</resources> </resources>

View File

@ -43,6 +43,22 @@
<item name="android:background">@color/white</item> <item name="android:background">@color/white</item>
</style> </style>
<style name="SwapTheme.StartSwap.Text" parent="@style/SwapTheme.StartSwap">
</style>
<style name="SwapTheme.BluetoothDeviceList" parent="@style/SwapTheme.Wizard">
</style>
<style name="SwapTheme.BluetoothDeviceList.ListItem" parent="AppThemeDark">
</style>
<style name="SwapTheme.BluetoothDeviceList.Text" parent="@style/SwapTheme.BluetoothDeviceList">
</style>
<style name="SwapTheme.BluetoothDeviceList.Heading" parent="@style/SwapTheme.BluetoothDeviceList.Text">
<item name="android:textSize">32.5dp</item> <!-- 58px * 96dpi / 160dpi = 32.5sp -->
</style>
<style name="SwapTheme.AppList" parent="AppThemeLight"> <style name="SwapTheme.AppList" parent="AppThemeLight">
</style> </style>

View File

@ -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.
* <p>
* 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 <code>BoundedInputStream</code> 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 <code>BoundedInputStream</code> 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 <code>read()</code> 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 <code>read(byte[])</code> 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 <code>read(byte[], int, int)</code> 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 <code>skip(long)</code> 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 <code>toString()</code> method.
* @return the delegate's <code>toString()</code>
*/
@Override
public String toString() {
return in.toString();
}
/**
* Invokes the delegate's <code>close()</code> 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 <code>reset()</code> method.
* @throws IOException if an I/O error occurs
*/
@Override
public synchronized void reset() throws IOException {
in.reset();
pos = mark;
}
/**
* Invokes the delegate's <code>mark(int)</code> method.
* @param readlimit read ahead limit
*/
@Override
public synchronized void mark(int readlimit) {
in.mark(readlimit);
mark = pos;
}
/**
* Invokes the delegate's <code>markSupported()</code> 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 <code>close()</code> 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 <code>close()</code>
* method of the underlying stream or
* {@code false} if it does not.
*/
public void setPropagateClose(boolean propagateClose) {
this.propagateClose = propagateClose;
}
}

View File

@ -69,6 +69,7 @@ public class SwapManager {
public static final int STEP_JOIN_WIFI = 3; public static final int STEP_JOIN_WIFI = 3;
public static final int STEP_SHOW_NFC = 4; public static final int STEP_SHOW_NFC = 4;
public static final int STEP_WIFI_QR = 5; public static final int STEP_WIFI_QR = 5;
public static final int STEP_BLUETOOTH = 6;
private @SwapStep int step = STEP_INTRO; private @SwapStep int step = STEP_INTRO;
@ -96,7 +97,7 @@ public class SwapManager {
* be passed rather than in integer, however that is harder to persist on disk than an int. * be passed rather than in integer, however that is harder to persist on disk than an int.
* This is the same as, e.g. {@link Context#getSystemService(String)} * 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}) @IntDef({STEP_INTRO, STEP_SELECT_APPS, STEP_JOIN_WIFI, STEP_SHOW_NFC, STEP_WIFI_QR, STEP_BLUETOOTH})
@Retention(RetentionPolicy.SOURCE) @Retention(RetentionPolicy.SOURCE)
public @interface SwapStep {} public @interface SwapStep {}

View File

@ -39,6 +39,8 @@ public class WebServerType implements SwapType {
public void run() { public void run() {
localHttpd = new LocalHTTPD( localHttpd = new LocalHTTPD(
context, context,
FDroidApp.ipAddressString,
FDroidApp.port,
context.getFilesDir(), context.getFilesDir(),
Preferences.get().isLocalRepoHttpsEnabled()); Preferences.get().isLocalRepoHttpsEnabled());

View File

@ -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())
);
}
}

View File

@ -127,7 +127,8 @@ public abstract class Downloader {
// we were interrupted before proceeding to the download. // we were interrupted before proceeding to the download.
throwExceptionIfInterrupted(); throwExceptionIfInterrupted();
copyInputToOutputStream(getInputStream()); // TODO: Check side effects of changing this second getInputStream() to input.
copyInputToOutputStream(input);
} finally { } finally {
Utils.closeQuietly(outputStream); Utils.closeQuietly(outputStream);
Utils.closeQuietly(input); Utils.closeQuietly(input);
@ -173,12 +174,13 @@ public abstract class Downloader {
int count = input.read(buffer); int count = input.read(buffer);
throwExceptionIfInterrupted(); throwExceptionIfInterrupted();
bytesRead += count;
sendProgress(bytesRead, totalBytes);
if (count == -1) { if (count == -1) {
Log.d(TAG, "Finished downloading from stream"); Log.d(TAG, "Finished downloading from stream");
break; break;
} }
bytesRead += count;
sendProgress(bytesRead, totalBytes);
outputStream.write(buffer, 0, count); outputStream.write(buffer, 0, count);
} }
outputStream.flush(); outputStream.flush();

View File

@ -1,27 +1,38 @@
package org.fdroid.fdroid.net; package org.fdroid.fdroid.net;
import android.content.Context; import android.content.Context;
import android.net.Uri;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
public class DownloaderFactory { public class DownloaderFactory {
public static Downloader create(String url, Context context) public static Downloader create(String url, Context context) throws IOException {
throws IOException { Uri uri = Uri.parse(url);
if (isOnionAddress(url)) { if (isBluetoothAddress(uri)) {
return new BluetoothDownloader(null, uri.getPath(), context);
} else if (isOnionAddress(url)) {
return new TorHttpDownloader(url, context); 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)) {
return new TorHttpDownloader(url, destFile);
} }
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) { private static boolean isOnionAddress(String url) {
return url.matches("^[a-zA-Z0-9]+://[^/]+\\.onion/.*"); return url.matches("^[a-zA-Z0-9]+://[^/]+\\.onion/.*");

View File

@ -2,9 +2,9 @@ package org.fdroid.fdroid.net;
import android.content.Context; import android.content.Context;
import android.util.Log; import android.util.Log;
import org.fdroid.fdroid.Preferences; import org.fdroid.fdroid.Preferences;
import javax.net.ssl.SSLHandshakeException;
import java.io.File; import java.io.File;
import java.io.FileNotFoundException; import java.io.FileNotFoundException;
import java.io.IOException; import java.io.IOException;
@ -16,8 +16,6 @@ import java.net.Proxy;
import java.net.SocketAddress; import java.net.SocketAddress;
import java.net.URL; import java.net.URL;
import javax.net.ssl.SSLHandshakeException;
public class HttpDownloader extends Downloader { public class HttpDownloader extends Downloader {
private static final String TAG = "HttpDownloader"; private static final String TAG = "HttpDownloader";
@ -26,6 +24,7 @@ public class HttpDownloader extends Downloader {
protected HttpURLConnection connection; protected HttpURLConnection connection;
private int statusCode = -1; private int statusCode = -1;
private boolean onlyStream = false;
// The context is required for opening the file to write to. // The context is required for opening the file to write to.
HttpDownloader(String source, File destFile) HttpDownloader(String source, File destFile)
@ -39,11 +38,22 @@ public class HttpDownloader extends Downloader {
* you are done*. * you are done*.
* @see org.fdroid.fdroid.net.Downloader#getFile() * @see org.fdroid.fdroid.net.Downloader#getFile()
*/ */
HttpDownloader(String source, Context ctx) throws IOException { public HttpDownloader(String source, Context ctx) throws IOException {
super(ctx); super(ctx);
sourceUrl = new URL(source); 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 @Override
public InputStream getInputStream() throws IOException { public InputStream getInputStream() throws IOException {
setupConnection(); setupConnection();
@ -134,4 +144,8 @@ public class HttpDownloader extends Downloader {
return this.statusCode != 304; return this.statusCode != 304;
} }
public int getStatusCode() {
return statusCode;
}
} }

View File

@ -36,8 +36,8 @@ public class LocalHTTPD extends NanoHTTPD {
private final File webRoot; private final File webRoot;
private final boolean logRequests; private final boolean logRequests;
public LocalHTTPD(Context context, File webRoot, boolean useHttps) { public LocalHTTPD(Context context, String hostname, int port, File webRoot, boolean useHttps) {
super(FDroidApp.ipAddressString, FDroidApp.port); super(hostname, port);
this.logRequests = false; this.logRequests = false;
this.webRoot = webRoot; this.webRoot = webRoot;
this.context = context.getApplicationContext(); this.context = context.getApplicationContext();

View File

@ -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.createRfcommSocketToServiceRecord(BluetoothConstants.fdroidUuid());
BluetoothConnection connection = new BluetoothConnection(socket);
connection.open();
return connection;
}
}

View File

@ -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();
}
}

View File

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

View File

@ -0,0 +1,152 @@
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 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.IOException;
import java.util.ArrayList;
import java.util.List;
/**
* 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<Connection> clients = new ArrayList<>();
private final Context context;
public BluetoothServer(Context context) {
this.context = context.getApplicationContext();
}
public void close() {
for (Connection connection : clients) {
connection.interrupt();
}
if (serverSocket != null) {
Utils.closeQuietly(serverSocket);
}
}
@Override
public void run() {
BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
try {
serverSocket = adapter.listenUsingRfcommWithServiceRecord("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);
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;
public Connection(Context context, BluetoothSocket socket) {
this.context = context.getApplicationContext();
this.socket = socket;
}
@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.");
try {
HttpDownloader downloader = new HttpDownloader("http://127.0.0.1:" + ( FDroidApp.port + 1 ) + "/" + request.getPath(), context);
Response.Builder builder;
if (request.getMethod().equals(Request.Methods.HEAD)) {
builder = new Response.Builder();
} else {
builder = new Response.Builder(downloader.getInputStream());
}
// 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(downloader.getStatusCode())
.setFileSize(downloader.totalDownloadSize())
.build();
} catch (IOException 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);
}
}
}
}
}

View File

@ -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;
}
}

View File

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

View File

@ -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<String, String> 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<String, String> 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<String, String> readHeaders() throws IOException {
Map<String, String> 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;
}
}

View File

@ -0,0 +1,161 @@
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.Writer;
import java.util.HashMap;
import java.util.Map;
public class Response {
private static final String TAG = "bluetooth.Response";
private int statusCode;
private Map<String, String> headers;
private final InputStream contentStream;
public Response(int statusCode, Map<String, String> 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<String, String> headers, InputStream contentStream) {
this.statusCode = statusCode;
this.headers = headers;
this.contentStream = contentStream;
}
public int getStatusCode() {
return statusCode;
}
public int getFileSize() {
if (headers != null) {
for (Map.Entry<String, String> 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<String, String> 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<String, String> 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<String, String> 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);
}
}
}

View File

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

View File

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

View File

@ -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;
}
}
}
}

View File

@ -21,9 +21,18 @@ public abstract class ThemeableListFragment extends ListFragment {
return 0; 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) { if (getHeaderLayout() > 0) {
return inflater.inflate(getHeaderLayout(), null, false); if (headerView == null) {
headerView = inflater.inflate(getHeaderLayout(), null, false);
}
return headerView;
} else { } else {
return null; return null;
} }

View File

@ -0,0 +1,346 @@
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 org.fdroid.fdroid.R;
import org.fdroid.fdroid.localrepo.SwapManager;
import org.fdroid.fdroid.net.BluetoothDownloader;
import org.fdroid.fdroid.net.bluetooth.BluetoothClient;
import org.fdroid.fdroid.net.bluetooth.BluetoothConnection;
import org.fdroid.fdroid.views.fragments.ThemeableListFragment;
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;
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 SwapManager.STEP_BLUETOOTH;
}
@Override
public int getPreviousStep() {
return SwapManager.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);
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());
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...");
cancelMenuItem.setVisible(true);
scanMenuItem.setVisible(false);
final ContentLoadingProgressBar loadingBar = getLoadingIndicator();
loadingBar.show();
final BluetoothAdapter bluetooth = BluetoothAdapter.getDefaultAdapter();
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());
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));
if (!bluetooth.startDiscovery()) {
// TODO: Discovery did not start for some reason :(
Log.e(TAG, "Could not start bluetooth discovery, but am not sure why :(");
}
}
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<BluetoothDevice> {
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<BluetoothDevice> objects) {
super(context, resource, objects);
}
public Adapter(Context context, int resource, int textViewResourceId, List<BluetoothDevice> 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);
}
}
}
}

View File

@ -17,6 +17,7 @@ import android.view.Menu;
import android.view.MenuInflater; import android.view.MenuInflater;
import android.view.MenuItem; import android.view.MenuItem;
import android.view.View; import android.view.View;
import android.widget.Button;
import android.widget.ImageView; import android.widget.ImageView;
import android.widget.RelativeLayout; import android.widget.RelativeLayout;
import android.widget.TextView; import android.widget.TextView;
@ -72,6 +73,15 @@ public class JoinWifiView extends RelativeLayout implements SwapWorkflowActivity
new IntentFilter(WifiStateChangeService.BROADCAST) 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. // TODO: Listen for "Connecting..." state and reflect that in the view too.

View File

@ -2,6 +2,7 @@ package org.fdroid.fdroid.views.swap;
import android.app.Activity; import android.app.Activity;
import android.app.ProgressDialog; import android.app.ProgressDialog;
import android.bluetooth.BluetoothAdapter;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.net.Uri; import android.net.Uri;
@ -13,6 +14,7 @@ import android.support.annotation.NonNull;
import android.support.v4.app.FragmentActivity; import android.support.v4.app.FragmentActivity;
import android.support.v7.app.ActionBarActivity; import android.support.v7.app.ActionBarActivity;
import android.support.v7.widget.Toolbar; import android.support.v7.widget.Toolbar;
import android.util.Log;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.Menu; import android.view.Menu;
import android.view.MenuInflater; import android.view.MenuInflater;
@ -32,6 +34,7 @@ import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.data.NewRepoConfig; import org.fdroid.fdroid.data.NewRepoConfig;
import org.fdroid.fdroid.localrepo.LocalRepoManager; import org.fdroid.fdroid.localrepo.LocalRepoManager;
import org.fdroid.fdroid.localrepo.SwapManager; import org.fdroid.fdroid.localrepo.SwapManager;
import org.fdroid.fdroid.net.bluetooth.BluetoothServer;
import java.util.Set; import java.util.Set;
@ -59,7 +62,11 @@ public class SwapWorkflowActivity extends ActionBarActivity {
String getToolbarTitle(); String getToolbarTitle();
} }
private static final String TAG = "SwapWorkflowActivity";
private static final int CONNECT_TO_SWAP = 1; 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 Toolbar toolbar;
private SwapManager state; private SwapManager state;
@ -198,6 +205,10 @@ public class SwapWorkflowActivity extends ActionBarActivity {
inflateInnerView(R.layout.swap_join_wifi); inflateInnerView(R.layout.swap_join_wifi);
} }
private void showBluetoothDeviceList() {
inflateInnerView(R.layout.swap_bluetooth_devices);
}
public void onJoinWifiComplete() { public void onJoinWifiComplete() {
ensureLocalRepoRunning(); ensureLocalRepoRunning();
if (!attemptToShowNfc()) { if (!attemptToShowNfc()) {
@ -248,9 +259,81 @@ public class SwapWorkflowActivity extends ActionBarActivity {
} }
} else if (requestCode == CONNECT_TO_SWAP && resultCode == Activity.RESULT_OK) { } else if (requestCode == CONNECT_TO_SWAP && resultCode == Activity.RESULT_OK) {
finish(); 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();
// TODO: May be null (e.g. on an emulator).
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);
} else {
Log.d(TAG, "Bluetooth is already discoverable, so lets start the Bluetooth server.");
startBluetoothServer();
}
}
private void startBluetoothServer() {
Log.d(TAG, "Starting bluetooth server.");
if (!state.isEnabled()) {
state.enableSwapping();
}
new BluetoothServer(this).start();
showBluetoothDeviceList();
}
class UpdateAsyncTask extends AsyncTask<Void, String, Void> { class UpdateAsyncTask extends AsyncTask<Void, String, Void> {
@SuppressWarnings("UnusedDeclaration") @SuppressWarnings("UnusedDeclaration")