() {
+ public FDroidServiceInfo createFromParcel(Parcel source) {
+ return new FDroidServiceInfo(source);
+ }
+
+ public FDroidServiceInfo[] newArray(int size) {
+ return new FDroidServiceInfo[size];
+ }
+ };
+}
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/FDroid.java b/F-Droid/src/org/fdroid/fdroid/FDroid.java
index 9a90e023f..80346c758 100644
--- a/F-Droid/src/org/fdroid/fdroid/FDroid.java
+++ b/F-Droid/src/org/fdroid/fdroid/FDroid.java
@@ -48,8 +48,7 @@ import org.fdroid.fdroid.data.NewRepoConfig;
import org.fdroid.fdroid.installer.InstallIntoSystemDialogActivity;
import org.fdroid.fdroid.views.AppListFragmentPagerAdapter;
import org.fdroid.fdroid.views.ManageReposActivity;
-import org.fdroid.fdroid.views.swap.ConnectSwapActivity;
-import org.fdroid.fdroid.views.swap.SwapActivity;
+import org.fdroid.fdroid.views.swap.SwapWorkflowActivity;
public class FDroid extends ActionBarActivity {
@@ -208,7 +207,8 @@ public class FDroid extends ActionBarActivity {
if (parser.isValidRepo()) {
intent.putExtra("handled", true);
if (parser.isFromSwap()) {
- Intent confirmIntent = new Intent(this, ConnectSwapActivity.class);
+ Intent confirmIntent = new Intent(this, SwapWorkflowActivity.class);
+ confirmIntent.putExtra(SwapWorkflowActivity.EXTRA_CONFIRM, true);
confirmIntent.setData(intent.getData());
startActivityForResult(confirmIntent, REQUEST_SWAP);
} else {
@@ -256,7 +256,7 @@ public class FDroid extends ActionBarActivity {
return true;
case R.id.action_swap:
- startActivity(new Intent(this, SwapActivity.class));
+ startActivity(new Intent(this, SwapWorkflowActivity.class));
return true;
case R.id.action_search:
diff --git a/F-Droid/src/org/fdroid/fdroid/FDroidApp.java b/F-Droid/src/org/fdroid/fdroid/FDroidApp.java
index ab607f37a..3209bba07 100644
--- a/F-Droid/src/org/fdroid/fdroid/FDroidApp.java
+++ b/F-Droid/src/org/fdroid/fdroid/FDroidApp.java
@@ -23,10 +23,8 @@ import android.app.Activity;
import android.app.Application;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothManager;
-import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
-import android.content.ServiceConnection;
import android.content.SharedPreferences;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
@@ -34,11 +32,8 @@ import android.content.pm.ResolveInfo;
import android.content.res.Configuration;
import android.net.Uri;
import android.os.Build;
-import android.os.IBinder;
-import android.os.Message;
-import android.os.Messenger;
-import android.os.RemoteException;
import android.preference.PreferenceManager;
+import android.text.TextUtils;
import android.util.Log;
import android.widget.Toast;
@@ -53,14 +48,17 @@ import org.fdroid.fdroid.compat.PRNGFixes;
import org.fdroid.fdroid.data.AppProvider;
import org.fdroid.fdroid.data.InstalledAppCacheUpdater;
import org.fdroid.fdroid.data.Repo;
-import org.fdroid.fdroid.localrepo.LocalRepoService;
import org.fdroid.fdroid.net.IconDownloader;
import org.fdroid.fdroid.net.WifiStateChangeService;
import java.io.File;
+import java.net.URL;
+import java.net.URLStreamHandler;
+import java.net.URLStreamHandlerFactory;
import java.security.Security;
import java.util.Locale;
-import java.util.Set;
+
+import sun.net.www.protocol.bluetooth.Handler;
public class FDroidApp extends Application {
@@ -72,13 +70,11 @@ public class FDroidApp extends Application {
public static String ssid;
public static String bssid;
public static final Repo repo = new Repo();
- public static Set selectedApps = null; // init in SelectLocalAppsFragment
// Leaving the fully qualified class name here to help clarify the difference between spongy/bouncy castle.
private static final org.spongycastle.jce.provider.BouncyCastleProvider spongyCastleProvider;
- private static Messenger localRepoServiceMessenger = null;
- private static boolean localRepoServiceIsBound = false;
+ @SuppressWarnings("unused")
BluetoothAdapter bluetoothAdapter = null;
static {
@@ -187,6 +183,16 @@ public class FDroidApp extends Application {
}
});
+ // This is added so that the bluetooth:// scheme we use for URLs the BluetoothDownloader
+ // understands is not treated as invalid by the java.net.URL class. The actual Handler does
+ // nothing, but its presence is enough.
+ URL.setURLStreamHandlerFactory(new URLStreamHandlerFactory() {
+ @Override
+ public URLStreamHandler createURLStreamHandler(String protocol) {
+ return TextUtils.equals(protocol, "bluetooth") ? new Handler() : null;
+ }
+ });
+
// Clear cached apk files. We used to just remove them after they'd
// been installed, but this causes problems for proprietary gapps
// users since the introduction of verification (on pre-4.2 Android),
@@ -262,7 +268,7 @@ public class FDroidApp extends Application {
return ((BluetoothManager) getSystemService(BLUETOOTH_SERVICE)).getAdapter();
}
- void sendViaBluetooth(Activity activity, int resultCode, String packageName) {
+ public void sendViaBluetooth(Activity activity, int resultCode, String packageName) {
if (resultCode == Activity.RESULT_CANCELED)
return;
String bluetoothPackageName = null;
@@ -305,53 +311,4 @@ public class FDroidApp extends Application {
}
}
}
-
- private static final ServiceConnection serviceConnection = new ServiceConnection() {
- @Override
- public void onServiceConnected(ComponentName className, IBinder service) {
- localRepoServiceMessenger = new Messenger(service);
- }
-
- @Override
- public void onServiceDisconnected(ComponentName className) {
- localRepoServiceMessenger = null;
- }
- };
-
- public static void startLocalRepoService(Context context) {
- if (!localRepoServiceIsBound) {
- Context app = context.getApplicationContext();
- Intent service = new Intent(app, LocalRepoService.class);
- localRepoServiceIsBound = app.bindService(service, serviceConnection, Context.BIND_AUTO_CREATE);
- if (localRepoServiceIsBound)
- app.startService(service);
- }
- }
-
- public static void stopLocalRepoService(Context context) {
- Context app = context.getApplicationContext();
- if (localRepoServiceIsBound) {
- app.unbindService(serviceConnection);
- localRepoServiceIsBound = false;
- }
- app.stopService(new Intent(app, LocalRepoService.class));
- }
-
- /**
- * Handles checking if the {@link LocalRepoService} is running, and only restarts it if it was running.
- */
- public static void restartLocalRepoServiceIfRunning() {
- if (localRepoServiceMessenger != null) {
- try {
- Message msg = Message.obtain(null, LocalRepoService.RESTART, LocalRepoService.RESTART, 0);
- localRepoServiceMessenger.send(msg);
- } catch (RemoteException e) {
- Log.e(TAG, "Could not reset local repo service", e);
- }
- }
- }
-
- public static boolean isLocalRepoServiceRunning() {
- return localRepoServiceIsBound;
- }
}
diff --git a/F-Droid/src/org/fdroid/fdroid/Preferences.java b/F-Droid/src/org/fdroid/fdroid/Preferences.java
index ae21febbb..1dd04f0a3 100644
--- a/F-Droid/src/org/fdroid/fdroid/Preferences.java
+++ b/F-Droid/src/org/fdroid/fdroid/Preferences.java
@@ -51,7 +51,6 @@ public class Preferences implements SharedPreferences.OnSharedPreferenceChangeLi
public static final String PREF_UPD_LAST = "lastUpdateCheck";
public static final String PREF_SYSTEM_INSTALLER = "systemInstaller";
public static final String PREF_UNINSTALL_SYSTEM_APP = "uninstallSystemApp";
- public static final String PREF_LOCAL_REPO_BONJOUR = "localRepoBonjour";
public static final String PREF_LOCAL_REPO_NAME = "localRepoName";
public static final String PREF_LOCAL_REPO_HTTPS = "localRepoHttps";
public static final String PREF_LANGUAGE = "language";
@@ -87,7 +86,6 @@ public class Preferences implements SharedPreferences.OnSharedPreferenceChangeLi
private final List compactLayoutListeners = new ArrayList<>();
private final List filterAppsRequiringRootListeners = new ArrayList<>();
private final List updateHistoryListeners = new ArrayList<>();
- private final List localRepoBonjourListeners = new ArrayList<>();
private final List localRepoNameListeners = new ArrayList<>();
private final List localRepoHttpsListeners = new ArrayList<>();
@@ -127,10 +125,6 @@ public class Preferences implements SharedPreferences.OnSharedPreferenceChangeLi
preferences.edit().putBoolean(PREF_POST_SYSTEM_INSTALL, postInstall).commit();
}
- public boolean isLocalRepoBonjourEnabled() {
- return preferences.getBoolean(PREF_LOCAL_REPO_BONJOUR, DEFAULT_LOCAL_REPO_BONJOUR);
- }
-
public boolean shouldCacheApks() {
return preferences.getBoolean(PREF_CACHE_APK, DEFAULT_CACHE_APK);
}
@@ -262,11 +256,6 @@ public class Preferences implements SharedPreferences.OnSharedPreferenceChangeLi
listener.onPreferenceChange();
}
break;
- case PREF_LOCAL_REPO_BONJOUR:
- for (ChangeListener listener : localRepoBonjourListeners) {
- listener.onPreferenceChange();
- }
- break;
case PREF_LOCAL_REPO_NAME:
for (ChangeListener listener : localRepoNameListeners) {
listener.onPreferenceChange();
@@ -288,14 +277,6 @@ public class Preferences implements SharedPreferences.OnSharedPreferenceChangeLi
updateHistoryListeners.remove(listener);
}
- public void registerLocalRepoBonjourListeners(ChangeListener listener) {
- localRepoBonjourListeners.add(listener);
- }
-
- public void unregisterLocalRepoBonjourListeners(ChangeListener listener) {
- localRepoBonjourListeners.remove(listener);
- }
-
public void registerLocalRepoNameListeners(ChangeListener listener) {
localRepoNameListeners.add(listener);
}
diff --git a/F-Droid/src/org/fdroid/fdroid/RepoUpdater.java b/F-Droid/src/org/fdroid/fdroid/RepoUpdater.java
index cfc6cd495..4ce038e6c 100644
--- a/F-Droid/src/org/fdroid/fdroid/RepoUpdater.java
+++ b/F-Droid/src/org/fdroid/fdroid/RepoUpdater.java
@@ -70,7 +70,7 @@ public class RepoUpdater {
public List getApks() { return apks; }
- protected URL getIndexAddress() throws MalformedURLException {
+ private URL getIndexAddress() throws MalformedURLException {
String urlString = repo.address + "/index.jar";
try {
String versionName = context.getPackageManager().getPackageInfo(context.getPackageName(), 0).versionName;
diff --git a/F-Droid/src/org/fdroid/fdroid/UpdateService.java b/F-Droid/src/org/fdroid/fdroid/UpdateService.java
index a29535023..b07573151 100644
--- a/F-Droid/src/org/fdroid/fdroid/UpdateService.java
+++ b/F-Droid/src/org/fdroid/fdroid/UpdateService.java
@@ -160,6 +160,13 @@ public class UpdateService extends IntentService implements ProgressListener {
.setOngoing(true)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setContentTitle(getString(R.string.update_notification_title));
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) {
+ Intent intent = new Intent(this, FDroid.class);
+ // TODO: Is this the correct FLAG?
+ notificationBuilder.setContentIntent(PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT));
+ }
+
notificationManager.notify(NOTIFY_ID_UPDATING, notificationBuilder.build());
}
diff --git a/F-Droid/src/org/fdroid/fdroid/Utils.java b/F-Droid/src/org/fdroid/fdroid/Utils.java
index e2bc69ab3..e2b2ec3ff 100644
--- a/F-Droid/src/org/fdroid/fdroid/Utils.java
+++ b/F-Droid/src/org/fdroid/fdroid/Utils.java
@@ -22,6 +22,7 @@ import android.content.Context;
import android.content.pm.PackageManager;
import android.content.res.AssetManager;
import android.content.res.XmlResourceParser;
+import android.graphics.Bitmap;
import android.net.Uri;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
@@ -31,9 +32,13 @@ import android.text.TextUtils;
import android.util.DisplayMetrics;
import android.util.Log;
+import com.nostra13.universalimageloader.core.DisplayImageOptions;
+import com.nostra13.universalimageloader.core.assist.ImageScaleType;
+import com.nostra13.universalimageloader.core.display.FadeInBitmapDisplayer;
import com.nostra13.universalimageloader.utils.StorageUtils;
import org.fdroid.fdroid.compat.FileCompat;
+import org.fdroid.fdroid.data.Apk;
import org.fdroid.fdroid.data.Repo;
import org.fdroid.fdroid.data.SanitizedFile;
import org.xml.sax.XMLReader;
@@ -403,6 +408,14 @@ public final class Utils {
return new Locale(languageTag);
}
+ public static String getApkUrl(Apk apk) {
+ return getApkUrl(apk.repoAddress, apk);
+ }
+
+ public static String getApkUrl(String repoAddress, Apk apk) {
+ return repoAddress + "/" + apk.apkName.replace(" ", "%20");
+ }
+
public static class CommaSeparatedList implements Iterable {
private final String value;
@@ -472,6 +485,17 @@ public final class Utils {
}
}
+ public static DisplayImageOptions.Builder getImageLoadingOptions() {
+ return new DisplayImageOptions.Builder()
+ .cacheInMemory(true)
+ .cacheOnDisk(true)
+ .imageScaleType(ImageScaleType.NONE)
+ .showImageOnLoading(R.drawable.ic_repo_app_default)
+ .showImageForEmptyUri(R.drawable.ic_repo_app_default)
+ .displayer(new FadeInBitmapDisplayer(200, true, true, false))
+ .bitmapConfig(Bitmap.Config.RGB_565);
+ }
+
// this is all new stuff being added
public static String hashBytes(byte[] input, String algo) {
try {
diff --git a/F-Droid/src/org/fdroid/fdroid/data/AppProvider.java b/F-Droid/src/org/fdroid/fdroid/data/AppProvider.java
index 267e50c3a..152a2929f 100644
--- a/F-Droid/src/org/fdroid/fdroid/data/AppProvider.java
+++ b/F-Droid/src/org/fdroid/fdroid/data/AppProvider.java
@@ -213,7 +213,7 @@ public class AppProvider extends FDroidProvider {
}
String[] ALL = {
- IS_COMPATIBLE, APP_ID, NAME, SUMMARY, ICON, DESCRIPTION,
+ _ID, IS_COMPATIBLE, APP_ID, NAME, SUMMARY, ICON, DESCRIPTION,
LICENSE, WEB_URL, TRACKER_URL, SOURCE_URL, CHANGELOG_URL, DONATE_URL,
BITCOIN_ADDR, LITECOIN_ADDR, DOGECOIN_ADDR, FLATTR_ID,
UPSTREAM_VERSION, UPSTREAM_VERSION_CODE, ADDED, LAST_UPDATED,
@@ -416,6 +416,7 @@ public class AppProvider extends FDroidProvider {
private static final String PATH_INSTALLED = "installed";
private static final String PATH_CAN_UPDATE = "canUpdate";
private static final String PATH_SEARCH = "search";
+ private static final String PATH_SEARCH_REPO = "searchRepo";
private static final String PATH_NO_APKS = "noApks";
private static final String PATH_APPS = "apps";
private static final String PATH_RECENTLY_UPDATED = "recentlyUpdated";
@@ -436,6 +437,7 @@ public class AppProvider extends FDroidProvider {
private static final int IGNORED = CATEGORY + 1;
private static final int CALC_APP_DETAILS_FROM_INDEX = IGNORED + 1;
private static final int REPO = CALC_APP_DETAILS_FROM_INDEX + 1;
+ private static final int SEARCH_REPO = REPO + 1;
static {
matcher.addURI(getAuthority(), null, CODE_LIST);
@@ -445,6 +447,7 @@ public class AppProvider extends FDroidProvider {
matcher.addURI(getAuthority(), PATH_NEWLY_ADDED, NEWLY_ADDED);
matcher.addURI(getAuthority(), PATH_CATEGORY + "/*", CATEGORY);
matcher.addURI(getAuthority(), PATH_SEARCH + "/*", SEARCH);
+ matcher.addURI(getAuthority(), PATH_SEARCH_REPO + "/*/*", SEARCH_REPO);
matcher.addURI(getAuthority(), PATH_REPO + "/#", REPO);
matcher.addURI(getAuthority(), PATH_CAN_UPDATE, CAN_UPDATE);
matcher.addURI(getAuthority(), PATH_INSTALLED, INSTALLED);
@@ -528,6 +531,14 @@ public class AppProvider extends FDroidProvider {
.build();
}
+ public static Uri getSearchUri(Repo repo, String query) {
+ return getContentUri().buildUpon()
+ .appendPath(PATH_SEARCH_REPO)
+ .appendPath(repo.id + "")
+ .appendPath(query)
+ .build();
+ }
+
@Override
protected String getTableName() { return DBHelper.TABLE_APP; }
@@ -700,6 +711,11 @@ public class AppProvider extends FDroidProvider {
includeSwap = false;
break;
+ case SEARCH_REPO:
+ selection = selection.add(querySearch(uri.getPathSegments().get(2)));
+ selection = selection.add(queryRepo(Long.parseLong(uri.getPathSegments().get(1))));
+ break;
+
case NO_APKS:
selection = selection.add(queryNoApks());
break;
diff --git a/F-Droid/src/org/fdroid/fdroid/data/NewRepoConfig.java b/F-Droid/src/org/fdroid/fdroid/data/NewRepoConfig.java
index e2e77bee8..b9323f4c4 100644
--- a/F-Droid/src/org/fdroid/fdroid/data/NewRepoConfig.java
+++ b/F-Droid/src/org/fdroid/fdroid/data/NewRepoConfig.java
@@ -4,11 +4,11 @@ import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.text.TextUtils;
-import android.util.Log;
import org.fdroid.fdroid.R;
import org.fdroid.fdroid.Utils;
-import org.fdroid.fdroid.views.swap.ConnectSwapActivity;
+import org.fdroid.fdroid.localrepo.peers.WifiPeer;
+import org.fdroid.fdroid.views.swap.SwapWorkflowActivity;
import java.util.Arrays;
import java.util.Locale;
@@ -21,10 +21,8 @@ public class NewRepoConfig {
private boolean isValidRepo = false;
private String uriString;
- private Uri uri;
private String host;
private int port = -1;
- private String scheme;
private String fingerprint;
private String bssid;
private String ssid;
@@ -37,12 +35,12 @@ public class NewRepoConfig {
public NewRepoConfig(Context context, Intent intent) {
init(context, intent.getData());
- preventFurtherSwaps = intent.getBooleanExtra(ConnectSwapActivity.EXTRA_PREVENT_FURTHER_SWAP_REQUESTS, false);
+ preventFurtherSwaps = intent.getBooleanExtra(SwapWorkflowActivity.EXTRA_PREVENT_FURTHER_SWAP_REQUESTS, false);
}
private void init(Context context, Uri incomingUri) {
/* an URL from a click, NFC, QRCode scan, etc */
- uri = incomingUri;
+ Uri uri = incomingUri;
if (uri == null) {
isValidRepo = false;
return;
@@ -51,7 +49,7 @@ public class NewRepoConfig {
Utils.DebugLog(TAG, "Parsing incoming intent looking for repo: " + incomingUri);
// scheme and host should only ever be pure ASCII aka Locale.ENGLISH
- scheme = uri.getScheme();
+ String scheme = uri.getScheme();
host = uri.getHost();
port = uri.getPort();
if (TextUtils.isEmpty(scheme) || TextUtils.isEmpty(host)) {
@@ -110,14 +108,6 @@ public class NewRepoConfig {
public String getRepoUriString() { return uriString; }
- /**
- * This is the URI which was passed to the NewRepoConfig for parsing.
- * Not that it may be an fdroidrepo:// or http:// scheme, and it may also have
- * ssid, bssid, and perhaps other query parameters. If you want the actual repo
- * URL, then you will probably want {@link org.fdroid.fdroid.data.NewRepoConfig#getRepoUri()}.
- */
- public Uri getParsedUri() { return uri; }
-
public Uri getRepoUri() {
if (uriString == null) {
return null;
@@ -127,8 +117,6 @@ public class NewRepoConfig {
public String getHost() { return host; }
- public String getScheme() { return scheme; }
-
public String getFingerprint() { return fingerprint; }
public boolean isValidRepo() { return isValidRepo; }
@@ -137,14 +125,6 @@ public class NewRepoConfig {
public boolean preventFurtherSwaps() { return preventFurtherSwaps; }
- /*
- * The port starts out as 8888, but if there is a conflict, it will be
- * incremented until there is a free port found.
- */
- public boolean looksLikeLocalRepo() {
- return (port >= 8888 && host.matches("[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+"));
- }
-
public String getErrorMessage() { return errorMessage; }
/** Sanitize and format an incoming repo URI for function and readability */
@@ -159,4 +139,8 @@ public class NewRepoConfig {
.replace("fdroidrepo", "http") // proper repo address
.replace("/FDROID/REPO", "/fdroid/repo"); // for QR FDroid path
}
+
+ public WifiPeer toPeer() {
+ return new WifiPeer(this);
+ }
}
diff --git a/F-Droid/src/org/fdroid/fdroid/installer/Installer.java b/F-Droid/src/org/fdroid/fdroid/installer/Installer.java
index f3ff93788..db0fab9df 100644
--- a/F-Droid/src/org/fdroid/fdroid/installer/Installer.java
+++ b/F-Droid/src/org/fdroid/fdroid/installer/Installer.java
@@ -93,6 +93,10 @@ abstract public class Installer {
this.mCallback = callback;
}
+ public static Installer getActivityInstaller(Activity activity, InstallerCallback callback) {
+ return getActivityInstaller(activity, activity.getPackageManager(), callback);
+ }
+
/**
* Creates a new Installer for installing/deleting processes starting from
* an Activity
diff --git a/F-Droid/src/org/fdroid/fdroid/localrepo/LocalRepoManager.java b/F-Droid/src/org/fdroid/fdroid/localrepo/LocalRepoManager.java
index 692d4b470..d507358b4 100644
--- a/F-Droid/src/org/fdroid/fdroid/localrepo/LocalRepoManager.java
+++ b/F-Droid/src/org/fdroid/fdroid/localrepo/LocalRepoManager.java
@@ -55,6 +55,12 @@ import java.util.Map;
import java.util.jar.JarEntry;
import java.util.jar.JarOutputStream;
+/**
+ * The {@link SwapService} deals with managing the entire workflow from selecting apps to
+ * swap, to invoking this class to prepare the webroot, to enabling various communication protocols.
+ * This class deals specifically with the webroot side of things, ensuring we have a valid index.jar
+ * and the relevant .apk and icon files available.
+ */
public class LocalRepoManager {
private static final String TAG = "LocalRepoManager";
diff --git a/F-Droid/src/org/fdroid/fdroid/localrepo/LocalRepoService.java b/F-Droid/src/org/fdroid/fdroid/localrepo/LocalRepoService.java
deleted file mode 100644
index 6b43c3ecb..000000000
--- a/F-Droid/src/org/fdroid/fdroid/localrepo/LocalRepoService.java
+++ /dev/null
@@ -1,309 +0,0 @@
-package org.fdroid.fdroid.localrepo;
-
-import android.annotation.SuppressLint;
-import android.app.Notification;
-import android.app.NotificationManager;
-import android.app.PendingIntent;
-import android.app.Service;
-import android.content.BroadcastReceiver;
-import android.content.Context;
-import android.content.Intent;
-import android.content.IntentFilter;
-import android.os.AsyncTask;
-import android.os.Handler;
-import android.os.IBinder;
-import android.os.Looper;
-import android.os.Message;
-import android.os.Messenger;
-import android.support.v4.app.NotificationCompat;
-import android.support.v4.content.LocalBroadcastManager;
-import android.util.Log;
-
-import org.fdroid.fdroid.FDroidApp;
-import org.fdroid.fdroid.Preferences;
-import org.fdroid.fdroid.Preferences.ChangeListener;
-import org.fdroid.fdroid.R;
-import org.fdroid.fdroid.Utils;
-import org.fdroid.fdroid.net.LocalHTTPD;
-import org.fdroid.fdroid.net.WifiStateChangeService;
-import org.fdroid.fdroid.views.swap.SwapActivity;
-
-import java.io.IOException;
-import java.net.BindException;
-import java.util.HashMap;
-import java.util.Random;
-
-import javax.jmdns.JmDNS;
-import javax.jmdns.ServiceInfo;
-
-public class LocalRepoService extends Service {
- private static final String TAG = "LocalRepoService";
-
- public static final String STATE = "org.fdroid.fdroid.action.LOCAL_REPO_STATE";
- public static final String STARTED = "org.fdroid.fdroid.category.LOCAL_REPO_STARTED";
- public static final String STOPPED = "org.fdroid.fdroid.category.LOCAL_REPO_STOPPED";
-
- private NotificationManager notificationManager;
- // Unique Identification Number for the Notification.
- // We use it on Notification start, and to cancel it.
- private final int NOTIFICATION = R.string.local_repo_running;
-
- private Handler webServerThreadHandler = null;
- private LocalHTTPD localHttpd;
- private JmDNS jmdns;
- private ServiceInfo pairService;
-
- public static final int START = 1111111;
- public static final int STOP = 12345678;
- public static final int RESTART = 87654;
-
- final Messenger messenger = new Messenger(new StartStopHandler(this));
-
- /**
- * This is most likely going to be created on the UI thread, hence all of
- * the message handling will take place on a new thread to prevent blocking
- * the UI.
- */
- static class StartStopHandler extends Handler {
-
- private final LocalRepoService service;
-
- public StartStopHandler(LocalRepoService service) {
- this.service = service;
- }
-
- @Override
- public void handleMessage(final Message msg) {
- new Thread() {
- public void run() {
- switch (msg.arg1) {
- case START:
- service.startNetworkServices();
- break;
- case STOP:
- service.stopNetworkServices();
- break;
- case RESTART:
- service.stopNetworkServices();
- service.startNetworkServices();
- break;
- default:
- Log.e(TAG, "Unsupported msg.arg1 (" + msg.arg1 + "), ignored");
- break;
- }
- }
- }.start();
- }
- }
-
- private final BroadcastReceiver onWifiChange = new BroadcastReceiver() {
- @Override
- public void onReceive(Context context, Intent i) {
- stopNetworkServices();
- startNetworkServices();
- }
- };
-
- private ChangeListener localRepoBonjourChangeListener = new ChangeListener() {
- @Override
- public void onPreferenceChange() {
- if (localHttpd.isAlive())
- if (Preferences.get().isLocalRepoBonjourEnabled())
- registerMDNSService();
- else
- unregisterMDNSService();
- }
- };
-
- private final ChangeListener localRepoHttpsChangeListener = new ChangeListener() {
- @Override
- public void onPreferenceChange() {
- Utils.DebugLog(TAG, "onPreferenceChange");
- if (localHttpd.isAlive()) {
- new AsyncTask() {
- @Override
- protected Void doInBackground(Void... params) {
- stopNetworkServices();
- startNetworkServices();
- return null;
- }
- }.execute();
- }
- }
- };
-
- private void showNotification() {
- notificationManager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
- // launch LocalRepoActivity if the user selects this notification
- Intent intent = new Intent(this, SwapActivity.class);
- intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
- PendingIntent contentIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_CANCEL_CURRENT);
- Notification notification = new NotificationCompat.Builder(this)
- .setContentTitle(getText(R.string.local_repo_running))
- .setContentText(getText(R.string.touch_to_configure_local_repo))
- .setSmallIcon(R.drawable.ic_swap)
- .setContentIntent(contentIntent)
- .build();
- startForeground(NOTIFICATION, notification);
- }
-
- @Override
- public void onCreate() {
- showNotification();
- startNetworkServices();
- Preferences.get().registerLocalRepoBonjourListeners(localRepoBonjourChangeListener);
-
- LocalBroadcastManager.getInstance(this).registerReceiver(onWifiChange,
- new IntentFilter(WifiStateChangeService.BROADCAST));
- }
-
- @Override
- public int onStartCommand(Intent intent, int flags, int startId) {
- // We want this service to continue running until it is explicitly
- // stopped, so return sticky.
- return START_STICKY;
- }
-
- @Override
- public void onDestroy() {
- new Thread() {
- public void run() {
- stopNetworkServices();
- }
- }.start();
-
- notificationManager.cancel(NOTIFICATION);
- LocalBroadcastManager.getInstance(this).unregisterReceiver(onWifiChange);
- Preferences.get().unregisterLocalRepoBonjourListeners(localRepoBonjourChangeListener);
- }
-
- @Override
- public IBinder onBind(Intent intent) {
- return messenger.getBinder();
- }
-
- private void startNetworkServices() {
- Utils.DebugLog(TAG, "Starting local repo network services");
- startWebServer();
- if (Preferences.get().isLocalRepoBonjourEnabled())
- registerMDNSService();
- Preferences.get().registerLocalRepoHttpsListeners(localRepoHttpsChangeListener);
- }
-
- private void stopNetworkServices() {
- Utils.DebugLog(TAG, "Stopping local repo network services");
- Preferences.get().unregisterLocalRepoHttpsListeners(localRepoHttpsChangeListener);
-
- Utils.DebugLog(TAG, "Unregistering MDNS service...");
- unregisterMDNSService();
-
- Utils.DebugLog(TAG, "Stopping web server...");
- stopWebServer();
- }
-
- private void startWebServer() {
- Runnable webServer = new Runnable() {
- // Tell Eclipse this is not a leak because of Looper use.
- @SuppressLint("HandlerLeak")
- @Override
- public void run() {
- localHttpd = new LocalHTTPD(
- LocalRepoService.this,
- getFilesDir(),
- Preferences.get().isLocalRepoHttpsEnabled());
-
- Looper.prepare(); // must be run before creating a Handler
- webServerThreadHandler = new Handler() {
- @Override
- public void handleMessage(Message msg) {
- Utils.DebugLog(TAG, "we've been asked to stop the webserver: " + msg.obj);
- localHttpd.stop();
- }
- };
- try {
- localHttpd.start();
- } catch (BindException e) {
- int prev = FDroidApp.port;
- FDroidApp.port = FDroidApp.port + new Random().nextInt(1111);
- Log.w(TAG, "port " + prev + " occupied, trying on " + FDroidApp.port + "!");
- startService(new Intent(LocalRepoService.this, WifiStateChangeService.class));
- } catch (IOException e) {
- Log.e(TAG, "Could not start local repo HTTP server", e);
- }
- Looper.loop(); // start the message receiving loop
- }
- };
- new Thread(webServer).start();
- Intent intent = new Intent(STATE);
- intent.putExtra(STATE, STARTED);
- LocalBroadcastManager.getInstance(LocalRepoService.this).sendBroadcast(intent);
- }
-
- private void stopWebServer() {
- if (webServerThreadHandler == null) {
- Utils.DebugLog(TAG, "null handler in stopWebServer");
- return;
- }
- Message msg = webServerThreadHandler.obtainMessage();
- msg.obj = webServerThreadHandler.getLooper().getThread().getName() + " says stop";
- webServerThreadHandler.sendMessage(msg);
- Intent intent = new Intent(STATE);
- intent.putExtra(STATE, STOPPED);
- LocalBroadcastManager.getInstance(LocalRepoService.this).sendBroadcast(intent);
- }
-
- private void registerMDNSService() {
- new Thread(new Runnable() {
- @Override
- public void run() {
- /*
- * a ServiceInfo can only be registered with a single instance
- * of JmDNS, and there is only ever a single LocalHTTPD port to
- * advertise anyway.
- */
- if (pairService != null || jmdns != null)
- clearCurrentMDNSService();
- String repoName = Preferences.get().getLocalRepoName();
- HashMap values = new HashMap<>();
- values.put("path", "/fdroid/repo");
- values.put("name", repoName);
- values.put("fingerprint", FDroidApp.repo.fingerprint);
- String type;
- if (Preferences.get().isLocalRepoHttpsEnabled()) {
- values.put("type", "fdroidrepos");
- type = "_https._tcp.local.";
- } else {
- values.put("type", "fdroidrepo");
- type = "_http._tcp.local.";
- }
- try {
- pairService = ServiceInfo.create(type, repoName, FDroidApp.port, 0, 0, values);
- jmdns = JmDNS.create();
- jmdns.registerService(pairService);
- } catch (IOException e) {
- Log.e(TAG, "Error while registering jmdns service", e);
- }
- }
- }).start();
- }
-
- private void unregisterMDNSService() {
- if (localRepoBonjourChangeListener != null) {
- Preferences.get().unregisterLocalRepoBonjourListeners(localRepoBonjourChangeListener);
- localRepoBonjourChangeListener = null;
- }
- clearCurrentMDNSService();
- }
-
- private void clearCurrentMDNSService() {
- if (jmdns != null) {
- if (pairService != null) {
- jmdns.unregisterService(pairService);
- pairService = null;
- }
- jmdns.unregisterAllServices();
- Utils.closeQuietly(jmdns);
- jmdns = null;
- }
- }
-}
diff --git a/F-Droid/src/org/fdroid/fdroid/localrepo/SwapService.java b/F-Droid/src/org/fdroid/fdroid/localrepo/SwapService.java
new file mode 100644
index 000000000..b5aa52b27
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/localrepo/SwapService.java
@@ -0,0 +1,642 @@
+package org.fdroid.fdroid.localrepo;
+
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.app.Service;
+import android.content.BroadcastReceiver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.SharedPreferences;
+import android.net.Uri;
+import android.net.http.AndroidHttpClient;
+import android.os.AsyncTask;
+import android.os.IBinder;
+import android.support.annotation.IntDef;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.v4.app.NotificationCompat;
+import android.support.v4.content.LocalBroadcastManager;
+import android.text.TextUtils;
+import android.util.Log;
+
+import org.apache.http.HttpHost;
+import org.apache.http.NameValuePair;
+import org.apache.http.client.entity.UrlEncodedFormEntity;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.message.BasicNameValuePair;
+import org.fdroid.fdroid.FDroidApp;
+import org.fdroid.fdroid.Preferences;
+import org.fdroid.fdroid.R;
+import org.fdroid.fdroid.UpdateService;
+import org.fdroid.fdroid.Utils;
+import org.fdroid.fdroid.data.App;
+import org.fdroid.fdroid.data.NewRepoConfig;
+import org.fdroid.fdroid.data.Repo;
+import org.fdroid.fdroid.data.RepoProvider;
+import org.fdroid.fdroid.localrepo.peers.BluetoothFinder;
+import org.fdroid.fdroid.localrepo.peers.BonjourFinder;
+import org.fdroid.fdroid.localrepo.peers.Peer;
+import org.fdroid.fdroid.localrepo.type.BluetoothSwap;
+import org.fdroid.fdroid.localrepo.type.SwapType;
+import org.fdroid.fdroid.localrepo.type.WifiSwap;
+import org.fdroid.fdroid.net.WifiStateChangeService;
+import org.fdroid.fdroid.views.swap.SwapWorkflowActivity;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.Timer;
+import java.util.TimerTask;
+
+/**
+ * Central service which manages all of the different moving parts of swap which are required
+ * to enable p2p swapping of apps.
+ *
+ * The following UI elements don't do anything:
+ * + TODO: Be notified of changes to wifi state correctly, particularly from the WiFi AP (https://github.com/mvdan/accesspoint/issues/5)
+ * + TODO: The "?" button in the top right of the swap start screen doesn't do anything
+ * (This has been commented out for now, but it is still preferable to have a working help mechanism)
+ *
+ * TODO: Show "Waiting for other device to finish setting up swap" when only F-Droid shown in swap
+ * TODO: Handle "not connected to wifi" more gracefully. For example, Bonjour discovery falls over.
+ * TODO: When unable to reach the swap repo, but viewing apps to swap, show relevant feedback when attempting to download and install.
+ * TODO: Remove peers from list of peers when no longer "visible".
+ * TODO: Feedback for "Setting up (wifi|bluetooth)" in start swap view is not as immediate as I had hoped.
+ * TODO: Turn off bluetooth after cancelling/timing out if we turned it on.
+ * TODO: Disable the Scan QR button unless visible via something. Could equally show relevant feedback.
+ *
+ * TODO: Starting wifi after cancelling swap and beginning again doesn't work properly
+ * TODO: Scan QR hangs when updating repoo. Swapper was 2.3.3 and Swappee was 5.0
+ * TODO: Showing the progress bar during install doesn't work when the view is inflated again, or when the adapter is scrolled off screen and back again.
+ */
+public class SwapService extends Service {
+
+ private static final String TAG = "SwapManager";
+ public static final String SHARED_PREFERENCES = "swap-state";
+ private static final String KEY_APPS_TO_SWAP = "appsToSwap";
+ private static final String KEY_BLUETOOTH_ENABLED = "bluetoothEnabled";
+ private static final String KEY_WIFI_ENABLED = "wifiEnabled";
+
+ @NonNull
+ private Set appsToSwap = new HashSet<>();
+
+ public SwapService() {
+ super();
+ }
+
+ /**
+ * Where relevant, the state of the swap process will be saved to disk using preferences.
+ * Note that this is not always useful, for example saving the "current wifi network" is
+ * bound to cause trouble when the user opens the swap process again and is connected to
+ * a different network.
+ */
+ private SharedPreferences persistence() {
+ return getSharedPreferences(SHARED_PREFERENCES, MODE_APPEND);
+ }
+
+ // ==========================================================
+ // Search for peers to swap
+ // ==========================================================
+
+ public void scanForPeers() {
+ Log.d(TAG, "Scanning for nearby devices to swap with...");
+ bonjourFinder.scan();
+ bluetoothFinder.scan();
+ }
+
+ public void stopScanningForPeers() {
+ bonjourFinder.cancel();
+ bluetoothFinder.cancel();
+ }
+
+
+ // ==========================================================
+ // Manage the current step
+ // ("Step" refers to the current view being shown in the UI)
+ // ==========================================================
+
+ public static final int STEP_INTRO = 1;
+ public static final int STEP_SELECT_APPS = 2;
+ public static final int STEP_JOIN_WIFI = 3;
+ public static final int STEP_SHOW_NFC = 4;
+ public static final int STEP_WIFI_QR = 5;
+ public static final int STEP_CONNECTING = 6;
+ public static final int STEP_SUCCESS = 7;
+ public static final int STEP_CONFIRM_SWAP = 8;
+
+ /**
+ * 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
+ * we are in the state waiting for the {@link SwapService} to get started and
+ * bound to the {@link SwapWorkflowActivity}.
+ */
+ public static final int STEP_INITIAL_LOADING = 9;
+
+ private @SwapStep int step = STEP_INTRO;
+
+ /**
+ * Current screen that the swap process is up to.
+ * Will be one of the SwapState.STEP_* values.
+ */
+ @SwapStep
+ public int getStep() {
+ return step;
+ }
+
+ public SwapService setStep(@SwapStep int step) {
+ this.step = step;
+ return this;
+ }
+
+ public @NonNull Set getAppsToSwap() {
+ return appsToSwap;
+ }
+
+ public void refreshSwap() {
+ if (peer != null) {
+ connectTo(peer, false);
+ }
+ }
+
+ public void connectToPeer() {
+ if (getPeer() == null) {
+ throw new IllegalStateException("Cannot connect to peer, no peer has been selected.");
+ }
+ connectTo(getPeer(), getPeer().shouldPromptForSwapBack());
+ }
+
+ public void connectTo(@NonNull Peer peer, boolean requestSwapBack) {
+ if (peer != this.peer) {
+ Log.e(TAG, "Oops, got a different peer to swap with than initially planned.");
+ }
+
+ peerRepo = ensureRepoExists(peer);
+
+ // Only ask server to swap with us, if we are actually running a local repo service.
+ // It is possible to have a swap initiated without first starting a swap, in which
+ // case swapping back is pointless.
+ if (isEnabled() && requestSwapBack) {
+ askServerToSwapWithUs(peerRepo);
+ }
+
+ UpdateService.updateRepoNow(peer.getRepoAddress(), this);
+ }
+
+ private void askServerToSwapWithUs(final Repo repo) {
+ askServerToSwapWithUs(repo.address);
+ }
+
+ public void askServerToSwapWithUs(final NewRepoConfig config) {
+ askServerToSwapWithUs(config.getRepoUriString());
+ }
+
+ private void askServerToSwapWithUs(final String address) {
+ new AsyncTask() {
+ @Override
+ protected Void doInBackground(Void... args) {
+ Uri repoUri = Uri.parse(address);
+ String swapBackUri = Utils.getLocalRepoUri(FDroidApp.repo).toString();
+
+ AndroidHttpClient client = AndroidHttpClient.newInstance("F-Droid", SwapService.this);
+ HttpPost request = new HttpPost("/request-swap");
+ HttpHost host = new HttpHost(repoUri.getHost(), repoUri.getPort(), repoUri.getScheme());
+
+ try {
+ Log.d(TAG, "Asking server at " + address + " to swap with us in return (by POSTing to \"/request-swap\" with repo \"" + swapBackUri + "\")...");
+ populatePostParams(swapBackUri, request);
+ client.execute(host, request);
+ } catch (IOException e) {
+ notifyOfErrorOnUiThread();
+ Log.e(TAG, "Error while asking server to swap with us: " + e.getMessage());
+ } finally {
+ client.close();
+ }
+ return null;
+ }
+
+ private void populatePostParams(String swapBackUri, HttpPost request) throws UnsupportedEncodingException {
+ List params = new ArrayList<>();
+ params.add(new BasicNameValuePair("repo", swapBackUri));
+ UrlEncodedFormEntity encodedParams = new UrlEncodedFormEntity(params);
+ request.setEntity(encodedParams);
+ }
+
+ private void notifyOfErrorOnUiThread() {
+ // TODO: Broadcast error message so that whoever wants to can display a relevant
+ // message in the UI. This service doesn't understand the concept of UI.
+ /*runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ Toast.makeText(
+ SwapService.this,
+ R.string.swap_reciprocate_failed,
+ Toast.LENGTH_LONG
+ ).show();
+ }
+ });*/
+ }
+ }.execute();
+ }
+
+ private Repo ensureRepoExists(@NonNull Peer peer) {
+ // TODO: newRepoConfig.getParsedUri() will include a fingerprint, which may not match with
+ // the repos address in the database. Not sure on best behaviour in this situation.
+ Repo repo = RepoProvider.Helper.findByAddress(this, peer.getRepoAddress());
+ if (repo == null) {
+ ContentValues values = new ContentValues(6);
+
+ // The name/description is not really required, as swap repos are not shown in the
+ // "Manage repos" UI on other device. Doesn't hurt to put something there though,
+ // on the off chance that somebody is looking through the sqlite database which
+ // contains the repos...
+ values.put(RepoProvider.DataColumns.NAME, peer.getName());
+ values.put(RepoProvider.DataColumns.ADDRESS, peer.getRepoAddress());
+ values.put(RepoProvider.DataColumns.DESCRIPTION, "");
+ values.put(RepoProvider.DataColumns.FINGERPRINT, peer.getFingerprint());
+ values.put(RepoProvider.DataColumns.IN_USE, true);
+ values.put(RepoProvider.DataColumns.IS_SWAP, true);
+ Uri uri = RepoProvider.Helper.insert(this, values);
+ repo = RepoProvider.Helper.findByUri(this, uri);
+ }
+
+ return repo;
+ }
+
+ @Nullable
+ public Repo getPeerRepo() {
+ return peerRepo;
+ }
+
+ /**
+ * Ensure that we don't get put into an incorrect state, by forcing people to pass valid
+ * states to setStep. Ideally this would be done by requiring an enum or something to
+ * 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)}
+ */
+ @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})
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface SwapStep {}
+
+
+ // =================================================
+ // Have selected a specific peer to swap with
+ // (Rather than showing a generic QR code to scan)
+ // =================================================
+
+ @Nullable
+ private Peer peer;
+
+ @Nullable
+ private Repo peerRepo;
+
+ public void swapWith(Peer peer) {
+ this.peer = peer;
+ }
+
+ public boolean isConnectingWithPeer() {
+ return peer != null;
+ }
+
+ @Nullable
+ public Peer getPeer() {
+ return peer;
+ }
+
+
+ // ==========================================
+ // Remember apps user wants to swap
+ // ==========================================
+
+ private void persistAppsToSwap() {
+ persistence().edit().putString(KEY_APPS_TO_SWAP, serializePackages(appsToSwap)).commit();
+ }
+
+ /**
+ * Replacement for {@link android.content.SharedPreferences.Editor#putStringSet(String, Set)}
+ * which is only available in API >= 11.
+ * Package names are reverse-DNS-style, so they should only have alpha numeric values. Thus,
+ * this uses a comma as the separator.
+ * @see SwapService#deserializePackages(String)
+ */
+ private static String serializePackages(Set packages) {
+ StringBuilder sb = new StringBuilder();
+ for (String pkg : packages) {
+ if (sb.length() > 0) {
+ sb.append(',');
+ }
+ sb.append(pkg);
+ }
+ return sb.toString();
+ }
+
+ /**
+ * @see SwapService#deserializePackages(String)
+ */
+ private static Set deserializePackages(String packages) {
+ Set set = new HashSet<>();
+ if (!TextUtils.isEmpty(packages)) {
+ Collections.addAll(set, packages.split(","));
+ }
+ return set;
+ }
+
+ public void ensureFDroidSelected() {
+ String fdroid = getPackageName();
+ if (!hasSelectedPackage(fdroid)) {
+ selectPackage(fdroid);
+ }
+ }
+
+ public boolean hasSelectedPackage(String packageName) {
+ return appsToSwap.contains(packageName);
+ }
+
+ public void selectPackage(String packageName) {
+ appsToSwap.add(packageName);
+ persistAppsToSwap();
+ }
+
+ public void deselectPackage(String packageName) {
+ if (appsToSwap.contains(packageName)) {
+ appsToSwap.remove(packageName);
+ }
+ persistAppsToSwap();
+ }
+
+
+ // =============================================================
+ // Remember which swap technologies a user used in the past
+ // =============================================================
+
+ private void persistPreferredSwapTypes() {
+ persistence().edit()
+ .putBoolean(KEY_BLUETOOTH_ENABLED, bluetoothSwap.isConnected())
+ .putBoolean(KEY_WIFI_ENABLED, wifiSwap.isConnected())
+ .commit();
+ }
+
+ private boolean wasBluetoothEnabled() {
+ return persistence().getBoolean(KEY_BLUETOOTH_ENABLED, false);
+ }
+
+ private boolean wasWifiEnabled() {
+ return persistence().getBoolean(KEY_WIFI_ENABLED, false);
+ }
+
+ // ==========================================
+ // Local repo stop/start/restart handling
+ // ==========================================
+
+ /**
+ * Moves the service to the forground and [re]starts the timeout timer.
+ */
+ private void attachService() {
+ Log.d(TAG, "Moving SwapService to foreground so that it hangs around even when F-Droid is closed.");
+ startForeground(NOTIFICATION, createNotification());
+
+ // Regardless of whether it was previously enabled, start the timer again. This ensures that
+ // if, e.g. a person views the swap activity again, it will attempt to enable swapping if
+ // appropriate, and thus restart this timer.
+ initTimer();
+ }
+
+ private void detachService() {
+ if (timer != null) {
+ timer.cancel();
+ }
+
+ Log.d(TAG, "Moving SwapService to background so that it can be GC'ed if required.");
+ stopForeground(true);
+ }
+
+ /**
+ * Handles checking if the {@link SwapService} is running, and only restarts it if it was running.
+ */
+ public void restartWifiIfEnabled() {
+ if (wifiSwap.isConnected()) {
+ new AsyncTask() {
+ @Override
+ protected Void doInBackground(Void... params) {
+ Log.d(TAG, "Restarting WiFi swap service");
+ wifiSwap.stop();
+ wifiSwap.start();
+ return null;
+ }
+ }.execute();
+ }
+ }
+
+ public boolean isEnabled() {
+ return bluetoothSwap.isConnected() || wifiSwap.isConnected();
+ }
+
+ // ==========================================
+ // Interacting with Bluetooth adapter
+ // ==========================================
+
+ public BonjourFinder getBonjourFinder() {
+ return bonjourFinder;
+ }
+
+ public BluetoothFinder getBluetoothFinder() {
+ return bluetoothFinder;
+ }
+
+ public boolean isBluetoothDiscoverable() {
+ return bluetoothSwap.isConnected();
+ }
+
+ public boolean isBonjourDiscoverable() {
+ return wifiSwap.isConnected() && wifiSwap.getBonjour().isConnected();
+ }
+
+ public boolean isScanningForPeers() {
+ return bonjourFinder.isScanning() || bluetoothFinder.isScanning();
+ }
+
+ public static final String ACTION_PEER_FOUND = "org.fdroid.fdroid.SwapManager.ACTION_PEER_FOUND";
+ public static final String EXTRA_PEER = "EXTRA_PEER";
+
+
+ // ===============================================================
+ // Old SwapService stuff being merged into that.
+ // ===============================================================
+
+ public static final String BONJOUR_STATE_CHANGE = "org.fdroid.fdroid.BONJOUR_STATE_CHANGE";
+ public static final String BLUETOOTH_STATE_CHANGE = "org.fdroid.fdroid.BLUETOOTH_STATE_CHANGE";
+ public static final String WIFI_STATE_CHANGE = "org.fdroid.fdroid.WIFI_STATE_CHANGE";
+ public static final String EXTRA_STARTING = "STARTING";
+ public static final String EXTRA_STARTED = "STARTED";
+ public static final String EXTRA_STOPPED = "STOPPED";
+
+ private static final int NOTIFICATION = 1;
+
+ private final Binder binder = new Binder();
+ private SwapType bluetoothSwap;
+ private WifiSwap wifiSwap;
+
+ private BonjourFinder bonjourFinder;
+ private BluetoothFinder bluetoothFinder;
+
+ private final static int TIMEOUT = 900000; // 15 mins
+
+ /**
+ * Used to automatically turn of swapping after a defined amount of time (15 mins).
+ */
+ @Nullable
+ private Timer timer;
+
+ public SwapType getBluetoothSwap() {
+ return bluetoothSwap;
+ }
+
+ public WifiSwap getWifiSwap() {
+ return wifiSwap;
+ }
+
+ public class Binder extends android.os.Binder {
+ public SwapService getService() {
+ return SwapService.this;
+ }
+ }
+
+ public void onCreate() {
+ super.onCreate();
+
+ Log.d(TAG, "Creating swap service.");
+
+ SharedPreferences preferences = getSharedPreferences(SHARED_PREFERENCES, Context.MODE_PRIVATE);
+
+ appsToSwap.addAll(deserializePackages(preferences.getString(KEY_APPS_TO_SWAP, "")));
+ bluetoothSwap = BluetoothSwap.create(this);
+ wifiSwap = new WifiSwap(this);
+ bonjourFinder = new BonjourFinder(this);
+ bluetoothFinder = new BluetoothFinder(this);
+
+ Preferences.get().registerLocalRepoHttpsListeners(httpsEnabledListener);
+
+ LocalBroadcastManager.getInstance(this).registerReceiver(onWifiChange, new IntentFilter(WifiStateChangeService.BROADCAST));
+
+ IntentFilter filter = new IntentFilter(BLUETOOTH_STATE_CHANGE);
+ filter.addAction(WIFI_STATE_CHANGE);
+ LocalBroadcastManager.getInstance(this).registerReceiver(receiveSwapStatusChanged, filter);
+
+ if (wasBluetoothEnabled()) {
+ Log.d(TAG, "Previously the user enabled Bluetooth swap, so enabling again automatically.");
+ bluetoothSwap.startInBackground();
+ }
+
+ if (wasWifiEnabled()) {
+ Log.d(TAG, "Previously the user enabled Wifi swap, so enabling again automatically.");
+ wifiSwap.startInBackground();
+ }
+ }
+
+ /**
+ * Responsible for moving the service into the foreground or the background, depending on
+ * whether or not there are any swap services (i.e. bluetooth or wifi) running or not.
+ */
+ private final BroadcastReceiver receiveSwapStatusChanged = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (intent.hasExtra(EXTRA_STARTED)) {
+ if (getWifiSwap().isConnected() || getBluetoothSwap().isConnected()) {
+ attachService();
+ }
+ } else if (intent.hasExtra(EXTRA_STOPPED)) {
+ if (!getWifiSwap().isConnected() && !getBluetoothSwap().isConnected()) {
+ detachService();
+ }
+ }
+ persistPreferredSwapTypes();
+ }
+ };
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+
+ return START_STICKY;
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return binder;
+ }
+
+ public void disableAllSwapping() {
+ Log.i(TAG, "Asked to stop swapping, will stop bluetooth, wifi, and move service to BG for GC.");
+ getBluetoothSwap().stopInBackground();
+ getWifiSwap().stopInBackground();
+
+ // Ensure the user is sent back go the first screen when returning if we have just forceably
+ // cancelled all swapping.
+ setStep(STEP_INTRO);
+ detachService();
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ Log.d(TAG, "Destroying service, will disable swapping if required, and unregister listeners.");
+ disableAllSwapping();
+ Preferences.get().unregisterLocalRepoHttpsListeners(httpsEnabledListener);
+ LocalBroadcastManager.getInstance(this).unregisterReceiver(onWifiChange);
+ LocalBroadcastManager.getInstance(this).unregisterReceiver(receiveSwapStatusChanged);
+ }
+
+ private Notification createNotification() {
+ Intent intent = new Intent(this, SwapWorkflowActivity.class);
+ intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
+ PendingIntent contentIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_CANCEL_CURRENT);
+ return new NotificationCompat.Builder(this)
+ .setContentTitle(getText(R.string.local_repo_running))
+ .setContentText(getText(R.string.touch_to_configure_local_repo))
+ .setSmallIcon(R.drawable.ic_swap)
+ .setContentIntent(contentIntent)
+ .build();
+ }
+
+ private void initTimer() {
+ if (timer != null) {
+ Log.d(TAG, "Cancelling existing timeout timer so timeout can be reset.");
+ timer.cancel();
+ }
+
+ Log.d(TAG, "Initializing swap timeout to " + TIMEOUT + "ms minutes");
+ timer = new Timer();
+ timer.schedule(new TimerTask() {
+ @Override
+ public void run() {
+ Log.d(TAG, "Disabling swap because " + TIMEOUT + "ms passed.");
+ disableAllSwapping();
+ }
+ }, TIMEOUT);
+ }
+
+ @SuppressWarnings("FieldCanBeLocal") // The constructor will get bloated if these are all local...
+ private final Preferences.ChangeListener httpsEnabledListener = new Preferences.ChangeListener() {
+ @Override
+ public void onPreferenceChange() {
+ Log.i(TAG, "Swap over HTTPS preference changed.");
+ restartWifiIfEnabled();
+ }
+ };
+
+ @SuppressWarnings("FieldCanBeLocal") // The constructor will get bloated if these are all local...
+ private final BroadcastReceiver onWifiChange = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent i) {
+ restartWifiIfEnabled();
+ }
+ };
+
+}
diff --git a/F-Droid/src/org/fdroid/fdroid/localrepo/peers/BluetoothFinder.java b/F-Droid/src/org/fdroid/fdroid/localrepo/peers/BluetoothFinder.java
new file mode 100644
index 000000000..fc74cd245
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/localrepo/peers/BluetoothFinder.java
@@ -0,0 +1,100 @@
+package org.fdroid.fdroid.localrepo.peers;
+
+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.util.Log;
+
+import org.fdroid.fdroid.localrepo.type.BluetoothSwap;
+
+public class BluetoothFinder extends PeerFinder {
+
+ private static final String TAG = "BluetoothFinder";
+
+ private final BluetoothAdapter adapter;
+
+ public BluetoothFinder(Context context) {
+ super(context);
+ adapter = BluetoothAdapter.getDefaultAdapter();
+ }
+
+ private BroadcastReceiver deviceFoundReceiver;
+ private BroadcastReceiver scanCompleteReceiver;
+
+ @Override
+ public void scan() {
+
+ if (adapter == null) {
+ Log.i(TAG, "Not scanning for bluetooth peers to swap with, couldn't find a bluetooth adapter on this device.");
+ return;
+ }
+
+ isScanning = true;
+
+ if (deviceFoundReceiver == null) {
+ 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);
+ onDeviceFound(device);
+ }
+ }
+ };
+ context.registerReceiver(deviceFoundReceiver, new IntentFilter(BluetoothDevice.ACTION_FOUND));
+ }
+
+ if (scanCompleteReceiver == null) {
+ scanCompleteReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (isScanning) {
+ Log.d(TAG, "Scan complete, but we haven't been asked to stop scanning yet, so will restart scan.");
+ startDiscovery();
+ }
+ }
+ };
+
+ // TODO: Unregister this receiver at the appropriate time.
+ context.registerReceiver(scanCompleteReceiver, new IntentFilter(BluetoothAdapter.ACTION_DISCOVERY_FINISHED));
+ }
+
+ startDiscovery();
+ }
+
+ private void startDiscovery() {
+
+ if (adapter.isDiscovering()) {
+ // TODO: Can we reset the discovering timeout, so that it doesn't, e.g. time out in 3
+ // seconds because we had already almost completed the previous scan? We could
+ // cancelDiscovery(), but then it will probably prompt the user again.
+ Log.d(TAG, "Requested bluetooth scan when already scanning, so will ignore request.");
+ return;
+ }
+
+ if (!adapter.startDiscovery()) {
+ Log.e(TAG, "Couldn't start bluetooth scanning.");
+ }
+
+ }
+
+ @Override
+ public void cancel() {
+ if (adapter != null) {
+ Log.d(TAG, "Stopping bluetooth discovery.");
+ adapter.cancelDiscovery();
+ }
+
+ isScanning = false;
+ }
+
+ private void onDeviceFound(BluetoothDevice device) {
+ if (device != null && device.getName() != null && device.getName().startsWith(BluetoothSwap.BLUETOOTH_NAME_TAG)) {
+ foundPeer(new BluetoothPeer(device));
+ }
+ }
+
+}
diff --git a/F-Droid/src/org/fdroid/fdroid/localrepo/peers/BluetoothPeer.java b/F-Droid/src/org/fdroid/fdroid/localrepo/peers/BluetoothPeer.java
new file mode 100644
index 000000000..45261d6d5
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/localrepo/peers/BluetoothPeer.java
@@ -0,0 +1,81 @@
+package org.fdroid.fdroid.localrepo.peers;
+
+import android.bluetooth.BluetoothDevice;
+import android.os.Parcel;
+
+import org.fdroid.fdroid.R;
+import org.fdroid.fdroid.localrepo.type.BluetoothSwap;
+
+public class BluetoothPeer implements Peer {
+
+ private BluetoothDevice device;
+
+ public BluetoothPeer(BluetoothDevice device) {
+ this.device = device;
+ }
+
+ @Override
+ public String toString() {
+ return getName();
+ }
+
+ @Override
+ public String getName() {
+ return device.getName().replaceAll("^" + BluetoothSwap.BLUETOOTH_NAME_TAG, "");
+ }
+
+ @Override
+ public int getIcon() {
+ return R.drawable.ic_bluetooth_white;
+ }
+
+ @Override
+ public boolean equals(Object peer) {
+ return peer != null && peer instanceof BluetoothPeer && ((BluetoothPeer)peer).device.getAddress().equals(device.getAddress());
+ }
+
+ @Override
+ public String getRepoAddress() {
+ return "bluetooth://" + device.getAddress().replace(':', '-') + "/fdroid/repo";
+ }
+
+ /**
+ * Bluetooth will exclusively be TOFU. Once a device is connected to a bluetooth socket,
+ * if we trust it enough to accept a fingerprint from it somehow, then we may as well trust it
+ * enough to receive an index from it that contains a fingerprint we can use.
+ */
+ @Override
+ public String getFingerprint() {
+ return "";
+ }
+
+ @Override
+ public boolean shouldPromptForSwapBack() {
+ return false;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeParcelable(this.device, 0);
+ }
+
+ protected BluetoothPeer(Parcel in) {
+ this.device = in.readParcelable(BluetoothDevice.class.getClassLoader());
+ }
+
+ public static final Creator CREATOR = new Creator() {
+ public BluetoothPeer createFromParcel(Parcel source) {
+ return new BluetoothPeer(source);
+ }
+
+ public BluetoothPeer[] newArray(int size) {
+ return new BluetoothPeer[size];
+ }
+ };
+
+}
diff --git a/F-Droid/src/org/fdroid/fdroid/localrepo/peers/BonjourFinder.java b/F-Droid/src/org/fdroid/fdroid/localrepo/peers/BonjourFinder.java
new file mode 100644
index 000000000..9eee0e0de
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/localrepo/peers/BonjourFinder.java
@@ -0,0 +1,182 @@
+package org.fdroid.fdroid.localrepo.peers;
+
+import android.content.Context;
+import android.net.wifi.WifiManager;
+import android.os.AsyncTask;
+import android.util.Log;
+
+import org.fdroid.fdroid.BuildConfig;
+import org.fdroid.fdroid.FDroidApp;
+
+import java.io.IOException;
+import java.net.Inet4Address;
+import java.net.InetAddress;
+import java.util.Timer;
+import java.util.TimerTask;
+
+import javax.jmdns.JmDNS;
+import javax.jmdns.ServiceEvent;
+import javax.jmdns.ServiceInfo;
+import javax.jmdns.ServiceListener;
+
+public class BonjourFinder extends PeerFinder implements ServiceListener {
+
+ private static final String TAG = "BonjourFinder";
+
+ public static final String HTTP_SERVICE_TYPE = "_http._tcp.local.";
+ public static final String HTTPS_SERVICE_TYPE = "_https._tcp.local.";
+
+ private JmDNS jmdns;
+ private WifiManager wifiManager;
+ private WifiManager.MulticastLock mMulticastLock;
+
+ public BonjourFinder(Context context) {
+ super(context);
+ }
+
+ @Override
+ public void scan() {
+
+ Log.d(TAG, "Requested Bonjour (mDNS) scan for peers.");
+
+ if (wifiManager == null) {
+ wifiManager = (WifiManager) context.getSystemService(Context.WIFI_SERVICE);
+ mMulticastLock = wifiManager.createMulticastLock(context.getPackageName());
+ mMulticastLock.setReferenceCounted(false);
+ }
+
+ if (isScanning) {
+ Log.d(TAG, "Requested Bonjour scan, but already scanning. But we will still try to explicitly scan for services.");
+ // listServices();
+ return;
+ }
+
+ isScanning = true;
+ mMulticastLock.acquire();
+ new AsyncTask() {
+
+ @Override
+ protected Void doInBackground(Void... params) {
+ try {
+ Log.d(TAG, "Searching for Bonjour (mDNS) clients...");
+ jmdns = JmDNS.create(InetAddress.getByName(FDroidApp.ipAddressString));
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(Void result) {
+ // TODO: This is not threadsafe - cancelling the discovery will make jmdns null, but it could happen after this check and before call to addServiceListener().
+ if (jmdns != null) {
+ Log.d(TAG, "Adding mDNS service listeners for " + HTTP_SERVICE_TYPE + " and " + HTTPS_SERVICE_TYPE);
+ jmdns.addServiceListener(HTTP_SERVICE_TYPE, BonjourFinder.this);
+ jmdns.addServiceListener(HTTPS_SERVICE_TYPE, BonjourFinder.this);
+ listServices();
+ }
+ }
+ }.execute();
+
+ }
+
+ private void listServices() {
+
+ // The member variable is likely to get set to null if a swap process starts, thus we hold
+ // a reference for the benefit of the background task so it doesn't have to synchronoize on it.
+ final JmDNS mdns = jmdns;
+
+ new AsyncTask() {
+
+ @Override
+ protected Void doInBackground(Void... params) {
+ Log.d(TAG, "Explicitly querying for services, in addition to waiting for notifications.");
+ addFDroidServices(mdns.list(HTTP_SERVICE_TYPE));
+ addFDroidServices(mdns.list(HTTPS_SERVICE_TYPE));
+ return null;
+ }
+ }.execute();
+ }
+
+ @Override
+ public void serviceRemoved(ServiceEvent event) {
+ }
+
+ @Override
+ public void serviceAdded(final ServiceEvent event) {
+ // TODO: Get clarification, but it looks like this is:
+ // 1) Identifying that there is _a_ bonjour service available
+ // 2) Adding it to the list to give some sort of feedback to the user
+ // 3) Requesting more detailed info in an async manner
+ // 4) If that is in fact an fdroid repo (after requesting info), then add it again
+ // so that more detailed info can be shown to the user.
+ //
+ // If so, when is the old one removed?
+ addFDroidService(event.getInfo());
+
+ // The member variable is likely to get set to null if a swap process starts, thus we hold
+ // a reference for the benefit of the background task so it doesn't have to synchronoize on it.
+ final JmDNS mdns = jmdns;
+ new AsyncTask() {
+ @Override
+ protected Void doInBackground(Void... params) {
+ mdns.requestServiceInfo(event.getType(), event.getName(), true);
+ return null;
+ }
+ }.execute();
+ }
+
+ @Override
+ public void serviceResolved(ServiceEvent event) {
+ addFDroidService(event.getInfo());
+ }
+
+ private void addFDroidServices(ServiceInfo[] services) {
+ for (ServiceInfo info : services) {
+ addFDroidService(info);
+ }
+ }
+
+ /**
+ * Broadcasts the fact that a Bonjour peer was found to swap with.
+ * Checks that the service is an F-Droid service, and also that it is not the F-Droid service
+ * for this device (by comparing its signing fingerprint to our signing fingerprint).
+ */
+ private void addFDroidService(ServiceInfo serviceInfo) {
+ final String type = serviceInfo.getPropertyString("type");
+ final String fingerprint = serviceInfo.getPropertyString("fingerprint");
+ final boolean isFDroid = type != null && type.startsWith("fdroidrepo");
+ final boolean isSelf = FDroidApp.repo != null && fingerprint != null && fingerprint.equalsIgnoreCase(FDroidApp.repo.fingerprint);
+ if (isFDroid && !isSelf) {
+ if (BuildConfig.DEBUG) {
+ Log.d(TAG, "Found F-Droid swap Bonjour service:\n" + serviceInfo);
+ }
+ foundPeer(new BonjourPeer(serviceInfo));
+ } else {
+ if (BuildConfig.DEBUG) {
+ if (isSelf) {
+ Log.d(TAG, "Ignoring Bonjour service because it belongs to this device:\n" + serviceInfo);
+ } else {
+ Log.d(TAG, "Ignoring Bonjour service because it doesn't look like an F-Droid swap repo:\n" + serviceInfo);
+ }
+ }
+ }
+ }
+
+ @Override
+ public void cancel() {
+ if (mMulticastLock != null) {
+ mMulticastLock.release();
+ }
+
+ isScanning = false;
+
+ if (jmdns == null)
+ return;
+ jmdns.removeServiceListener(HTTP_SERVICE_TYPE, this);
+ jmdns.removeServiceListener(HTTPS_SERVICE_TYPE, this);
+ jmdns = null;
+
+ }
+
+}
diff --git a/F-Droid/src/org/fdroid/fdroid/localrepo/peers/BonjourPeer.java b/F-Droid/src/org/fdroid/fdroid/localrepo/peers/BonjourPeer.java
new file mode 100644
index 000000000..8ee36c05a
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/localrepo/peers/BonjourPeer.java
@@ -0,0 +1,72 @@
+package org.fdroid.fdroid.localrepo.peers;
+
+import android.net.Uri;
+import android.os.Parcel;
+
+import javax.jmdns.impl.FDroidServiceInfo;
+import javax.jmdns.ServiceInfo;
+
+public class BonjourPeer extends WifiPeer {
+
+ private FDroidServiceInfo serviceInfo;
+
+ public BonjourPeer(ServiceInfo serviceInfo) {
+ this.serviceInfo = new FDroidServiceInfo(serviceInfo);
+ this.name = serviceInfo.getDomain();
+ this.uri = Uri.parse(this.serviceInfo.getRepoAddress());
+ this.shouldPromptForSwapBack = true;
+ }
+
+ @Override
+ public String toString() {
+ return getName();
+ }
+
+ @Override
+ public String getName() {
+ return serviceInfo.getName();
+ }
+
+ @Override
+ public boolean equals(Object peer) {
+ if (peer != null && peer instanceof BonjourPeer) {
+ BonjourPeer that = (BonjourPeer)peer;
+ return this.getFingerprint().equals(that.getFingerprint());
+ }
+ return false;
+ }
+
+ @Override
+ public String getRepoAddress() {
+ return serviceInfo.getRepoAddress();
+ }
+
+ @Override
+ public String getFingerprint() {
+ return serviceInfo.getFingerprint();
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeParcelable(serviceInfo, flags);
+ }
+
+ protected BonjourPeer(Parcel in) {
+ this((ServiceInfo)in.readParcelable(FDroidServiceInfo.class.getClassLoader()));
+ }
+
+ public static final Creator CREATOR = new Creator() {
+ public BonjourPeer createFromParcel(Parcel source) {
+ return new BonjourPeer(source);
+ }
+
+ public BonjourPeer[] newArray(int size) {
+ return new BonjourPeer[size];
+ }
+ };
+}
diff --git a/F-Droid/src/org/fdroid/fdroid/localrepo/peers/Peer.java b/F-Droid/src/org/fdroid/fdroid/localrepo/peers/Peer.java
new file mode 100644
index 000000000..c1481e297
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/localrepo/peers/Peer.java
@@ -0,0 +1,19 @@
+package org.fdroid.fdroid.localrepo.peers;
+
+import android.os.Parcelable;
+import android.support.annotation.DrawableRes;
+
+public interface Peer extends Parcelable {
+
+ String getName();
+
+ @DrawableRes int getIcon();
+
+ boolean equals(Object peer);
+
+ String getRepoAddress();
+
+ String getFingerprint();
+
+ boolean shouldPromptForSwapBack();
+}
diff --git a/F-Droid/src/org/fdroid/fdroid/localrepo/peers/PeerFinder.java b/F-Droid/src/org/fdroid/fdroid/localrepo/peers/PeerFinder.java
new file mode 100644
index 000000000..bcd2d84ce
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/localrepo/peers/PeerFinder.java
@@ -0,0 +1,44 @@
+package org.fdroid.fdroid.localrepo.peers;
+
+import android.content.Context;
+import android.content.Intent;
+import android.support.v4.content.LocalBroadcastManager;
+import android.util.Log;
+
+import org.fdroid.fdroid.localrepo.SwapService;
+
+/**
+ * Searches for other devices in the vicinity, using specific technologies.
+ * Once found, sends an {@link SwapService#ACTION_PEER_FOUND} intent with the {@link SwapService#EXTRA_PEER}
+ * extra attribute set to the subclass of {@link Peer} that was found.
+ */
+public abstract class PeerFinder {
+
+ private static final String TAG = "PeerFinder";
+
+ protected boolean isScanning = false;
+ protected final Context context;
+
+ public abstract void scan();
+ public abstract void cancel();
+
+ public PeerFinder(Context context) {
+ this.context = context;
+ }
+
+ public boolean isScanning() {
+ return isScanning;
+ }
+
+ protected void foundPeer(T peer) {
+ Log.i(TAG, "Found peer " + peer.getName());
+ Intent intent = new Intent(SwapService.ACTION_PEER_FOUND);
+ intent.putExtra(SwapService.EXTRA_PEER, peer);
+ LocalBroadcastManager.getInstance(context).sendBroadcast(intent);
+ }
+
+ protected void removePeer(T peer) {
+ // TODO: Broadcast messages when peers are removed too.
+ }
+
+}
diff --git a/F-Droid/src/org/fdroid/fdroid/localrepo/peers/WifiPeer.java b/F-Droid/src/org/fdroid/fdroid/localrepo/peers/WifiPeer.java
new file mode 100644
index 000000000..23e2db129
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/localrepo/peers/WifiPeer.java
@@ -0,0 +1,80 @@
+package org.fdroid.fdroid.localrepo.peers;
+
+import android.net.Uri;
+import android.os.Parcel;
+
+import org.fdroid.fdroid.R;
+import org.fdroid.fdroid.data.NewRepoConfig;
+
+public class WifiPeer implements Peer {
+
+ protected String name;
+ protected Uri uri;
+ protected boolean shouldPromptForSwapBack;
+
+ public WifiPeer() {
+
+ }
+
+ public WifiPeer(NewRepoConfig config) {
+ this(config.getRepoUri(), config.getHost(), !config.preventFurtherSwaps());
+ }
+
+ protected WifiPeer(Uri uri, String name, boolean shouldPromptForSwapBack) {
+ this.name = name;
+ this.uri = uri;
+ this.shouldPromptForSwapBack = shouldPromptForSwapBack;
+ }
+
+ @Override
+ public String getName() {
+ return name;
+ }
+
+ @Override
+ public int getIcon() {
+ return R.drawable.ic_network_wifi_white;
+ }
+
+ @Override
+ public String getRepoAddress() {
+ return uri.toString();
+ }
+
+ @Override
+ public String getFingerprint() {
+ return uri.getQueryParameter("fingerprint");
+ }
+
+ @Override
+ public boolean shouldPromptForSwapBack() {
+ return shouldPromptForSwapBack;
+ }
+
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeString(name);
+ dest.writeString(uri.toString());
+ dest.writeByte(shouldPromptForSwapBack ? (byte) 1 : (byte) 0);
+ }
+
+ protected WifiPeer(Parcel in) {
+ this(Uri.parse(in.readString()), in.readString(), in.readByte() == 1);
+ }
+
+ public static final Creator CREATOR = new Creator() {
+ public WifiPeer createFromParcel(Parcel source) {
+ return new WifiPeer(source);
+ }
+
+ public WifiPeer[] newArray(int size) {
+ return new WifiPeer[size];
+ }
+ };
+}
diff --git a/F-Droid/src/org/fdroid/fdroid/localrepo/type/BluetoothSwap.java b/F-Droid/src/org/fdroid/fdroid/localrepo/type/BluetoothSwap.java
new file mode 100644
index 000000000..10dab0ea8
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/localrepo/type/BluetoothSwap.java
@@ -0,0 +1,145 @@
+package org.fdroid.fdroid.localrepo.type;
+
+import android.bluetooth.BluetoothAdapter;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.util.Log;
+
+import org.fdroid.fdroid.localrepo.SwapService;
+import org.fdroid.fdroid.net.bluetooth.BluetoothServer;
+
+public class BluetoothSwap extends SwapType {
+
+ private static final String TAG = "BluetoothBroadcastType";
+ public final static String BLUETOOTH_NAME_TAG = "FDroid:";
+
+ @NonNull
+ private final BluetoothAdapter adapter;
+
+ @Nullable
+ private BluetoothServer server;
+
+ private String deviceBluetoothName = null;
+
+ public static SwapType create(@NonNull Context context) {
+ BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
+ if (adapter == null) {
+ return new NoBluetoothType(context);
+ } else {
+ return new BluetoothSwap(context, adapter);
+ }
+ }
+
+ private BluetoothSwap(@NonNull Context context, @NonNull BluetoothAdapter adapter) {
+ super(context);
+ this.adapter = adapter;
+
+ context.registerReceiver(new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ switch (intent.getIntExtra(BluetoothAdapter.EXTRA_SCAN_MODE, -1)) {
+ case BluetoothAdapter.SCAN_MODE_NONE:
+ setConnected(false);
+ break;
+
+ case BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE:
+ if (server != null && server.isRunning()) {
+ setConnected(true);
+ }
+ break;
+
+ // Only other is BluetoothAdapter.SCAN_MODE_CONNECTABLE. For now don't handle that.
+ }
+ }
+ }, new IntentFilter(BluetoothAdapter.ACTION_SCAN_MODE_CHANGED));
+ }
+
+ @Override
+ public boolean isConnected() {
+ return server != null && server.isRunning() && super.isConnected();
+ }
+
+ @Override
+ public void start() {
+ if (server != null) {
+ Log.d(TAG, "Attempting to start Bluetooth swap, but it appears to be running already. Will cancel it so it can be restarted.");
+ server.close();
+ server = null;
+ }
+
+ server = new BluetoothServer(this, context.getFilesDir());
+
+ sendBroadcast(SwapService.EXTRA_STARTING);
+
+ //store the original bluetoothname, and update this one to be unique
+ deviceBluetoothName = adapter.getName();
+
+ Log.d(TAG, "Prefixing Bluetooth adapter name with " + BLUETOOTH_NAME_TAG + " to make it identifiable as a swap device.");
+ if (!deviceBluetoothName.startsWith(BLUETOOTH_NAME_TAG))
+ adapter.setName(BLUETOOTH_NAME_TAG + deviceBluetoothName);
+
+ if (!adapter.getName().startsWith(BLUETOOTH_NAME_TAG)) {
+ Log.e(TAG, "Couldn't change the name of the Bluetooth adapter, it will not get recognized by other swap clients.");
+ // TODO: Should we bail here?
+ }
+
+ if (!adapter.isEnabled()) {
+ Log.d(TAG, "Bluetooth adapter is disabled, attempting to enable.");
+ if (!adapter.enable()) {
+ Log.d(TAG, "Could not enable Bluetooth adapter, so bailing out of Bluetooth swap.");
+ setConnected(false);
+ return;
+ }
+ }
+
+ if (adapter.isEnabled()) {
+ server.start();
+ setConnected(true);
+ } else {
+ Log.i(TAG, "Didn't start Bluetooth swapping server, because Bluetooth is disabled and couldn't be enabled.");
+ setConnected(false);
+ }
+ }
+
+ @Override
+ public void stop() {
+ if (server != null && server.isAlive()) {
+ server.close();
+ setConnected(false);
+ } else {
+ Log.i(TAG, "Attempting to stop Bluetooth swap, but it is not currently running.");
+ }
+ }
+
+ protected void onStopped() {
+ Log.d(TAG, "Resetting bluetooth device name to " + deviceBluetoothName + " after swapping.");
+ adapter.setName(deviceBluetoothName);
+ }
+
+ @Override
+ public String getBroadcastAction() {
+ return SwapService.BLUETOOTH_STATE_CHANGE;
+ }
+
+ private static class NoBluetoothType extends SwapType {
+
+ public NoBluetoothType(@NonNull Context context) {
+ super(context);
+ }
+
+ @Override
+ public void start() {}
+
+ @Override
+ public void stop() {}
+
+ @Override
+ protected String getBroadcastAction() {
+ return null;
+ }
+ }
+}
diff --git a/F-Droid/src/org/fdroid/fdroid/localrepo/type/BonjourBroadcast.java b/F-Droid/src/org/fdroid/fdroid/localrepo/type/BonjourBroadcast.java
new file mode 100644
index 000000000..ab50642a0
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/localrepo/type/BonjourBroadcast.java
@@ -0,0 +1,96 @@
+package org.fdroid.fdroid.localrepo.type;
+
+import android.content.Context;
+import android.util.Log;
+
+import org.fdroid.fdroid.FDroidApp;
+import org.fdroid.fdroid.Preferences;
+import org.fdroid.fdroid.Utils;
+import org.fdroid.fdroid.localrepo.SwapService;
+
+import java.io.IOException;
+import java.net.InetAddress;
+import java.util.HashMap;
+
+import javax.jmdns.JmDNS;
+import javax.jmdns.ServiceInfo;
+
+/**
+ * Sends a {@link SwapService#BONJOUR_STATE_CHANGE} broadcasts when starting, started or stopped.
+ */
+public class BonjourBroadcast extends SwapType {
+
+ private static final String TAG = "BonjourSwapService";
+
+ private JmDNS jmdns;
+ private ServiceInfo pairService;
+
+ public BonjourBroadcast(Context context) {
+ super(context);
+ }
+
+ @Override
+ public void start() {
+
+ Log.d(TAG, "Preparing to start Bonjour service.");
+ sendBroadcast(SwapService.EXTRA_STARTING);
+
+ /*
+ * a ServiceInfo can only be registered with a single instance
+ * of JmDNS, and there is only ever a single LocalHTTPD port to
+ * advertise anyway.
+ */
+ if (pairService != null || jmdns != null)
+ clearCurrentMDNSService();
+ String repoName = Preferences.get().getLocalRepoName();
+ HashMap values = new HashMap<>();
+ values.put("path", "/fdroid/repo");
+ values.put("name", repoName);
+ values.put("fingerprint", FDroidApp.repo.fingerprint);
+ String type;
+ if (Preferences.get().isLocalRepoHttpsEnabled()) {
+ values.put("type", "fdroidrepos");
+ type = "_https._tcp.local.";
+ } else {
+ values.put("type", "fdroidrepo");
+ type = "_http._tcp.local.";
+ }
+ try {
+ Log.d(TAG, "Starting bonjour service...");
+ pairService = ServiceInfo.create(type, repoName, FDroidApp.port, 0, 0, values);
+ jmdns = JmDNS.create(InetAddress.getByName(FDroidApp.ipAddressString));
+ jmdns.registerService(pairService);
+ setConnected(true);
+ Log.d(TAG, "... Bounjour service started.");
+ } catch (IOException e) {
+ Log.e(TAG, "Error while registering jmdns service: " + e);
+ Log.e(TAG, Log.getStackTraceString(e));
+ setConnected(false);
+ }
+ }
+
+ @Override
+ public void stop() {
+ Log.d(TAG, "Unregistering MDNS service...");
+ clearCurrentMDNSService();
+ setConnected(false);
+ }
+
+ private void clearCurrentMDNSService() {
+ if (jmdns != null) {
+ if (pairService != null) {
+ jmdns.unregisterService(pairService);
+ pairService = null;
+ }
+ jmdns.unregisterAllServices();
+ Utils.closeQuietly(jmdns);
+ jmdns = null;
+ }
+ }
+
+ @Override
+ public String getBroadcastAction() {
+ return SwapService.BONJOUR_STATE_CHANGE;
+ }
+
+}
diff --git a/F-Droid/src/org/fdroid/fdroid/localrepo/type/SwapType.java b/F-Droid/src/org/fdroid/fdroid/localrepo/type/SwapType.java
new file mode 100644
index 000000000..1e6f781a1
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/localrepo/type/SwapType.java
@@ -0,0 +1,102 @@
+package org.fdroid.fdroid.localrepo.type;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.os.AsyncTask;
+import android.support.annotation.NonNull;
+import android.support.v4.content.LocalBroadcastManager;
+
+import org.fdroid.fdroid.localrepo.SwapService;
+
+/**
+ * There is lots of common functionality, and a common API among different communication protocols
+ * associated with the swap process. This includes Bluetooth visability, Bonjour visability,
+ * and the web server which serves info for swapping. This class provides a common API for
+ * starting and stopping these services. In addition, it helps with the process of sending broadcast
+ * intents in response to the thing starting or stopping.
+ */
+public abstract class SwapType {
+
+ private boolean isConnected;
+
+ @NonNull
+ protected final Context context;
+
+ public SwapType(@NonNull Context context) {
+ this.context = context;
+ }
+
+ abstract public void start();
+
+ abstract public void stop();
+
+ abstract protected String getBroadcastAction();
+
+ protected final void setConnected(boolean connected) {
+ if (connected) {
+ isConnected = true;
+ sendBroadcast(SwapService.EXTRA_STARTED);
+ } else {
+ isConnected = false;
+ onStopped();
+ sendBroadcast(SwapService.EXTRA_STOPPED);
+ }
+ }
+
+ protected void onStopped() {}
+
+ /**
+ * Sends either a {@link org.fdroid.fdroid.localrepo.SwapService#EXTRA_STARTING},
+ * {@link org.fdroid.fdroid.localrepo.SwapService#EXTRA_STARTED} or
+ * {@link org.fdroid.fdroid.localrepo.SwapService#EXTRA_STOPPED} broadcast.
+ */
+ protected final void sendBroadcast(String extra) {
+ if (getBroadcastAction() != null) {
+ Intent intent = new Intent(getBroadcastAction());
+ intent.putExtra(extra, true);
+ LocalBroadcastManager.getInstance(context).sendBroadcast(intent);
+ }
+ }
+
+ public boolean isConnected() {
+ return isConnected;
+ }
+
+ public void startInBackground() {
+ new AsyncTask() {
+ @Override
+ protected Void doInBackground(Void... params) {
+ start();
+ return null;
+ }
+ }.execute();
+ }
+
+ public void ensureRunning() {
+ if (!isConnected()) {
+ start();
+ }
+ }
+
+ public void ensureRunningInBackground() {
+ new AsyncTask() {
+ @Override
+ protected Void doInBackground(Void... params) {
+ ensureRunning();
+ return null;
+ }
+ }.execute();
+ }
+
+ public void stopInBackground() {
+ new AsyncTask() {
+ @Override
+ protected Void doInBackground(Void... params) {
+ stop();
+ return null;
+ }
+ }.execute();
+ }
+
+}
diff --git a/F-Droid/src/org/fdroid/fdroid/localrepo/type/WifiSwap.java b/F-Droid/src/org/fdroid/fdroid/localrepo/type/WifiSwap.java
new file mode 100644
index 000000000..ec00bddf9
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/localrepo/type/WifiSwap.java
@@ -0,0 +1,105 @@
+package org.fdroid.fdroid.localrepo.type;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.util.Log;
+
+import org.fdroid.fdroid.FDroidApp;
+import org.fdroid.fdroid.Preferences;
+import org.fdroid.fdroid.localrepo.SwapService;
+import org.fdroid.fdroid.net.LocalHTTPD;
+import org.fdroid.fdroid.net.WifiStateChangeService;
+
+import java.io.IOException;
+import java.net.BindException;
+import java.util.Random;
+
+public class WifiSwap extends SwapType {
+
+ private static final String TAG = "WebServerType";
+
+ private Handler webServerThreadHandler = null;
+ private LocalHTTPD localHttpd;
+ private final BonjourBroadcast bonjourBroadcast;
+
+ public WifiSwap(Context context) {
+ super(context);
+ bonjourBroadcast = new BonjourBroadcast(context);
+ }
+
+ protected String getBroadcastAction() {
+ return SwapService.WIFI_STATE_CHANGE;
+ }
+
+ public BonjourBroadcast getBonjour() {
+ return bonjourBroadcast;
+ }
+
+ @Override
+ public void start() {
+
+ Log.d(TAG, "Preparing swap webserver.");
+ sendBroadcast(SwapService.EXTRA_STARTING);
+
+ Runnable webServer = new Runnable() {
+ // Tell Eclipse this is not a leak because of Looper use.
+ @SuppressLint("HandlerLeak")
+ @Override
+ public void run() {
+ localHttpd = new LocalHTTPD(
+ context,
+ FDroidApp.ipAddressString,
+ FDroidApp.port,
+ context.getFilesDir(),
+ Preferences.get().isLocalRepoHttpsEnabled());
+
+ Looper.prepare(); // must be run before creating a Handler
+ webServerThreadHandler = new Handler() {
+ @Override
+ public void handleMessage(Message msg) {
+ Log.i(TAG, "we've been asked to stop the webserver: " + msg.obj);
+ setConnected(false);
+ localHttpd.stop();
+ }
+ };
+ try {
+ Log.d(TAG, "Starting swap webserver...");
+ localHttpd.start();
+ setConnected(true);
+ Log.d(TAG, "Swap webserver started.");
+ } catch (BindException e) {
+ int prev = FDroidApp.port;
+ FDroidApp.port = FDroidApp.port + new Random().nextInt(1111);
+ setConnected(false);
+ Log.w(TAG, "port " + prev + " occupied, trying on " + FDroidApp.port + "!");
+ context.startService(new Intent(context, WifiStateChangeService.class));
+ } catch (IOException e) {
+ setConnected(false);
+ Log.e(TAG, "Could not start local repo HTTP server: " + e);
+ Log.e(TAG, Log.getStackTraceString(e));
+ }
+ Looper.loop(); // start the message receiving loop
+ }
+ };
+ new Thread(webServer).start();
+ bonjourBroadcast.start();
+ }
+
+ @Override
+ public void stop() {
+ if (webServerThreadHandler == null) {
+ Log.i(TAG, "null handler in stopWebServer");
+ } else {
+ Log.d(TAG, "Sending message to swap webserver to stop it.");
+ Message msg = webServerThreadHandler.obtainMessage();
+ msg.obj = webServerThreadHandler.getLooper().getThread().getName() + " says stop";
+ webServerThreadHandler.sendMessage(msg);
+ }
+ bonjourBroadcast.stop();
+ }
+
+}
diff --git a/F-Droid/src/org/fdroid/fdroid/net/ApkDownloader.java b/F-Droid/src/org/fdroid/fdroid/net/ApkDownloader.java
index 6c8b5a9ba..8fc64b83e 100644
--- a/F-Droid/src/org/fdroid/fdroid/net/ApkDownloader.java
+++ b/F-Droid/src/org/fdroid/fdroid/net/ApkDownloader.java
@@ -21,8 +21,11 @@
package org.fdroid.fdroid.net;
import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
import android.os.Bundle;
import android.support.annotation.NonNull;
+import android.support.v4.content.LocalBroadcastManager;
import android.util.Log;
import org.fdroid.fdroid.Hasher;
@@ -50,6 +53,10 @@ public class ApkDownloader implements AsyncDownloadWrapper.Listener {
public static final String EVENT_APK_DOWNLOAD_CANCELLED = "apkDownloadCancelled";
public static final String EVENT_ERROR = "apkDownloadError";
+ public static final String ACTION_STATUS = "apkDownloadStatus";
+ public static final String EXTRA_TYPE = "apkDownloadStatusType";
+ public static final String EXTRA_URL = "apkDownloadUrl";
+
public static final int ERROR_HASH_MISMATCH = 101;
public static final int ERROR_DOWNLOAD_FAILED = 102;
@@ -105,10 +112,6 @@ public class ApkDownloader implements AsyncDownloadWrapper.Listener {
return event.getData().containsKey(EVENT_SOURCE_ID) && event.getData().getLong(EVENT_SOURCE_ID) == id;
}
- public String getRemoteAddress() {
- return repoAddress + "/" + curApk.apkName.replace(" ", "%20");
- }
-
private Hasher createHasher(File apkFile) {
Hasher hasher;
try {
@@ -186,7 +189,7 @@ public class ApkDownloader implements AsyncDownloadWrapper.Listener {
return false;
}
- String remoteAddress = getRemoteAddress();
+ String remoteAddress = Utils.getApkUrl(repoAddress, curApk);
Utils.DebugLog(TAG, "Downloading apk from " + remoteAddress + " to " + localFile);
try {
@@ -212,6 +215,7 @@ public class ApkDownloader implements AsyncDownloadWrapper.Listener {
sendProgressEvent(new Event(EVENT_ERROR, data));
}
+ // TODO: Completely remove progress listener, only use broadcasts...
private void sendProgressEvent(Event event) {
event.getData().putLong(EVENT_SOURCE_ID, id);
@@ -219,6 +223,12 @@ public class ApkDownloader implements AsyncDownloadWrapper.Listener {
if (listener != null) {
listener.onProgress(event);
}
+
+ Intent intent = new Intent(ACTION_STATUS);
+ intent.putExtras(event.getData());
+ intent.putExtra(EXTRA_TYPE, event.type);
+ intent.putExtra(EXTRA_URL, Utils.getApkUrl(repoAddress, curApk));
+ LocalBroadcastManager.getInstance(context).sendBroadcast(intent);
}
@Override
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..b9d6e2e40
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/net/BluetoothDownloader.java
@@ -0,0 +1,92 @@
+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.BluetoothClient;
+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.IOException;
+import java.io.InputStream;
+import java.net.URL;
+
+public class BluetoothDownloader extends Downloader {
+
+ private static final String TAG = "BluetoothDownloader";
+
+ private final BluetoothConnection connection;
+ private FileDetails fileDetails;
+ private final String sourcePath;
+
+ public BluetoothDownloader(Context context, String macAddress, URL sourceUrl, File destFile) throws IOException {
+ super(context, sourceUrl, destFile);
+ this.connection = new BluetoothClient(macAddress).openConnection();
+ this.sourcePath = sourceUrl.getPath();
+ }
+
+ @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 weely-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 42f8977cd..90a4e4f05 100644
--- a/F-Droid/src/org/fdroid/fdroid/net/Downloader.java
+++ b/F-Droid/src/org/fdroid/fdroid/net/Downloader.java
@@ -108,7 +108,7 @@ public abstract class Downloader {
// we were interrupted before proceeding to the download.
throwExceptionIfInterrupted();
- copyInputToOutputStream(getInputStream());
+ copyInputToOutputStream(input);
} finally {
Utils.closeQuietly(outputStream);
Utils.closeQuietly(input);
@@ -163,6 +163,7 @@ public abstract class Downloader {
Utils.DebugLog(TAG, "Finished downloading from stream");
break;
}
+
bytesRead += count;
sendProgress(bytesRead, totalBytes);
outputStream.write(buffer, 0, count);
diff --git a/F-Droid/src/org/fdroid/fdroid/net/DownloaderFactory.java b/F-Droid/src/org/fdroid/fdroid/net/DownloaderFactory.java
index fdc7b3255..5b476dd1c 100644
--- a/F-Droid/src/org/fdroid/fdroid/net/DownloaderFactory.java
+++ b/F-Droid/src/org/fdroid/fdroid/net/DownloaderFactory.java
@@ -37,10 +37,18 @@ public class DownloaderFactory {
public static Downloader create(Context context, URL url, File destFile)
throws IOException {
- if (isOnionAddress(url)) {
+ if (isBluetoothAddress(url)) {
+ String macAddress = url.getHost().replace("-", ":");
+ return new BluetoothDownloader(context, macAddress, url, destFile);
+ } else if (isOnionAddress(url)) {
return new TorHttpDownloader(context, url, destFile);
+ } else {
+ return new HttpDownloader(context, url, destFile);
}
- return new HttpDownloader(context, url, destFile);
+ }
+
+ private static boolean isBluetoothAddress(URL url) {
+ return "bluetooth".equalsIgnoreCase(url.getProtocol());
}
private static boolean isOnionAddress(URL url) {
diff --git a/F-Droid/src/org/fdroid/fdroid/net/HttpDownloader.java b/F-Droid/src/org/fdroid/fdroid/net/HttpDownloader.java
index 55ea1bc92..02ad333bd 100644
--- a/F-Droid/src/org/fdroid/fdroid/net/HttpDownloader.java
+++ b/F-Droid/src/org/fdroid/fdroid/net/HttpDownloader.java
@@ -3,9 +3,12 @@ package org.fdroid.fdroid.net;
import android.content.Context;
import android.util.Log;
+import com.nostra13.universalimageloader.core.download.BaseImageDownloader;
+
import org.fdroid.fdroid.Preferences;
import org.fdroid.fdroid.Utils;
+import javax.net.ssl.SSLHandshakeException;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
@@ -17,8 +20,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";
@@ -27,16 +28,35 @@ public class HttpDownloader extends Downloader {
protected HttpURLConnection connection;
private int statusCode = -1;
+ private boolean onlyStream = false;
HttpDownloader(Context context, URL url, File destFile)
throws FileNotFoundException, MalformedURLException {
super(context, url, destFile);
}
+ /**
+ * 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;
+ }
+
+ /**
+ * Note: Doesn't follow redirects (as far as I'm aware).
+ * {@link BaseImageDownloader#getStreamFromNetwork(String, Object)} has an implementation worth
+ * checking out that follows redirects up to a certain point. I guess though the correct way
+ * is probably to check for a loop (keep a list of all URLs redirected to and if you hit the
+ * same one twice, bail with an exception).
+ * @throws IOException
+ */
@Override
public InputStream getInputStream() throws IOException {
setupConnection();
- // TODO check out BaseImageDownloader.getStreamFromNetwork() for optims
return connection.getInputStream();
}
@@ -123,4 +143,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 9c1129e81..79b149061 100644
--- a/F-Droid/src/org/fdroid/fdroid/net/LocalHTTPD.java
+++ b/F-Droid/src/org/fdroid/fdroid/net/LocalHTTPD.java
@@ -10,7 +10,7 @@ import org.fdroid.fdroid.BuildConfig;
import org.fdroid.fdroid.FDroidApp;
import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.localrepo.LocalRepoKeyStore;
-import org.fdroid.fdroid.views.swap.ConnectSwapActivity;
+import org.fdroid.fdroid.views.swap.SwapWorkflowActivity;
import java.io.File;
import java.io.FileInputStream;
@@ -37,8 +37,8 @@ public class LocalHTTPD extends NanoHTTPD {
private final Context context;
private final File webRoot;
- 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.webRoot = webRoot;
this.context = context.getApplicationContext();
if (useHttps)
@@ -77,10 +77,11 @@ public class LocalHTTPD extends NanoHTTPD {
Utils.DebugLog(TAG, "Showing confirm screen to check whether that is okay with the user.");
Uri repoUri = Uri.parse(repo);
- Intent intent = new Intent(context, ConnectSwapActivity.class);
+ Intent intent = new Intent(context, SwapWorkflowActivity.class);
intent.setData(repoUri);
+ intent.putExtra(SwapWorkflowActivity.EXTRA_CONFIRM, true);
+ intent.putExtra(SwapWorkflowActivity.EXTRA_PREVENT_FURTHER_SWAP_REQUESTS, true);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
- intent.putExtra(ConnectSwapActivity.EXTRA_PREVENT_FURTHER_SWAP_REQUESTS, true);
context.startActivity(intent);
}
diff --git a/F-Droid/src/org/fdroid/fdroid/net/MDnsHelper.java b/F-Droid/src/org/fdroid/fdroid/net/MDnsHelper.java
deleted file mode 100644
index 56cf90134..000000000
--- a/F-Droid/src/org/fdroid/fdroid/net/MDnsHelper.java
+++ /dev/null
@@ -1,271 +0,0 @@
-package org.fdroid.fdroid.net;
-
-import android.app.Activity;
-import android.content.Context;
-import android.net.wifi.WifiManager;
-import android.net.wifi.WifiManager.MulticastLock;
-import android.os.AsyncTask;
-import android.os.Handler;
-import android.os.Looper;
-import android.util.Log;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.BaseAdapter;
-import android.widget.RelativeLayout;
-import android.widget.TextView;
-
-import org.fdroid.fdroid.R;
-
-import java.io.IOException;
-import java.net.InetAddress;
-import java.util.ArrayList;
-import java.util.List;
-
-import javax.jmdns.JmDNS;
-import javax.jmdns.ServiceEvent;
-import javax.jmdns.ServiceInfo;
-import javax.jmdns.ServiceListener;
-
-public class MDnsHelper implements ServiceListener {
-
- private static final String TAG = "MDnsHelper";
- public static final String HTTP_SERVICE_TYPE = "_http._tcp.local.";
- public static final String HTTPS_SERVICE_TYPE = "_https._tcp.local.";
-
- final Activity mActivity;
- final RepoScanListAdapter mAdapter;
-
- private JmDNS mJmdns;
- private final WifiManager wifiManager;
- private final MulticastLock mMulticastLock;
-
- public MDnsHelper(Activity activity, final RepoScanListAdapter adapter) {
- mActivity = activity;
- mAdapter = adapter;
- wifiManager = (WifiManager) activity.getSystemService(Context.WIFI_SERVICE);
- mMulticastLock = wifiManager.createMulticastLock(activity.getPackageName());
- mMulticastLock.setReferenceCounted(false);
- }
-
- @Override
- public void serviceRemoved(ServiceEvent event) {
- // a ListView Adapter can only be updated on the UI thread
- final ServiceInfo serviceInfo = event.getInfo();
- mActivity.runOnUiThread(new Runnable() {
- @Override
- public void run() {
- mAdapter.removeItem(serviceInfo);
- }
- });
- }
-
- @Override
- public void serviceAdded(final ServiceEvent event) {
- addFDroidService(event);
- new AsyncTask() {
- @Override
- protected Void doInBackground(Void... params) {
- mJmdns.requestServiceInfo(event.getType(), event.getName(), true);
- return null;
- }
- }.execute();
- }
-
- @Override
- public void serviceResolved(ServiceEvent event) {
- addFDroidService(event);
- }
-
- private void addFDroidService(ServiceEvent event) {
- // a ListView Adapter can only be updated on the UI thread
- final ServiceInfo serviceInfo = event.getInfo();
- String type = serviceInfo.getPropertyString("type");
- if (type.startsWith("fdroidrepo"))
- mActivity.runOnUiThread(new Runnable() {
- @Override
- public void run() {
- mAdapter.addItem(serviceInfo);
- }
- });
- }
-
- public void discoverServices() {
- mMulticastLock.acquire();
- new AsyncTask() {
-
- @Override
- protected Void doInBackground(Void... params) {
- try {
- int ip = wifiManager.getConnectionInfo().getIpAddress();
- byte[] byteIp = {
- (byte) (ip & 0xff),
- (byte) (ip >> 8 & 0xff),
- (byte) (ip >> 16 & 0xff),
- (byte) (ip >> 24 & 0xff)
- };
- mJmdns = JmDNS.create(InetAddress.getByAddress(byteIp));
- } catch (IOException e) {
- Log.e(TAG, "An error occured while discovering services", e);
- }
- return null;
- }
-
- @Override
- protected void onPostExecute(Void result) {
- if (mJmdns != null) {
- mJmdns.addServiceListener(HTTP_SERVICE_TYPE, MDnsHelper.this);
- mJmdns.addServiceListener(HTTPS_SERVICE_TYPE, MDnsHelper.this);
- }
- }
- }.execute();
- }
-
- public void stopDiscovery() {
- mMulticastLock.release();
- if (mJmdns == null)
- return;
- mJmdns.removeServiceListener(HTTP_SERVICE_TYPE, MDnsHelper.this);
- mJmdns.removeServiceListener(HTTPS_SERVICE_TYPE, MDnsHelper.this);
- mJmdns = null;
- }
-
- public static class RepoScanListAdapter extends BaseAdapter {
- private final Context mContext;
- private final LayoutInflater mLayoutInflater;
- private final List mEntries = new ArrayList<>();
-
- public RepoScanListAdapter(Context context) {
- mContext = context;
- mLayoutInflater = (LayoutInflater) mContext
- .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
- }
-
- @Override
- public int getCount() {
- return mEntries.size();
- }
-
- @Override
- public Object getItem(int position) {
- return mEntries.get(position);
- }
-
- @Override
- public long getItemId(int position) {
- return position;
- }
-
- @Override
- public boolean isEnabled(int position) {
- DiscoveredRepo service = mEntries.get(position);
- ServiceInfo serviceInfo = service.getServiceInfo();
- InetAddress[] addresses = serviceInfo.getInetAddresses();
- return (addresses != null && addresses.length > 0);
- }
-
- @Override
- public View getView(int position, View convertView, ViewGroup parent) {
- RelativeLayout itemView;
- if (convertView == null) {
- itemView = (RelativeLayout) mLayoutInflater.inflate(
- R.layout.repodiscoveryitem, parent, false);
- } else {
- itemView = (RelativeLayout) convertView;
- }
-
- TextView nameLabel = (TextView) itemView.findViewById(R.id.reposcanitemname);
- TextView addressLabel = (TextView) itemView.findViewById(R.id.reposcanitemaddress);
-
- final DiscoveredRepo service = mEntries.get(position);
- final ServiceInfo serviceInfo = service.getServiceInfo();
-
- nameLabel.setText(serviceInfo.getName());
-
- InetAddress[] addresses = serviceInfo.getInetAddresses();
- if (addresses != null && addresses.length > 0) {
- String addressTxt = "Hosted @ " + addresses[0] + ":" + serviceInfo.getPort();
- addressLabel.setText(addressTxt);
- }
-
- return itemView;
- }
-
- public void addItem(ServiceInfo item) {
- if (item == null || item.getName() == null)
- return;
-
- // Construct a DiscoveredRepo wrapper for the service being
- // added in order to use a name based equals().
- DiscoveredRepo newDRepo = new DiscoveredRepo(item);
- // if an unresolved entry with the same name exists, remove it
- for (DiscoveredRepo dr : mEntries)
- if (dr.equals(newDRepo)) {
- InetAddress[] addresses = dr.mServiceInfo.getInetAddresses();
- if (addresses == null || addresses.length == 0)
- mEntries.remove(dr);
- }
- mEntries.add(newDRepo);
-
- notifyUpdate();
- }
-
- public void removeItem(ServiceInfo item) {
- if (item == null || item.getName() == null)
- return;
-
- // Construct a DiscoveredRepo wrapper for the service being
- // removed in order to use a name based equals().
- DiscoveredRepo lostServiceBean = new DiscoveredRepo(item);
-
- if (mEntries.contains(lostServiceBean)) {
- mEntries.remove(lostServiceBean);
- notifyUpdate();
- }
- }
-
- private void notifyUpdate() {
- // Need to call notifyDataSetChanged from the UI thread
- // in order for it to update the ListView without error
- Handler refresh = new Handler(Looper.getMainLooper());
- refresh.post(new Runnable() {
- @Override
- public void run() {
- notifyDataSetChanged();
- }
- });
- }
- }
-
- public static class DiscoveredRepo {
- private final ServiceInfo mServiceInfo;
-
- public DiscoveredRepo(ServiceInfo serviceInfo) {
- if (serviceInfo == null || serviceInfo.getName() == null)
- throw new IllegalArgumentException(
- "Parameters \"serviceInfo\" and \"name\" must not be null.");
- mServiceInfo = serviceInfo;
- }
-
- public ServiceInfo getServiceInfo() {
- return mServiceInfo;
- }
-
- public String getName() {
- return mServiceInfo.getName();
- }
-
- @Override
- public boolean equals(Object other) {
- if (!(other instanceof DiscoveredRepo))
- return false;
-
- // Treat two services the same based on name. Eventually
- // there should be a persistent mapping between fingerprint
- // of the repo key and the discovered service such that we
- // could maintain trust across hostnames/ips/networks
- DiscoveredRepo otherRepo = (DiscoveredRepo) other;
- return getName().equals(otherRepo.getName());
- }
- }
-}
diff --git a/F-Droid/src/org/fdroid/fdroid/net/WifiStateChangeService.java b/F-Droid/src/org/fdroid/fdroid/net/WifiStateChangeService.java
index 398e65d59..88eeeda11 100644
--- a/F-Droid/src/org/fdroid/fdroid/net/WifiStateChangeService.java
+++ b/F-Droid/src/org/fdroid/fdroid/net/WifiStateChangeService.java
@@ -1,8 +1,10 @@
package org.fdroid.fdroid.net;
import android.app.Service;
+import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
+import android.content.ServiceConnection;
import android.net.NetworkInfo;
import android.net.wifi.WifiInfo;
import android.net.wifi.WifiManager;
@@ -17,6 +19,7 @@ import org.fdroid.fdroid.Preferences;
import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.localrepo.LocalRepoKeyStore;
import org.fdroid.fdroid.localrepo.LocalRepoManager;
+import org.fdroid.fdroid.localrepo.SwapService;
import java.net.Inet6Address;
import java.net.InetAddress;
@@ -37,6 +40,7 @@ public class WifiStateChangeService extends Service {
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
+ Log.d(TAG, "WiFi change service started, clearing info about wifi state until we have figured it out again.");
FDroidApp.initWifiSettings();
NetworkInfo ni = intent.getParcelableExtra(WifiManager.EXTRA_NETWORK_INFO);
wifiManager = (WifiManager) getSystemService(WIFI_SERVICE);
@@ -65,6 +69,7 @@ public class WifiStateChangeService extends Service {
@Override
protected Void doInBackground(Void... params) {
try {
+ Log.d(TAG, "Checking wifi state (in background thread).");
WifiInfo wifiInfo = null;
wifiState = wifiManager.getWifiState();
@@ -84,14 +89,20 @@ public class WifiStateChangeService extends Service {
} else { // a hotspot can be active during WIFI_STATE_UNKNOWN
FDroidApp.ipAddressString = getIpAddressFromNetworkInterface();
}
- Thread.sleep(1000);
- Utils.DebugLog(TAG, "waiting for an IP address...");
+
+ if (FDroidApp.ipAddressString == null) {
+ Thread.sleep(1000);
+ if (BuildConfig.DEBUG) {
+ Utils.DebugLog(TAG, "waiting for an IP address...");
+ }
+ }
}
if (isCancelled()) // can be canceled by a change via WifiStateChangeReceiver
return null;
if (wifiInfo != null) {
String ssid = wifiInfo.getSSID();
+ Log.d(TAG, "Have wifi info, connected to " + ssid);
if (ssid != null) {
FDroidApp.ssid = ssid.replaceAll("^\"(.*)\"$", "$1");
}
@@ -101,6 +112,7 @@ public class WifiStateChangeService extends Service {
}
}
+ // TODO: Can this be moved to the swap service instead?
String scheme;
if (Preferences.get().isLocalRepoHttpsEnabled())
scheme = "https";
@@ -146,7 +158,19 @@ public class WifiStateChangeService extends Service {
Intent intent = new Intent(BROADCAST);
LocalBroadcastManager.getInstance(WifiStateChangeService.this).sendBroadcast(intent);
WifiStateChangeService.this.stopSelf();
- FDroidApp.restartLocalRepoServiceIfRunning();
+
+ Intent swapService = new Intent(WifiStateChangeService.this, SwapService.class);
+ getApplicationContext().bindService(swapService, new ServiceConnection() {
+ @Override
+ public void onServiceConnected(ComponentName name, IBinder service) {
+ ((SwapService.Binder) service).getService().restartWifiIfEnabled();
+ getApplicationContext().unbindService(this);
+ }
+
+ @Override
+ public void onServiceDisconnected(ComponentName name) {
+ }
+ }, BIND_AUTO_CREATE);
}
}
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..b488f552e
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/net/bluetooth/BluetoothClient.java
@@ -0,0 +1,60 @@
+package org.fdroid.fdroid.net.bluetooth;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothSocket;
+import android.util.Log;
+
+import java.io.IOException;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
+public class BluetoothClient {
+
+ @SuppressWarnings("unused")
+ private static final String TAG = "BluetoothClient";
+
+ private final BluetoothDevice device;
+
+ public BluetoothClient(BluetoothDevice device) {
+ this.device = device;
+ }
+
+ public BluetoothClient(String macAddress) {
+ device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(macAddress);
+ }
+
+ public BluetoothConnection openConnection() throws IOException {
+ BluetoothSocket socket = null;
+ try {
+ socket = device.createInsecureRfcommSocketToServiceRecord(BluetoothConstants.fdroidUuid());
+ BluetoothConnection connection = new BluetoothConnection(socket);
+ connection.open();
+ return connection;
+ } catch (IOException e1) {
+ Log.e(TAG, "There was an error while establishing Bluetooth connection. Falling back to using reflection...");
+ Class> clazz = socket.getRemoteDevice().getClass();
+ Class>[] paramTypes = new Class>[]{Integer.TYPE};
+
+ Method method;
+ try {
+ method = clazz.getMethod("createInsecureRfcommSocket", paramTypes);
+ Object[] params = new Object[]{1};
+ BluetoothSocket sockFallback = (BluetoothSocket) method.invoke(socket.getRemoteDevice(), params);
+ BluetoothConnection connection = new BluetoothConnection(sockFallback);
+ connection.open();
+ return connection;
+ } catch (NoSuchMethodException e) {
+ throw e1;
+ } catch (IllegalAccessException e) {
+ throw e1;
+ } catch (InvocationTargetException e) {
+ throw e1;
+ }
+
+ // Don't catch exceptions this time, let it bubble up as we did our best but don't
+ // have anythign else to offer in terms of resolving the problem right now.
+ }
+ }
+
+}
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..bc1d5bcf5
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/net/bluetooth/BluetoothConnection.java
@@ -0,0 +1,62 @@
+package org.fdroid.fdroid.net.bluetooth;
+
+import android.annotation.TargetApi;
+import android.bluetooth.BluetoothDevice;
+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..35d7024cf
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/net/bluetooth/BluetoothConstants.java
@@ -0,0 +1,18 @@
+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 can be an offline process, as long as it can be reproduced by other people who
+ // want to do so.
+ // This UUID is just from mashing random hex characters on the keyboard.
+ return UUID.fromString("cd59ba31-5729-b3bb-cb29-732b59eb61aa");
+ }
+
+}
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..2379ceba6
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/net/bluetooth/BluetoothServer.java
@@ -0,0 +1,359 @@
+package org.fdroid.fdroid.net.bluetooth;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothServerSocket;
+import android.bluetooth.BluetoothSocket;
+import android.util.Log;
+import android.webkit.MimeTypeMap;
+
+import org.fdroid.fdroid.Utils;
+import org.fdroid.fdroid.localrepo.type.BluetoothSwap;
+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.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+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 File webRoot;
+ private final BluetoothSwap swap;
+ private boolean isRunning = false;
+
+ public BluetoothServer(BluetoothSwap swap, File webRoot) {
+ this.webRoot = webRoot;
+ this.swap = swap;
+ }
+
+ public boolean isRunning() { return isRunning; }
+
+ public void close() {
+
+ for (ClientConnection clientConnection : clients) {
+ clientConnection.interrupt();
+ }
+
+ interrupt();
+
+ if (serverSocket != null) {
+ Utils.closeQuietly(serverSocket);
+ }
+ }
+
+ @Override
+ public void run() {
+
+ isRunning = true;
+ BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
+
+ 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());
+ swap.stop();
+ isRunning = false;
+ return;
+ }
+
+ while (true) {
+ if (isInterrupted()) {
+ Log.d(TAG, "Server stopped so will terminate loop looking for client connections.");
+ break;
+ }
+
+ try {
+ BluetoothSocket clientSocket = serverSocket.accept();
+ if (clientSocket != null) {
+ if (!isInterrupted()) {
+ Log.d(TAG, "Server stopped after socket accepted from client, but before initiating connection.");
+ break;
+ }
+ ClientConnection client = new ClientConnection(clientSocket, webRoot);
+ client.start();
+ clients.add(client);
+ }
+ } catch (IOException e) {
+ Log.e(TAG, "Error receiving client connection over Bluetooth server socket, will continue listening for other clients: " + e.getMessage());
+ }
+ }
+ isRunning = false;
+ }
+
+ private static class ClientConnection extends Thread {
+
+ private final BluetoothSocket socket;
+ private final File webRoot;
+
+ public ClientConnection(BluetoothSocket socket, File webRoot) {
+ 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());
+ break;
+ }
+
+ 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 {
+ 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..afed47312
--- /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 1.1 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..2a6ef90bb
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/net/bluetooth/httpish/Response.java
@@ -0,0 +1,194 @@
+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.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStreamWriter;
+import java.io.UnsupportedEncodingException;
+import java.io.Writer;
+import java.util.HashMap;
+import java.util.Locale;
+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.headers.put("Content-Type", mimeType);
+ try {
+ this.contentStream = new ByteArrayInputStream(content.getBytes("UTF-8"));
+ } catch (UnsupportedEncodingException e) {
+ // Not quite sure what to do in the case of a phone not supporting UTF-8, so lets
+ // throw a runtime exception and hope that we get good bug reports if this ever happens.
+ Log.e(TAG, "Device does not support UTF-8: " + e.getMessage());
+ throw new IllegalStateException("Device does not support UTF-8.", e);
+ }
+ }
+
+ public Response(int statusCode, String mimeType, InputStream contentStream) {
+ this.statusCode = statusCode;
+ this.headers = new HashMap<>();
+ this.headers.put("Content-Type", mimeType);
+ 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(Locale.ENGLISH).equals("content-length")) {
+ try {
+ return Integer.parseInt(entry.getValue());
+ } catch (NumberFormatException e) {
+ return -1;
+ }
+ }
+ }
+ }
+ 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..1a529099d
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/net/bluetooth/httpish/headers/Header.java
@@ -0,0 +1,27 @@
+package org.fdroid.fdroid.net.bluetooth.httpish.headers;
+
+import org.fdroid.fdroid.net.bluetooth.FileDetails;
+
+import java.util.Locale;
+
+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(Locale.ENGLISH);
+ 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/AppListAdapter.java b/F-Droid/src/org/fdroid/fdroid/views/AppListAdapter.java
index fe1df03c2..3036c1b1c 100644
--- a/F-Droid/src/org/fdroid/fdroid/views/AppListAdapter.java
+++ b/F-Droid/src/org/fdroid/fdroid/views/AppListAdapter.java
@@ -18,6 +18,7 @@ import com.nostra13.universalimageloader.core.display.FadeInBitmapDisplayer;
import org.fdroid.fdroid.Preferences;
import org.fdroid.fdroid.R;
+import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.data.App;
abstract public class AppListAdapter extends CursorAdapter {
@@ -50,15 +51,7 @@ abstract public class AppListAdapter extends CursorAdapter {
mContext = context;
mInflater = (LayoutInflater) mContext.getSystemService(
Context.LAYOUT_INFLATER_SERVICE);
- displayImageOptions = new DisplayImageOptions.Builder()
- .cacheInMemory(true)
- .cacheOnDisk(true)
- .imageScaleType(ImageScaleType.NONE)
- .showImageOnLoading(R.drawable.ic_repo_app_default)
- .showImageForEmptyUri(R.drawable.ic_repo_app_default)
- .displayer(new FadeInBitmapDisplayer(200, true, true, false))
- .bitmapConfig(Bitmap.Config.RGB_565)
- .build();
+ displayImageOptions = Utils.getImageLoadingOptions().build();
}
diff --git a/F-Droid/src/org/fdroid/fdroid/views/ManageReposActivity.java b/F-Droid/src/org/fdroid/fdroid/views/ManageReposActivity.java
index 7a24a5b09..7927260fd 100644
--- a/F-Droid/src/org/fdroid/fdroid/views/ManageReposActivity.java
+++ b/F-Droid/src/org/fdroid/fdroid/views/ManageReposActivity.java
@@ -71,9 +71,6 @@ import org.fdroid.fdroid.compat.ClipboardCompat;
import org.fdroid.fdroid.data.NewRepoConfig;
import org.fdroid.fdroid.data.Repo;
import org.fdroid.fdroid.data.RepoProvider;
-import org.fdroid.fdroid.net.MDnsHelper;
-import org.fdroid.fdroid.net.MDnsHelper.DiscoveredRepo;
-import org.fdroid.fdroid.net.MDnsHelper.RepoScanListAdapter;
import java.io.IOException;
import java.net.MalformedURLException;
@@ -83,8 +80,6 @@ import java.net.URL;
import java.util.Date;
import java.util.Locale;
-import javax.jmdns.ServiceInfo;
-
public class ManageReposActivity extends ActionBarActivity {
private static final String TAG = "ManageReposActivity";
@@ -177,62 +172,10 @@ public class ManageReposActivity extends ActionBarActivity {
case R.id.action_update_repo:
UpdateService.updateNow(this);
return true;
- case R.id.action_find_local_repos:
- scanForRepos();
- return true;
}
return super.onOptionsItemSelected(item);
}
- private void scanForRepos() {
- final RepoScanListAdapter adapter = new RepoScanListAdapter(this);
- final MDnsHelper mDnsHelper = new MDnsHelper(this, adapter);
-
- final View view = getLayoutInflater().inflate(R.layout.repodiscoverylist, null);
- final ListView repoScanList = (ListView) view.findViewById(R.id.reposcanlist);
-
- final AlertDialog alrt = new AlertDialog.Builder(this).setView(view)
- .setNegativeButton(R.string.cancel, new DialogInterface.OnClickListener() {
- @Override
- public void onClick(DialogInterface dialog, int which) {
- mDnsHelper.stopDiscovery();
- dialog.dismiss();
- }
- }).create();
-
- alrt.setTitle(R.string.local_repos_title);
- alrt.setOnDismissListener(new DialogInterface.OnDismissListener() {
- @Override
- public void onDismiss(DialogInterface dialog) {
- mDnsHelper.stopDiscovery();
- }
- });
-
- repoScanList.setAdapter(adapter);
- repoScanList.setOnItemClickListener(new AdapterView.OnItemClickListener() {
- @Override
- public void onItemClick(AdapterView> parent, final View view,
- int position, long id) {
-
- final DiscoveredRepo discoveredService =
- (DiscoveredRepo) parent.getItemAtPosition(position);
-
- final ServiceInfo serviceInfo = discoveredService.getServiceInfo();
- String type = serviceInfo.getPropertyString("type");
- String protocol = type.contains("fdroidrepos") ? "https:/" : "http:/";
- String path = serviceInfo.getPropertyString("path");
- if (TextUtils.isEmpty(path))
- path = "/fdroid/repo";
- String serviceUrl = protocol + serviceInfo.getInetAddresses()[0]
- + ":" + serviceInfo.getPort() + path;
- showAddRepo(serviceUrl, serviceInfo.getPropertyString("fingerprint"));
- }
- });
-
- alrt.show();
- mDnsHelper.discoverServices();
- }
-
private void showAddRepo() {
/*
* If there is text in the clipboard, and it looks like a URL, use that.
diff --git a/F-Droid/src/org/fdroid/fdroid/views/fragments/PreferencesFragment.java b/F-Droid/src/org/fdroid/fdroid/views/fragments/PreferencesFragment.java
index 338c117b7..4b111c0d4 100644
--- a/F-Droid/src/org/fdroid/fdroid/views/fragments/PreferencesFragment.java
+++ b/F-Droid/src/org/fdroid/fdroid/views/fragments/PreferencesFragment.java
@@ -37,7 +37,6 @@ public class PreferencesFragment extends PreferenceFragment
Preferences.PREF_THEME,
Preferences.PREF_COMPACT_LAYOUT,
Preferences.PREF_IGN_TOUCH,
- Preferences.PREF_LOCAL_REPO_BONJOUR,
Preferences.PREF_LOCAL_REPO_NAME,
Preferences.PREF_LANGUAGE,
Preferences.PREF_CACHE_APK,
@@ -124,10 +123,6 @@ public class PreferencesFragment extends PreferenceFragment
checkSummary(key, R.string.ignoreTouch_on);
break;
- case Preferences.PREF_LOCAL_REPO_BONJOUR:
- checkSummary(key, R.string.local_repo_bonjour_on);
- break;
-
case Preferences.PREF_LOCAL_REPO_NAME:
textSummary(key, R.string.local_repo_name_summary);
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/ConfirmReceive.java b/F-Droid/src/org/fdroid/fdroid/views/swap/ConfirmReceive.java
new file mode 100644
index 000000000..bfbab9b4c
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/views/swap/ConfirmReceive.java
@@ -0,0 +1,93 @@
+package org.fdroid.fdroid.views.swap;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.os.Build;
+import android.support.annotation.ColorRes;
+import android.support.annotation.NonNull;
+import android.util.AttributeSet;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.View;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import org.fdroid.fdroid.R;
+import org.fdroid.fdroid.data.NewRepoConfig;
+import org.fdroid.fdroid.localrepo.SwapService;
+
+public class ConfirmReceive extends RelativeLayout implements SwapWorkflowActivity.InnerView {
+
+ private NewRepoConfig config;
+
+ public ConfirmReceive(Context context) {
+ super(context);
+ }
+
+ public ConfirmReceive(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public ConfirmReceive(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ @TargetApi(Build.VERSION_CODES.LOLLIPOP)
+ public ConfirmReceive(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ private SwapWorkflowActivity getActivity() {
+ return (SwapWorkflowActivity)getContext();
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ super.onFinishInflate();
+
+ findViewById(R.id.no_button).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ getActivity().denySwap();
+ }
+ });
+
+ findViewById(R.id.yes_button).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ getActivity().swapWith(config);
+ }
+ });
+ }
+
+ @Override
+ public boolean buildMenu(Menu menu, @NonNull MenuInflater inflater) {
+ return true;
+ }
+
+ @Override
+ public int getStep() {
+ return SwapService.STEP_CONFIRM_SWAP;
+ }
+
+ @Override
+ public int getPreviousStep() {
+ return SwapService.STEP_INTRO;
+ }
+
+ @ColorRes
+ public int getToolbarColour() {
+ return R.color.swap_blue;
+ }
+
+ @Override
+ public String getToolbarTitle() {
+ return getResources().getString(R.string.swap_confirm);
+ }
+
+ public void setup(NewRepoConfig config) {
+ this.config = config;
+ TextView descriptionTextView = (TextView) findViewById(R.id.text_description);
+ descriptionTextView.setText(getResources().getString(R.string.swap_confirm_connect, config.getHost()));
+ }
+}
diff --git a/F-Droid/src/org/fdroid/fdroid/views/swap/ConnectSwapActivity.java b/F-Droid/src/org/fdroid/fdroid/views/swap/ConnectSwapActivity.java
deleted file mode 100644
index 608ec88ce..000000000
--- a/F-Droid/src/org/fdroid/fdroid/views/swap/ConnectSwapActivity.java
+++ /dev/null
@@ -1,229 +0,0 @@
-package org.fdroid.fdroid.views.swap;
-
-import android.app.Activity;
-import android.content.BroadcastReceiver;
-import android.content.ContentValues;
-import android.content.Context;
-import android.content.Intent;
-import android.content.IntentFilter;
-import android.net.Uri;
-import android.net.http.AndroidHttpClient;
-import android.os.AsyncTask;
-import android.os.Bundle;
-import android.support.annotation.Nullable;
-import android.support.v4.content.LocalBroadcastManager;
-import android.support.v7.app.ActionBarActivity;
-import android.util.Log;
-import android.view.View;
-import android.widget.TextView;
-import android.widget.Toast;
-
-import org.apache.http.HttpHost;
-import org.apache.http.NameValuePair;
-import org.apache.http.client.entity.UrlEncodedFormEntity;
-import org.apache.http.client.methods.HttpPost;
-import org.apache.http.message.BasicNameValuePair;
-import org.fdroid.fdroid.FDroidApp;
-import org.fdroid.fdroid.R;
-import org.fdroid.fdroid.UpdateService;
-import org.fdroid.fdroid.Utils;
-import org.fdroid.fdroid.data.NewRepoConfig;
-import org.fdroid.fdroid.data.Repo;
-import org.fdroid.fdroid.data.RepoProvider;
-
-import java.io.IOException;
-import java.io.UnsupportedEncodingException;
-import java.util.ArrayList;
-import java.util.List;
-
-public class ConnectSwapActivity extends ActionBarActivity {
- private static final String TAG = "ConnectSwapActivity";
-
- private static final String STATE_CONFIRM = "startSwap";
-
- /**
- * When connecting to a swap, we then go and initiate a connection with that
- * device and ask if it would like to swap with us. Upon receiving that request
- * and agreeing, we don't then want to be asked whether we want to swap back.
- * This flag protects against two devices continually going back and forth
- * among each other offering swaps.
- */
- public static final String EXTRA_PREVENT_FURTHER_SWAP_REQUESTS = "preventFurtherSwap";
-
- @Nullable
- private Repo repo;
-
- private NewRepoConfig newRepoConfig;
- private TextView descriptionTextView;
-
- @Override
- public void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
-
- setContentView(R.layout.swap_confirm_receive);
-
- descriptionTextView = (TextView) findViewById(R.id.text_description);
-
- findViewById(R.id.no_button).setOnClickListener(new View.OnClickListener() {
- @Override
- public void onClick(View v) {
- setResult(Activity.RESULT_OK);
- finish();
- }
- });
- findViewById(R.id.yes_button).setOnClickListener(new View.OnClickListener() {
- @Override
- public void onClick(View v) {
- confirm();
- }
- });
- }
-
- @Override
- protected void onResume() {
- super.onResume();
-
- LocalBroadcastManager.getInstance(this).registerReceiver(broadcastReceiver,
- new IntentFilter(UpdateService.LOCAL_ACTION_STATUS));
-
- // Only confirm the action, and then return a result...
- newRepoConfig = new NewRepoConfig(this, getIntent());
- if (newRepoConfig.isValidRepo()) {
- descriptionTextView.setText(getString(R.string.swap_confirm_connect, newRepoConfig.getHost()));
- } else {
- // TODO: Show error message on screen (not in popup).
- // TODO: I don't think we want to continue with this at all if the repo config is invalid,
- // how should we boot the user from this screen in this case?
- }
- }
-
- @Override
- protected void onPause() {
- super.onPause();
- LocalBroadcastManager.getInstance(this).unregisterReceiver(broadcastReceiver);
- }
-
- final BroadcastReceiver broadcastReceiver = new BroadcastReceiver() {
- @Override
- public void onReceive(Context context, Intent intent) {
- // TODO: Show progress, but we can worry about that later.
- // Might be nice to have it nicely embedded in the UI, rather than as
- // an additional dialog. E.g. White text on blue, letting the user
- // know what we are up to.
- int statusCode = intent.getIntExtra(UpdateService.EXTRA_STATUS_CODE, -1);
-
- switch (statusCode) {
- case UpdateService.STATUS_COMPLETE_AND_SAME:
- Utils.DebugLog(TAG, "STATUS_COMPLETE_AND_SAME");
- case UpdateService.STATUS_COMPLETE_WITH_CHANGES:
- Utils.DebugLog(TAG, "STATUS_COMPLETE_WITH_CHANGES");
- Intent salIntent = new Intent(getBaseContext(), SwapAppListActivity.class);
- salIntent.putExtra(SwapAppListActivity.EXTRA_REPO_ID, repo.getId());
- startActivity(salIntent);
- finish();
- /*
- // TODO: Load repo from database to get proper name. This is what the category we want to select will be called.
- intent.putExtra("category", newRepoConfig.getHost());
- getActivity().setResult(Activity.RESULT_OK, intent);
- */
- break;
- case UpdateService.STATUS_ERROR_GLOBAL:
- // TODO: Show message on this screen (with a big "okay" button that goes back to F-Droid activity)
- // rather than finishing directly.
- finish();
- break;
- }
- }
- };
-
- private void confirm() {
- repo = ensureRepoExists();
- if (repo != null) {
- UpdateService.updateRepoNow(repo.address, this);
- }
- }
-
- private Repo ensureRepoExists() {
- if (!newRepoConfig.isValidRepo()) {
- return null;
- }
-
- // TODO: newRepoConfig.getParsedUri() will include a fingerprint, which may not match with
- // the repos address in the database. Not sure on best behaviour in this situation.
- Repo repo = RepoProvider.Helper.findByAddress(this, newRepoConfig.getRepoUriString());
- if (repo == null) {
- ContentValues values = new ContentValues(6);
-
- // TODO: i18n and think about most appropriate name. Although it wont be visible in
- // the "Manage repos" UI after being marked as a swap repo here...
- values.put(RepoProvider.DataColumns.NAME, getString(R.string.swap_repo_name));
- values.put(RepoProvider.DataColumns.ADDRESS, newRepoConfig.getRepoUriString());
- values.put(RepoProvider.DataColumns.DESCRIPTION, ""); // TODO;
- values.put(RepoProvider.DataColumns.FINGERPRINT, newRepoConfig.getFingerprint());
- values.put(RepoProvider.DataColumns.IN_USE, true);
- values.put(RepoProvider.DataColumns.IS_SWAP, true);
- Uri uri = RepoProvider.Helper.insert(this, values);
- repo = RepoProvider.Helper.findByUri(this, uri);
- }
-
- // Only ask server to swap with us, if we are actually running a local repo service.
- // It is possible to have a swap initiated without first starting a swap, in which
- // case swapping back is pointless.
- if (!newRepoConfig.preventFurtherSwaps() && FDroidApp.isLocalRepoServiceRunning()) {
- askServerToSwapWithUs();
- }
-
- return repo;
- }
-
- private void askServerToSwapWithUs() {
- if (!newRepoConfig.isValidRepo()) {
- return;
- }
-
- new AsyncTask() {
- @Override
- protected Void doInBackground(Void... args) {
- Uri repoUri = newRepoConfig.getRepoUri();
- String swapBackUri = Utils.getLocalRepoUri(FDroidApp.repo).toString();
-
- AndroidHttpClient client = AndroidHttpClient.newInstance("F-Droid", ConnectSwapActivity.this);
- HttpPost request = new HttpPost("/request-swap");
- HttpHost host = new HttpHost(repoUri.getHost(), repoUri.getPort(), repoUri.getScheme());
-
- try {
- Utils.DebugLog(TAG, "Asking server at " + newRepoConfig.getRepoUriString() +
- " to swap with us in return (by POSTing to \"/request-swap\" with repo \"" + swapBackUri + "\")...");
- populatePostParams(swapBackUri, request);
- client.execute(host, request);
- } catch (IOException e) {
- notifyOfErrorOnUiThread();
- Log.e(TAG, "Error while asking server to swap with us", e);
- } finally {
- client.close();
- }
- return null;
- }
-
- private void populatePostParams(String swapBackUri, HttpPost request) throws UnsupportedEncodingException {
- List params = new ArrayList<>();
- params.add(new BasicNameValuePair("repo", swapBackUri));
- UrlEncodedFormEntity encodedParams = new UrlEncodedFormEntity(params);
- request.setEntity(encodedParams);
- }
-
- private void notifyOfErrorOnUiThread() {
- runOnUiThread(new Runnable() {
- @Override
- public void run() {
- Toast.makeText(
- ConnectSwapActivity.this,
- R.string.swap_reciprocate_failed,
- Toast.LENGTH_LONG
- ).show();
- }
- });
- }
- }.execute();
- }
-}
diff --git a/F-Droid/src/org/fdroid/fdroid/views/swap/InitialLoadingView.java b/F-Droid/src/org/fdroid/fdroid/views/swap/InitialLoadingView.java
new file mode 100644
index 000000000..26f62680a
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/views/swap/InitialLoadingView.java
@@ -0,0 +1,68 @@
+package org.fdroid.fdroid.views.swap;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.os.Build;
+import android.support.annotation.ColorRes;
+import android.support.annotation.NonNull;
+import android.support.v4.view.MenuItemCompat;
+import android.util.AttributeSet;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.widget.CheckBox;
+import android.widget.CompoundButton;
+import android.widget.RelativeLayout;
+
+import org.fdroid.fdroid.Preferences;
+import org.fdroid.fdroid.R;
+import org.fdroid.fdroid.localrepo.SwapService;
+
+public class InitialLoadingView extends RelativeLayout implements SwapWorkflowActivity.InnerView {
+
+ public InitialLoadingView(Context context) {
+ super(context);
+ }
+
+ public InitialLoadingView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public InitialLoadingView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ @TargetApi(Build.VERSION_CODES.LOLLIPOP)
+ public InitialLoadingView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ private SwapWorkflowActivity getActivity() {
+ return (SwapWorkflowActivity)getContext();
+ }
+
+ @Override
+ public boolean buildMenu(Menu menu, @NonNull MenuInflater inflater) {
+ return true;
+ }
+
+ @Override
+ public int getStep() {
+ return SwapService.STEP_INITIAL_LOADING;
+ }
+
+ @Override
+ public int getPreviousStep() {
+ return SwapService.STEP_JOIN_WIFI;
+ }
+
+ @ColorRes
+ public int getToolbarColour() {
+ return R.color.swap_blue;
+ }
+
+ @Override
+ public String getToolbarTitle() {
+ return getResources().getString(R.string.swap);
+ }
+}
diff --git a/F-Droid/src/org/fdroid/fdroid/views/swap/JoinWifiFragment.java b/F-Droid/src/org/fdroid/fdroid/views/swap/JoinWifiFragment.java
deleted file mode 100644
index 9723945b9..000000000
--- a/F-Droid/src/org/fdroid/fdroid/views/swap/JoinWifiFragment.java
+++ /dev/null
@@ -1,109 +0,0 @@
-package org.fdroid.fdroid.views.swap;
-
-import android.app.Activity;
-import android.content.BroadcastReceiver;
-import android.content.Context;
-import android.content.Intent;
-import android.content.IntentFilter;
-import android.net.wifi.WifiManager;
-import android.os.Bundle;
-import android.support.v4.app.Fragment;
-import android.support.v4.content.LocalBroadcastManager;
-import android.support.v4.view.MenuItemCompat;
-import android.text.TextUtils;
-import android.view.LayoutInflater;
-import android.view.Menu;
-import android.view.MenuInflater;
-import android.view.MenuItem;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.ImageView;
-import android.widget.TextView;
-
-import org.fdroid.fdroid.FDroidApp;
-import org.fdroid.fdroid.R;
-import org.fdroid.fdroid.net.WifiStateChangeService;
-
-public class JoinWifiFragment extends Fragment {
-
- private final BroadcastReceiver onWifiChange = new BroadcastReceiver() {
- @Override
- public void onReceive(Context context, Intent intent) {
- refreshWifiState();
- }
- };
-
- @Override
- public void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setHasOptionsMenu(true);
- }
-
- @Override
- public void onCreateOptionsMenu(Menu menu, MenuInflater menuInflater) {
- menuInflater.inflate(R.menu.swap_next, menu);
- MenuItem nextMenuItem = menu.findItem(R.id.action_next);
- int flags = MenuItemCompat.SHOW_AS_ACTION_ALWAYS | MenuItemCompat.SHOW_AS_ACTION_WITH_TEXT;
- MenuItemCompat.setShowAsAction(nextMenuItem, flags);
- }
-
- @Override
- public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
- View joinWifiView = inflater.inflate(R.layout.swap_join_wifi, container, false);
- joinWifiView.setOnClickListener(new View.OnClickListener() {
- @Override
- public void onClick(View v) {
- openAvailableNetworks();
- }
- });
- return joinWifiView;
- }
-
- @Override
- public void onAttach(Activity activity) {
- super.onAttach(activity);
- // TODO: Listen for "Connecting..." state and reflect that in the view too.
- LocalBroadcastManager.getInstance(activity).registerReceiver(
- onWifiChange,
- new IntentFilter(WifiStateChangeService.BROADCAST));
- }
-
- @Override
- public void onResume() {
- super.onResume();
- refreshWifiState();
- }
-
- private void refreshWifiState() {
- View view = getView();
- if (view != null) {
- TextView descriptionView = (TextView) view.findViewById(R.id.text_description);
- ImageView wifiIcon = (ImageView) view.findViewById(R.id.wifi_icon);
- TextView ssidView = (TextView) view.findViewById(R.id.wifi_ssid);
- TextView tapView = (TextView) view.findViewById(R.id.wifi_available_networks_prompt);
- if (TextUtils.isEmpty(FDroidApp.bssid) && !TextUtils.isEmpty(FDroidApp.ipAddressString)) {
- // empty bssid with an ipAddress means hotspot mode
- descriptionView.setText(R.string.swap_join_this_hotspot);
- wifiIcon.setImageDrawable(getResources().getDrawable(R.drawable.hotspot));
- ssidView.setText(R.string.swap_active_hotspot);
- tapView.setText(R.string.swap_switch_to_wifi);
- } else if (TextUtils.isEmpty(FDroidApp.ssid)) {
- // not connected to or setup with any wifi network
- descriptionView.setText(R.string.swap_join_same_wifi);
- wifiIcon.setImageDrawable(getResources().getDrawable(R.drawable.wifi));
- ssidView.setText(R.string.swap_no_wifi_network);
- tapView.setText(R.string.swap_view_available_networks);
- } else {
- // connected to a regular wifi network
- descriptionView.setText(R.string.swap_join_same_wifi);
- wifiIcon.setImageDrawable(getResources().getDrawable(R.drawable.wifi));
- ssidView.setText(FDroidApp.ssid);
- tapView.setText(R.string.swap_view_available_networks);
- }
- }
- }
-
- private void openAvailableNetworks() {
- startActivity(new Intent(WifiManager.ACTION_PICK_WIFI_NETWORK));
- }
-}
diff --git a/F-Droid/src/org/fdroid/fdroid/views/swap/JoinWifiView.java b/F-Droid/src/org/fdroid/fdroid/views/swap/JoinWifiView.java
new file mode 100644
index 000000000..118eccdf6
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/views/swap/JoinWifiView.java
@@ -0,0 +1,142 @@
+package org.fdroid.fdroid.views.swap;
+
+import android.annotation.TargetApi;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.net.wifi.WifiManager;
+import android.os.Build;
+import android.support.annotation.ColorRes;
+import android.support.annotation.NonNull;
+import android.support.v4.content.LocalBroadcastManager;
+import android.support.v4.view.MenuItemCompat;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+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;
+
+import org.fdroid.fdroid.FDroidApp;
+import org.fdroid.fdroid.R;
+import org.fdroid.fdroid.localrepo.SwapService;
+import org.fdroid.fdroid.net.WifiStateChangeService;
+
+public class JoinWifiView extends RelativeLayout implements SwapWorkflowActivity.InnerView {
+
+ public JoinWifiView(Context context) {
+ super(context);
+ }
+
+ public JoinWifiView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public JoinWifiView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ @TargetApi(Build.VERSION_CODES.LOLLIPOP)
+ public JoinWifiView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ private SwapWorkflowActivity getActivity() {
+ return (SwapWorkflowActivity)getContext();
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ super.onFinishInflate();
+ setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ openAvailableNetworks();
+ }
+ });
+ refreshWifiState();
+
+ // TODO: This is effectively swap state management code, shouldn't be isolated to the
+ // WifiStateChangeService, but should be bundled with the main swap state handling code.
+ LocalBroadcastManager.getInstance(getActivity()).registerReceiver(
+ new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ refreshWifiState();
+ }
+ },
+ new IntentFilter(WifiStateChangeService.BROADCAST)
+ );
+ }
+
+ // TODO: Listen for "Connecting..." state and reflect that in the view too.
+ private void refreshWifiState() {
+ TextView descriptionView = (TextView) findViewById(R.id.text_description);
+ ImageView wifiIcon = (ImageView) findViewById(R.id.wifi_icon);
+ TextView ssidView = (TextView) findViewById(R.id.wifi_ssid);
+ TextView tapView = (TextView) findViewById(R.id.wifi_available_networks_prompt);
+ if (TextUtils.isEmpty(FDroidApp.bssid) && !TextUtils.isEmpty(FDroidApp.ipAddressString)) {
+ // empty bssid with an ipAddress means hotspot mode
+ descriptionView.setText(R.string.swap_join_this_hotspot);
+ wifiIcon.setImageDrawable(getResources().getDrawable(R.drawable.hotspot));
+ ssidView.setText(R.string.swap_active_hotspot);
+ tapView.setText(R.string.swap_switch_to_wifi);
+ } else if (TextUtils.isEmpty(FDroidApp.ssid)) {
+ // not connected to or setup with any wifi network
+ descriptionView.setText(R.string.swap_join_same_wifi);
+ wifiIcon.setImageDrawable(getResources().getDrawable(R.drawable.wifi));
+ ssidView.setText(R.string.swap_no_wifi_network);
+ tapView.setText(R.string.swap_view_available_networks);
+ } else {
+ // connected to a regular wifi network
+ descriptionView.setText(R.string.swap_join_same_wifi);
+ wifiIcon.setImageDrawable(getResources().getDrawable(R.drawable.wifi));
+ ssidView.setText(FDroidApp.ssid);
+ tapView.setText(R.string.swap_view_available_networks);
+ }
+ }
+
+ private void openAvailableNetworks() {
+ getActivity().startActivity(new Intent(WifiManager.ACTION_PICK_WIFI_NETWORK));
+ }
+
+ @Override
+ public boolean buildMenu(Menu menu, @NonNull MenuInflater inflater) {
+ inflater.inflate(R.menu.swap_next, menu);
+ MenuItem next = menu.findItem(R.id.action_next);
+ MenuItemCompat.setShowAsAction(next, MenuItemCompat.SHOW_AS_ACTION_ALWAYS | MenuItemCompat.SHOW_AS_ACTION_WITH_TEXT);
+ next.setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() {
+ @Override
+ public boolean onMenuItemClick(MenuItem item) {
+ getActivity().showSelectApps();
+ return true;
+ }
+ });
+ return true;
+ }
+
+ @Override
+ public int getStep() {
+ return SwapService.STEP_JOIN_WIFI;
+ }
+
+ @Override
+ public int getPreviousStep() {
+ return SwapService.STEP_INTRO;
+ }
+
+ @ColorRes
+ public int getToolbarColour() {
+ return R.color.swap_blue;
+ }
+
+ @Override
+ public String getToolbarTitle() {
+ return getResources().getString(R.string.swap_join_same_wifi);
+ }
+}
diff --git a/F-Droid/src/org/fdroid/fdroid/views/swap/NfcSwapFragment.java b/F-Droid/src/org/fdroid/fdroid/views/swap/NfcSwapFragment.java
deleted file mode 100644
index 6cf5b23e6..000000000
--- a/F-Droid/src/org/fdroid/fdroid/views/swap/NfcSwapFragment.java
+++ /dev/null
@@ -1,47 +0,0 @@
-package org.fdroid.fdroid.views.swap;
-
-import android.os.Bundle;
-import android.support.v4.app.Fragment;
-import android.support.v4.view.MenuItemCompat;
-import android.view.LayoutInflater;
-import android.view.Menu;
-import android.view.MenuInflater;
-import android.view.MenuItem;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.CheckBox;
-import android.widget.CompoundButton;
-
-import org.fdroid.fdroid.Preferences;
-import org.fdroid.fdroid.R;
-
-public class NfcSwapFragment extends Fragment {
-
- @Override
- public void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setHasOptionsMenu(true);
- }
-
- @Override
- public void onCreateOptionsMenu(Menu menu, MenuInflater menuInflater) {
- menuInflater.inflate(R.menu.swap_skip, menu);
- MenuItem nextMenuItem = menu.findItem(R.id.action_next);
- int flags = MenuItemCompat.SHOW_AS_ACTION_ALWAYS | MenuItemCompat.SHOW_AS_ACTION_WITH_TEXT;
- MenuItemCompat.setShowAsAction(nextMenuItem, flags);
- }
-
- @Override
- public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
- View view = inflater.inflate(R.layout.swap_nfc, container, false);
- CheckBox dontShowAgain = (CheckBox)view.findViewById(R.id.checkbox_dont_show);
- dontShowAgain.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
- @Override
- public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
- Preferences.get().setShowNfcDuringSwap(!isChecked);
- }
- });
- return view;
- }
-
-}
diff --git a/F-Droid/src/org/fdroid/fdroid/views/swap/NfcView.java b/F-Droid/src/org/fdroid/fdroid/views/swap/NfcView.java
new file mode 100644
index 000000000..930de1907
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/views/swap/NfcView.java
@@ -0,0 +1,90 @@
+package org.fdroid.fdroid.views.swap;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.os.Build;
+import android.support.annotation.ColorRes;
+import android.support.annotation.NonNull;
+import android.support.v4.view.MenuItemCompat;
+import android.util.AttributeSet;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.widget.CheckBox;
+import android.widget.CompoundButton;
+import android.widget.RelativeLayout;
+
+import org.fdroid.fdroid.Preferences;
+import org.fdroid.fdroid.R;
+import org.fdroid.fdroid.localrepo.SwapService;
+
+public class NfcView extends RelativeLayout implements SwapWorkflowActivity.InnerView {
+
+ public NfcView(Context context) {
+ super(context);
+ }
+
+ public NfcView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public NfcView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ @TargetApi(Build.VERSION_CODES.LOLLIPOP)
+ public NfcView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ private SwapWorkflowActivity getActivity() {
+ return (SwapWorkflowActivity)getContext();
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ super.onFinishInflate();
+ CheckBox dontShowAgain = (CheckBox)findViewById(R.id.checkbox_dont_show);
+ dontShowAgain.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
+ @Override
+ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
+ Preferences.get().setShowNfcDuringSwap(!isChecked);
+ }
+ });
+ }
+
+ @Override
+ public boolean buildMenu(Menu menu, @NonNull MenuInflater inflater) {
+ inflater.inflate(R.menu.swap_skip, menu);
+ MenuItem next = menu.findItem(R.id.action_next);
+ MenuItemCompat.setShowAsAction(next, MenuItemCompat.SHOW_AS_ACTION_ALWAYS | MenuItemCompat.SHOW_AS_ACTION_WITH_TEXT);
+ next.setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() {
+ @Override
+ public boolean onMenuItemClick(MenuItem item) {
+ getActivity().showWifiQr();
+ return true;
+ }
+ });
+ return true;
+ }
+
+ @Override
+ public int getStep() {
+ return SwapService.STEP_SHOW_NFC;
+ }
+
+ @Override
+ public int getPreviousStep() {
+ return SwapService.STEP_JOIN_WIFI;
+ }
+
+ @ColorRes
+ public int getToolbarColour() {
+ return R.color.swap_blue;
+ }
+
+ @Override
+ public String getToolbarTitle() {
+ return getResources().getString(R.string.swap_nfc_title);
+ }
+}
diff --git a/F-Droid/src/org/fdroid/fdroid/views/swap/SelectAppsFragment.java b/F-Droid/src/org/fdroid/fdroid/views/swap/SelectAppsView.java
similarity index 57%
rename from F-Droid/src/org/fdroid/fdroid/views/swap/SelectAppsFragment.java
rename to F-Droid/src/org/fdroid/fdroid/views/swap/SelectAppsView.java
index 0fa746256..ae6c0d4fa 100644
--- a/F-Droid/src/org/fdroid/fdroid/views/swap/SelectAppsFragment.java
+++ b/F-Droid/src/org/fdroid/fdroid/views/swap/SelectAppsView.java
@@ -1,11 +1,15 @@
package org.fdroid.fdroid.views.swap;
+import android.annotation.TargetApi;
import android.content.Context;
import android.content.pm.PackageManager;
import android.database.Cursor;
+import android.graphics.PorterDuff;
import android.graphics.drawable.Drawable;
import android.net.Uri;
+import android.os.Build;
import android.os.Bundle;
+import android.support.annotation.ColorRes;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.app.LoaderManager;
@@ -15,6 +19,7 @@ import android.support.v4.view.MenuItemCompat;
import android.support.v4.widget.CursorAdapter;
import android.support.v7.widget.SearchView;
import android.text.TextUtils;
+import android.util.AttributeSet;
import android.view.ContextThemeWrapper;
import android.view.LayoutInflater;
import android.view.Menu;
@@ -22,48 +27,85 @@ import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
+import android.widget.AdapterView;
import android.widget.CheckBox;
import android.widget.CompoundButton;
import android.widget.ImageView;
import android.widget.ListView;
import android.widget.TextView;
-import org.fdroid.fdroid.FDroidApp;
import org.fdroid.fdroid.R;
import org.fdroid.fdroid.data.InstalledAppProvider;
-import org.fdroid.fdroid.localrepo.LocalRepoManager;
-import org.fdroid.fdroid.views.fragments.ThemeableListFragment;
+import org.fdroid.fdroid.localrepo.SwapService;
-import java.util.HashSet;
-import java.util.Set;
+public class SelectAppsView extends ListView implements
+ SwapWorkflowActivity.InnerView,
+ LoaderManager.LoaderCallbacks,
+ SearchView.OnQueryTextListener {
-public class SelectAppsFragment extends ThemeableListFragment
- implements LoaderManager.LoaderCallbacks, SearchView.OnQueryTextListener {
+ public SelectAppsView(Context context) {
+ super(context);
+ }
- @SuppressWarnings("UnusedDeclaration")
- private static final String TAG = "SwapAppsList";
+ public SelectAppsView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+ public SelectAppsView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ @TargetApi(Build.VERSION_CODES.LOLLIPOP)
+ public SelectAppsView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ private SwapWorkflowActivity getActivity() {
+ return (SwapWorkflowActivity)getContext();
+ }
+
+ private SwapService getState() {
+ return getActivity().getState();
+ }
+
+ private static final int LOADER_INSTALLED_APPS = 253341534;
+
+ private AppListAdapter adapter;
private String mCurrentFilterString;
- @NonNull
- private final Set previouslySelectedApps = new HashSet<>();
+ @Override
+ protected void onFinishInflate() {
+ super.onFinishInflate();
+ adapter = new AppListAdapter(this, getContext(),
+ getContext().getContentResolver().query(InstalledAppProvider.getContentUri(), InstalledAppProvider.DataColumns.ALL, null, null, null));
- public Set getSelectedApps() {
- return FDroidApp.selectedApps;
+ setAdapter(adapter);
+ setChoiceMode(ListView.CHOICE_MODE_MULTIPLE);
+
+ // either reconnect with an existing loader or start a new one
+ getActivity().getSupportLoaderManager().initLoader(LOADER_INSTALLED_APPS, null, this);
+
+ setOnItemClickListener(new AdapterView.OnItemClickListener() {
+ public void onItemClick(AdapterView> parent, View v, int position, long id) {
+ toggleAppSelected(position);
+ }
+ });
}
@Override
- public void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setHasOptionsMenu(true);
- }
+ public boolean buildMenu(Menu menu, @NonNull MenuInflater inflater) {
- @Override
- public void onCreateOptionsMenu(Menu menu, MenuInflater menuInflater) {
- menuInflater.inflate(R.menu.swap_next_search, menu);
+ inflater.inflate(R.menu.swap_next_search, menu);
MenuItem nextMenuItem = menu.findItem(R.id.action_next);
int flags = MenuItemCompat.SHOW_AS_ACTION_ALWAYS | MenuItemCompat.SHOW_AS_ACTION_WITH_TEXT;
MenuItemCompat.setShowAsAction(nextMenuItem, flags);
+ nextMenuItem.setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() {
+ @Override
+ public boolean onMenuItemClick(MenuItem item) {
+ getActivity().onAppsSelected();
+ return true;
+ }
+ });
SearchView searchView = new SearchView(getActivity());
@@ -72,96 +114,54 @@ public class SelectAppsFragment extends ThemeableListFragment
MenuItemCompat.setShowAsAction(searchMenuItem, MenuItemCompat.SHOW_AS_ACTION_IF_ROOM);
searchView.setOnQueryTextListener(this);
+ return true;
}
@Override
- public void onResume() {
- super.onResume();
- previouslySelectedApps.clear();
- if (FDroidApp.selectedApps != null) {
- previouslySelectedApps.addAll(FDroidApp.selectedApps);
- }
- }
-
- public boolean hasSelectionChanged() {
-
- Set currentlySelected = getSelectedApps();
- if (currentlySelected.size() != previouslySelectedApps.size()) {
- return true;
- }
-
- for (String current : currentlySelected) {
- boolean found = false;
- for (String previous : previouslySelectedApps) {
- if (current.equals(previous)) {
- found = true;
- break;
- }
- }
- if (!found) {
- return true;
- }
- }
-
- return false;
+ public int getStep() {
+ return SwapService.STEP_SELECT_APPS;
}
@Override
- public void onActivityCreated(Bundle savedInstanceState) {
- super.onActivityCreated(savedInstanceState);
+ public int getPreviousStep() {
+ // TODO: The STEP_JOIN_WIFI step isn't shown first, need to make it so that it is, or so that this doesn't go back there.
+ return getState().isConnectingWithPeer() ? SwapService.STEP_INTRO : SwapService.STEP_JOIN_WIFI;
+ }
- setEmptyText(getString(R.string.no_applications_found));
-
- ListView listView = getListView();
- listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE);
-
- setListAdapter(new AppListAdapter(listView, getActivity(), null));
- setListShown(false); // start out with a progress indicator
-
- // either reconnect with an existing loader or start a new one
- getLoaderManager().initLoader(0, null, this);
-
- // build list of existing apps from what is on the file system
- if (FDroidApp.selectedApps == null) {
- FDroidApp.selectedApps = new HashSet<>();
- for (String filename : LocalRepoManager.get(getActivity()).repoDir.list()) {
- if (filename.matches(".*\\.apk")) {
- String packageName = filename.substring(0, filename.indexOf("_"));
- FDroidApp.selectedApps.add(packageName);
- }
- }
- }
+ @ColorRes
+ public int getToolbarColour() {
+ return R.color.swap_bright_blue;
}
@Override
- public void onListItemClick(ListView l, View v, int position, long id) {
- // Ignore the headerView at position 0.
- if (position > 0) {
- toggleAppSelected(position);
- }
+ public String getToolbarTitle() {
+ return getResources().getString(R.string.swap_choose_apps);
}
private void toggleAppSelected(int position) {
- Cursor c = (Cursor) getListAdapter().getItem(position - 1);
+ Cursor c = (Cursor) adapter.getItem(position);
String packageName = c.getString(c.getColumnIndex(InstalledAppProvider.DataColumns.APP_ID));
- if (FDroidApp.selectedApps.contains(packageName)) {
- FDroidApp.selectedApps.remove(packageName);
+ if (getState().hasSelectedPackage(packageName)) {
+ getState().deselectPackage(packageName);
+ adapter.updateCheckedIndicatorView(position, false);
} else {
- FDroidApp.selectedApps.add(packageName);
+ getState().selectPackage(packageName);
+ adapter.updateCheckedIndicatorView(position, true);
}
+
}
@Override
public CursorLoader onCreateLoader(int id, Bundle args) {
- Uri baseUri;
+ Uri uri;
if (TextUtils.isEmpty(mCurrentFilterString)) {
- baseUri = InstalledAppProvider.getContentUri();
+ uri = InstalledAppProvider.getContentUri();
} else {
- baseUri = InstalledAppProvider.getSearchUri(mCurrentFilterString);
+ uri = InstalledAppProvider.getSearchUri(mCurrentFilterString);
}
return new CursorLoader(
- this.getActivity(),
- baseUri,
+ getActivity(),
+ uri,
InstalledAppProvider.DataColumns.ALL,
null,
null,
@@ -170,34 +170,23 @@ public class SelectAppsFragment extends ThemeableListFragment
@Override
public void onLoadFinished(Loader loader, Cursor cursor) {
- ((AppListAdapter)getListAdapter()).swapCursor(cursor);
+ adapter.swapCursor(cursor);
- ListView listView = getListView();
- String fdroid = loader.getContext().getPackageName();
- for (int i = 0; i < listView.getCount() - 1; i++) {
- Cursor c = ((Cursor) listView.getItemAtPosition(i + 1));
+ for (int i = 0; i < getCount(); i++) {
+ Cursor c = ((Cursor) getItemAtPosition(i));
String packageName = c.getString(c.getColumnIndex(InstalledAppProvider.DataColumns.APP_ID));
- if (TextUtils.equals(packageName, fdroid)) {
- listView.setItemChecked(i + 1, true); // always include FDroid
- } else {
- for (String selected : FDroidApp.selectedApps) {
- if (TextUtils.equals(packageName, selected)) {
- listView.setItemChecked(i + 1, true);
- }
+ getState().ensureFDroidSelected();
+ for (String selected : getState().getAppsToSwap()) {
+ if (TextUtils.equals(packageName, selected)) {
+ setItemChecked(i, true);
}
}
}
-
- if (isResumed()) {
- setListShown(true);
- } else {
- setListShownNoAnimation(true);
- }
}
@Override
public void onLoaderReset(Loader loader) {
- ((AppListAdapter)getListAdapter()).swapCursor(null);
+ adapter.swapCursor(null);
}
@Override
@@ -210,7 +199,7 @@ public class SelectAppsFragment extends ThemeableListFragment
return true;
}
mCurrentFilterString = newFilter;
- getLoaderManager().restartLoader(0, null, this);
+ getActivity().getSupportLoaderManager().restartLoader(LOADER_INSTALLED_APPS, null, this);
return true;
}
@@ -220,16 +209,6 @@ public class SelectAppsFragment extends ThemeableListFragment
return true;
}
- @Override
- protected int getThemeStyle() {
- return R.style.SwapTheme_StartSwap;
- }
-
- @Override
- protected int getHeaderLayout() {
- return R.layout.swap_create_header;
- }
-
private class AppListAdapter extends CursorAdapter {
@SuppressWarnings("UnusedDeclaration")
@@ -258,7 +237,6 @@ public class SelectAppsFragment extends ThemeableListFragment
return inflater;
}
- @NonNull
private Drawable getDefaultAppIcon(Context context) {
if (defaultAppIcon == null) {
defaultAppIcon = context.getResources().getDrawable(android.R.drawable.sym_def_app_icon);
@@ -294,6 +272,8 @@ public class SelectAppsFragment extends ThemeableListFragment
labelView.setText(appLabel);
iconView.setImageDrawable(icon);
+ final int listPosition = cursor.getPosition();
+
// Since v11, the Android SDK provided the ability to show selected list items
// by highlighting their background. Prior to this, we need to handle this ourselves
// by adding a checkbox which can toggle selected items.
@@ -302,8 +282,6 @@ public class SelectAppsFragment extends ThemeableListFragment
CheckBox checkBox = (CheckBox)checkBoxView;
checkBox.setOnCheckedChangeListener(null);
- final int listPosition = cursor.getPosition() + 1; // To account for the header view.
-
checkBox.setChecked(listView.isItemChecked(listPosition));
checkBox.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override
@@ -313,6 +291,35 @@ public class SelectAppsFragment extends ThemeableListFragment
}
});
}
+
+ updateCheckedIndicatorView(view, listView.isItemChecked(listPosition));
+ }
+
+ public void updateCheckedIndicatorView(int position, boolean checked) {
+ final int firstListItemPosition = listView.getFirstVisiblePosition();
+ final int lastListItemPosition = firstListItemPosition + listView.getChildCount() - 1;
+
+ if (position >= firstListItemPosition && position <= lastListItemPosition ) {
+ final int childIndex = position - firstListItemPosition;
+ updateCheckedIndicatorView(listView.getChildAt(childIndex), checked);
+ }
+ }
+
+ private void updateCheckedIndicatorView(View view, boolean checked) {
+ ImageView imageView = (ImageView)view.findViewById(R.id.checked);
+ if (imageView != null) {
+ int resource;
+ int colour;
+ if (checked) {
+ resource = R.drawable.ic_check_circle_white;
+ colour = getResources().getColor(R.color.swap_bright_blue);
+ } else {
+ resource = R.drawable.ic_add_circle_outline_white;
+ colour = 0xFFD0D0D4;
+ }
+ imageView.setImageDrawable(getResources().getDrawable(resource));
+ imageView.setColorFilter(colour, PorterDuff.Mode.MULTIPLY);
+ }
}
}
diff --git a/F-Droid/src/org/fdroid/fdroid/views/swap/StartSwapFragment.java b/F-Droid/src/org/fdroid/fdroid/views/swap/StartSwapFragment.java
deleted file mode 100644
index 527b39b6b..000000000
--- a/F-Droid/src/org/fdroid/fdroid/views/swap/StartSwapFragment.java
+++ /dev/null
@@ -1,39 +0,0 @@
-package org.fdroid.fdroid.views.swap;
-
-import android.app.Activity;
-import android.content.Context;
-import android.os.Bundle;
-import android.support.v4.app.Fragment;
-import android.view.ContextThemeWrapper;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-
-import org.fdroid.fdroid.R;
-
-public class StartSwapFragment extends Fragment {
-
- private SwapProcessManager manager;
-
- public void onAttach(Activity activity) {
- super.onAttach(activity);
- manager = (SwapProcessManager)activity;
- }
-
- @Override
- public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
-
- LayoutInflater themedInflater = (LayoutInflater)new ContextThemeWrapper(inflater.getContext(), R.style.SwapTheme_StartSwap).getSystemService(Context.LAYOUT_INFLATER_SERVICE);
-
- View view = themedInflater.inflate(R.layout.swap_blank, container, false);
- view.findViewById(R.id.button_start_swap).setOnClickListener(new View.OnClickListener() {
- @Override
- public void onClick(View v) {
- manager.nextStep();
- }
- });
-
- return view;
- }
-
-}
diff --git a/F-Droid/src/org/fdroid/fdroid/views/swap/StartSwapView.java b/F-Droid/src/org/fdroid/fdroid/views/swap/StartSwapView.java
new file mode 100644
index 000000000..a91a980c9
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/views/swap/StartSwapView.java
@@ -0,0 +1,378 @@
+package org.fdroid.fdroid.views.swap;
+
+import android.annotation.TargetApi;
+import android.bluetooth.BluetoothAdapter;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.net.wifi.WifiConfiguration;
+import android.os.Build;
+import android.support.annotation.ColorRes;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.v4.content.LocalBroadcastManager;
+import android.support.v7.widget.SwitchCompat;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.CompoundButton;
+import android.widget.ImageView;
+import android.widget.ListView;
+import android.widget.ProgressBar;
+import android.widget.ScrollView;
+import android.widget.TextView;
+
+import org.fdroid.fdroid.FDroidApp;
+import org.fdroid.fdroid.R;
+import org.fdroid.fdroid.localrepo.SwapService;
+import org.fdroid.fdroid.localrepo.peers.Peer;
+import org.fdroid.fdroid.net.WifiStateChangeService;
+
+import java.util.ArrayList;
+
+import cc.mvdan.accesspoint.WifiApControl;
+
+public class StartSwapView extends ScrollView implements SwapWorkflowActivity.InnerView {
+
+ private static final String TAG = "StartSwapView";
+
+ // TODO: Is there a way to guarangee which of these constructors the inflater will call?
+ // Especially on different API levels? It would be nice to only have the one which accepts
+ // a Context, but I'm not sure if that is correct or not. As it stands, this class provides
+ // constructurs which match each of the ones available in the parent class.
+ // The same is true for the other views in the swap process too.
+
+ public StartSwapView(Context context) {
+ super(context);
+ }
+
+ public StartSwapView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @TargetApi(Build.VERSION_CODES.HONEYCOMB)
+ public StartSwapView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ @TargetApi(Build.VERSION_CODES.LOLLIPOP)
+ public StartSwapView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ private class PeopleNearbyAdapter extends ArrayAdapter {
+
+ public PeopleNearbyAdapter(Context context) {
+ super(context, 0, new ArrayList());
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ if (convertView == null) {
+ convertView = LayoutInflater.from(getContext()).inflate(R.layout.swap_peer_list_item, parent, false);
+ }
+
+ Peer peer = getItem(position);
+ ((TextView)convertView.findViewById(R.id.peer_name)).setText(peer.getName());
+ ((ImageView)convertView.findViewById(R.id.icon)).setImageDrawable(getResources().getDrawable(peer.getIcon()));
+
+ return convertView;
+ }
+
+
+ }
+
+ private SwapWorkflowActivity getActivity() {
+ // TODO: Try and find a better way to get to the SwapActivity, which makes less asumptions.
+ return (SwapWorkflowActivity)getContext();
+ }
+
+ private SwapService getManager() {
+ return getActivity().getState();
+ }
+
+ @Nullable /* Emulators typically don't have bluetooth adapters */
+ private final BluetoothAdapter bluetooth = BluetoothAdapter.getDefaultAdapter();
+
+ private TextView viewBluetoothId;
+ private TextView viewWifiId;
+ private TextView viewWifiNetwork;
+ private TextView peopleNearbyText;
+ private ListView peopleNearbyList;
+ private ProgressBar peopleNearbyProgress;
+
+ @Override
+ protected void onFinishInflate() {
+ super.onFinishInflate();
+
+ getManager().scanForPeers();
+
+ uiInitPeers();
+ uiInitBluetooth();
+ uiInitWifi();
+ uiInitButtons();
+ uiUpdatePeersInfo();
+
+ // TODO: Unregister this receiver at some point.
+ LocalBroadcastManager.getInstance(getActivity()).registerReceiver(
+ new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ uiUpdateWifiNetwork();
+ }
+ },
+ new IntentFilter(WifiStateChangeService.BROADCAST)
+ );
+ }
+
+ private void uiInitButtons() {
+ findViewById(R.id.btn_send_fdroid).setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ getActivity().sendFDroid();
+ }
+ });
+
+ findViewById(R.id.btn_qr_scanner).setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ getActivity().startQrWorkflow();
+ }
+ });
+ }
+
+ /**
+ * Setup the list of nearby peers with an adapter, and hide or show it and the associated
+ * message for when no peers are nearby depending on what is happening.
+ */
+ private void uiInitPeers() {
+
+ peopleNearbyText = (TextView)findViewById(R.id.text_people_nearby);
+ peopleNearbyList = (ListView)findViewById(R.id.list_people_nearby);
+ peopleNearbyProgress = (ProgressBar)findViewById(R.id.searching_people_nearby);
+
+ final PeopleNearbyAdapter adapter = new PeopleNearbyAdapter(getContext());
+ peopleNearbyList.setAdapter(adapter);
+ uiUpdatePeersInfo();
+
+ peopleNearbyList.setOnItemClickListener(new AdapterView.OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView> parent, View view, int position, long id) {
+ Peer peer = adapter.getItem(position);
+ onPeerSelected(peer);
+ }
+ });
+
+ // TODO: Unregister this receiver at the right time.
+ LocalBroadcastManager.getInstance(getContext()).registerReceiver(new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ Peer peer = intent.getParcelableExtra(SwapService.EXTRA_PEER);
+ if (adapter.getPosition(peer) >= 0) {
+ Log.d(TAG, "Found peer: " + peer + ", ignoring though, because it is already in our list.");
+ } else {
+ Log.d(TAG, "Found peer: " + peer + ", adding to list of peers in UI.");
+ adapter.add(peer);
+ uiUpdatePeersInfo();
+ }
+ }
+ }, new IntentFilter(SwapService.ACTION_PEER_FOUND));
+
+ }
+
+ private void uiUpdatePeersInfo() {
+ if (getManager().isScanningForPeers()) {
+ peopleNearbyText.setText(getContext().getString(R.string.swap_scanning_for_peers));
+ peopleNearbyProgress.setVisibility(View.VISIBLE);
+ } else {
+ peopleNearbyProgress.setVisibility(View.GONE);
+ if (peopleNearbyList.getAdapter().getCount() > 0) {
+ peopleNearbyText.setText(getContext().getString(R.string.swap_people_nearby));
+ } else {
+ peopleNearbyText.setText(getContext().getString(R.string.swap_no_peers_nearby));
+ }
+ }
+
+ }
+
+ private void uiInitBluetooth() {
+ if (bluetooth != null) {
+
+ final TextView textBluetoothVisible = (TextView)findViewById(R.id.bluetooth_visible);
+
+ viewBluetoothId = (TextView)findViewById(R.id.device_id_bluetooth);
+ viewBluetoothId.setText(bluetooth.getName());
+ viewBluetoothId.setVisibility(bluetooth.isEnabled() ? View.VISIBLE : View.GONE);
+
+ int textResource = getManager().isBluetoothDiscoverable() ? R.string.swap_visible_bluetooth : R.string.swap_not_visible_bluetooth;
+ textBluetoothVisible.setText(textResource);
+
+ final SwitchCompat bluetoothSwitch = ((SwitchCompat) findViewById(R.id.switch_bluetooth));
+ bluetoothSwitch.setChecked(getManager().isBluetoothDiscoverable());
+ bluetoothSwitch.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
+ @Override
+ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
+ if (isChecked) {
+ getActivity().startBluetoothSwap();
+ textBluetoothVisible.setText(R.string.swap_visible_bluetooth);
+ viewBluetoothId.setVisibility(View.VISIBLE);
+ uiUpdatePeersInfo();
+ // TODO: When they deny the request for enabling bluetooth, we need to disable this switch...
+ } else {
+ getManager().getBluetoothSwap().stop();
+ textBluetoothVisible.setText(R.string.swap_not_visible_bluetooth);
+ viewBluetoothId.setVisibility(View.GONE);
+ uiUpdatePeersInfo();
+ }
+ }
+ });
+
+ // TODO: Unregister receiver correctly...
+ LocalBroadcastManager.getInstance(getContext()).registerReceiver(new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (intent.hasExtra(SwapService.EXTRA_STARTING)) {
+ Log.d(TAG, "Bluetooth service is starting...");
+ bluetoothSwitch.setEnabled(false);
+ textBluetoothVisible.setText(R.string.swap_setting_up_bluetooth);
+ // bluetoothSwitch.setChecked(true);
+ } else {
+ bluetoothSwitch.setEnabled(true);
+ if (intent.hasExtra(SwapService.EXTRA_STARTED)) {
+ Log.d(TAG, "Bluetooth service has started.");
+ textBluetoothVisible.setText(R.string.swap_visible_bluetooth);
+ // bluetoothSwitch.setChecked(true);
+ } else {
+ Log.d(TAG, "Bluetooth service has stopped.");
+ textBluetoothVisible.setText(R.string.swap_not_visible_bluetooth);
+ bluetoothSwitch.setChecked(false);
+ }
+ }
+ }
+ }, new IntentFilter(SwapService.BLUETOOTH_STATE_CHANGE));
+
+ } else {
+ findViewById(R.id.bluetooth_info).setVisibility(View.GONE);
+ }
+ }
+
+ private void uiInitWifi() {
+
+ viewWifiId = (TextView)findViewById(R.id.device_id_wifi);
+ viewWifiNetwork = (TextView)findViewById(R.id.wifi_network);
+
+ final SwitchCompat wifiSwitch = (SwitchCompat)findViewById(R.id.switch_wifi);
+ wifiSwitch.setChecked(getManager().isBonjourDiscoverable());
+ wifiSwitch.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
+ @Override
+ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
+ if (isChecked) {
+ getManager().getWifiSwap().ensureRunningInBackground();
+ } else {
+ getManager().getWifiSwap().stop();
+ }
+ uiUpdatePeersInfo();
+ uiUpdateWifiNetwork();
+ }
+ });
+
+ final TextView textWifiVisible = (TextView)findViewById(R.id.wifi_visible);
+ int textResource = getManager().isBonjourDiscoverable() ? R.string.swap_visible_wifi : R.string.swap_not_visible_wifi;
+ textWifiVisible.setText(textResource);
+
+ // TODO: Unregister receiver correctly...
+ LocalBroadcastManager.getInstance(getContext()).registerReceiver(new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (intent.hasExtra(SwapService.EXTRA_STARTING)) {
+ Log.d(TAG, "Bonjour/WiFi service is starting...");
+ textWifiVisible.setText(R.string.swap_setting_up_wifi);
+ wifiSwitch.setEnabled(false);
+ wifiSwitch.setChecked(true);
+ } else {
+ wifiSwitch.setEnabled(true);
+ if (intent.hasExtra(SwapService.EXTRA_STARTED)) {
+ Log.d(TAG, "Bonjour/WiFi service has started.");
+ textWifiVisible.setText(R.string.swap_visible_wifi);
+ wifiSwitch.setChecked(true);
+ } else {
+ Log.d(TAG, "Bonjour/WiFi service has stopped.");
+ textWifiVisible.setText(R.string.swap_not_visible_wifi);
+ wifiSwitch.setChecked(false);
+ }
+ }
+ uiUpdateWifiNetwork();
+ }
+ }, new IntentFilter(SwapService.BONJOUR_STATE_CHANGE));
+
+ viewWifiNetwork.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ getActivity().promptToSelectWifiNetwork();
+ }
+ });
+
+ uiUpdateWifiNetwork();
+ }
+
+ private void uiUpdateWifiNetwork() {
+
+ viewWifiId.setText(FDroidApp.ipAddressString);
+ viewWifiId.setVisibility(TextUtils.isEmpty(FDroidApp.ipAddressString) ? View.GONE : View.VISIBLE);
+
+ WifiApControl wifiAp = WifiApControl.getInstance(getActivity());
+ if (wifiAp.isWifiApEnabled()) {
+ WifiConfiguration config = wifiAp.getConfiguration();
+ viewWifiNetwork.setText(getContext().getString(R.string.swap_active_hotspot, config.SSID));
+ } else if (TextUtils.isEmpty(FDroidApp.ssid)) {
+ // not connected to or setup with any wifi network
+ viewWifiNetwork.setText(R.string.swap_no_wifi_network);
+ } else {
+ // connected to a regular wifi network
+ viewWifiNetwork.setText(FDroidApp.ssid);
+ }
+ }
+
+ private void onPeerSelected(Peer peer) {
+ getActivity().swapWith(peer);
+ }
+
+ @Override
+ public boolean buildMenu(Menu menu, @NonNull MenuInflater inflater) {
+ return false;
+ }
+
+ @Override
+ public int getStep() {
+ return SwapService.STEP_INTRO;
+ }
+
+ @Override
+ public int getPreviousStep() {
+ // TODO: Currently this is handleed by the SwapWorkflowActivity as a special case, where
+ // if getStep is STEP_INTRO, don't even bother asking for getPreviousStep. But that is a
+ // bit messy. It would be nicer if this was handled using the same mechanism as everything
+ // else.
+ return SwapService.STEP_INTRO;
+ }
+
+ @Override
+ @ColorRes
+ public int getToolbarColour() {
+ return R.color.swap_bright_blue;
+ }
+
+ @Override
+ public String getToolbarTitle() {
+ return getResources().getString(R.string.swap_nearby);
+ }
+
+}
diff --git a/F-Droid/src/org/fdroid/fdroid/views/swap/SwapActivity.java b/F-Droid/src/org/fdroid/fdroid/views/swap/SwapActivity.java
deleted file mode 100644
index 2857b4518..000000000
--- a/F-Droid/src/org/fdroid/fdroid/views/swap/SwapActivity.java
+++ /dev/null
@@ -1,291 +0,0 @@
-package org.fdroid.fdroid.views.swap;
-
-import android.app.ProgressDialog;
-import android.content.Context;
-import android.net.Uri;
-import android.os.AsyncTask;
-import android.os.Bundle;
-import android.os.Handler;
-import android.support.annotation.NonNull;
-import android.support.v4.app.Fragment;
-import android.support.v4.app.FragmentManager;
-import android.support.v7.app.ActionBarActivity;
-import android.util.Log;
-import android.view.MenuItem;
-import android.widget.Toast;
-
-import org.fdroid.fdroid.FDroidApp;
-import org.fdroid.fdroid.NfcHelper;
-import org.fdroid.fdroid.Preferences;
-import org.fdroid.fdroid.R;
-import org.fdroid.fdroid.Utils;
-import org.fdroid.fdroid.localrepo.LocalRepoManager;
-
-import java.util.Set;
-import java.util.Timer;
-import java.util.TimerTask;
-
-public class SwapActivity extends ActionBarActivity implements SwapProcessManager {
-
- private static final String STATE_START_SWAP = "startSwap";
- private static final String STATE_SELECT_APPS = "selectApps";
- private static final String STATE_JOIN_WIFI = "joinWifi";
- private static final String STATE_NFC = "nfc";
- private static final String STATE_WIFI_QR = "wifiQr";
-
- private Timer shutdownLocalRepoTimer;
- private UpdateAsyncTask updateSwappableAppsTask = null;
- private boolean hasPreparedLocalRepo = false;
-
- @Override
- public void onBackPressed() {
- switch (currentState()) {
- case STATE_START_SWAP:
- finish();
- break;
- default:
- super.onBackPressed();
- break;
- }
- }
-
- private String currentState() {
- FragmentManager.BackStackEntry lastFragment = getSupportFragmentManager().getBackStackEntryAt(getSupportFragmentManager().getBackStackEntryCount() - 1);
- return lastFragment.getName();
- }
-
- public void nextStep() {
- switch (currentState()) {
- case STATE_START_SWAP:
- showSelectApps();
- break;
- case STATE_SELECT_APPS:
- prepareLocalRepo();
- break;
- case STATE_JOIN_WIFI:
- ensureLocalRepoRunning();
- if (!attemptToShowNfc()) {
- showWifiQr();
- }
- break;
- case STATE_NFC:
- showWifiQr();
- break;
- case STATE_WIFI_QR:
- break;
- }
- supportInvalidateOptionsMenu();
- }
-
- @Override
- public boolean onOptionsItemSelected(MenuItem item) {
- if (item.getItemId() == R.id.action_next) {
- nextStep();
- }
- return super.onOptionsItemSelected(item);
- }
-
- @Override
- public void onCreate(Bundle savedInstanceState) {
-
- super.onCreate(savedInstanceState);
-
- if (savedInstanceState == null) {
-
- setContentView(R.layout.swap_activity);
-
- // Necessary to run on an Android 2.3.[something] device.
- new Handler().post(new Runnable() {
- @Override
- public void run() {
-
- showFragment(new StartSwapFragment(), STATE_START_SWAP);
-
- if (FDroidApp.isLocalRepoServiceRunning()) {
- showSelectApps();
- showJoinWifi();
- attemptToShowNfc();
- showWifiQr();
- }
-
- }
- });
- }
-
- }
-
- private void showSelectApps() {
-
- showFragment(new SelectAppsFragment(), STATE_SELECT_APPS);
-
- }
-
- private void showJoinWifi() {
-
- showFragment(new JoinWifiFragment(), STATE_JOIN_WIFI);
-
- }
-
- private boolean attemptToShowNfc() {
- // TODO: What if NFC is disabled? Hook up with NfcNotEnabledActivity? Or maybe only if they
- // click a relevant button?
-
- // Even if they opted to skip the message which says "Touch devices to swap",
- // we still want to actually enable the feature, so that they could touch
- // during the wifi qr code being shown too.
- boolean nfcMessageReady = NfcHelper.setPushMessage(this, Utils.getSharingUri(FDroidApp.repo));
-
- if (Preferences.get().showNfcDuringSwap() && nfcMessageReady) {
- showFragment(new NfcSwapFragment(), STATE_NFC);
- return true;
- }
- return false;
- }
-
- private void showBluetooth() {
-
- }
-
- private void showWifiQr() {
- showFragment(new WifiQrFragment(), STATE_WIFI_QR);
- }
-
- private void showFragment(Fragment fragment, String name) {
- getSupportFragmentManager()
- .beginTransaction()
- .replace(R.id.fragment_container, fragment, name)
- .addToBackStack(name)
- .commit();
- }
-
- private void prepareLocalRepo() {
- SelectAppsFragment fragment = (SelectAppsFragment)getSupportFragmentManager().findFragmentByTag(STATE_SELECT_APPS);
- boolean needsUpdating = !hasPreparedLocalRepo || fragment.hasSelectionChanged();
- if (updateSwappableAppsTask == null && needsUpdating) {
- updateSwappableAppsTask = new UpdateAsyncTask(this, fragment.getSelectedApps());
- updateSwappableAppsTask.execute();
- } else {
- showJoinWifi();
- }
- }
-
- /**
- * Once the UpdateAsyncTask has finished preparing our repository index, we can
- * show the next screen to the user.
- */
- private void onLocalRepoPrepared() {
-
- updateSwappableAppsTask = null;
- hasPreparedLocalRepo = true;
- showJoinWifi();
-
- }
-
- private void ensureLocalRepoRunning() {
- if (!FDroidApp.isLocalRepoServiceRunning()) {
- FDroidApp.startLocalRepoService(this);
- initLocalRepoTimer(900000); // 15 mins
- }
- }
-
- private void initLocalRepoTimer(long timeoutMilliseconds) {
-
- // reset the timer if viewing this Activity again
- if (shutdownLocalRepoTimer != null)
- shutdownLocalRepoTimer.cancel();
-
- // automatically turn off after 15 minutes
- shutdownLocalRepoTimer = new Timer();
- shutdownLocalRepoTimer.schedule(new TimerTask() {
- @Override
- public void run() {
- FDroidApp.stopLocalRepoService(SwapActivity.this);
- }
- }, timeoutMilliseconds);
-
- }
-
- @Override
- public void stopSwapping() {
- if (FDroidApp.isLocalRepoServiceRunning()) {
- if (shutdownLocalRepoTimer != null) {
- shutdownLocalRepoTimer.cancel();
- }
- FDroidApp.stopLocalRepoService(SwapActivity.this);
- }
- finish();
- }
-
- class UpdateAsyncTask extends AsyncTask {
-
- @SuppressWarnings("UnusedDeclaration")
- private static final String TAG = "SwapActivity.UpdateAsyncTask";
-
- @NonNull
- private final ProgressDialog progressDialog;
-
- @NonNull
- private final Set selectedApps;
-
- @NonNull
- private final Uri sharingUri;
-
- public UpdateAsyncTask(Context c, @NonNull Set apps) {
- selectedApps = apps;
- progressDialog = new ProgressDialog(c);
- progressDialog.setProgressStyle(ProgressDialog.STYLE_SPINNER);
- progressDialog.setTitle(R.string.updating);
- sharingUri = Utils.getSharingUri(FDroidApp.repo);
- }
-
- @Override
- protected void onPreExecute() {
- progressDialog.show();
- }
-
- @Override
- protected Void doInBackground(Void... params) {
- try {
- final LocalRepoManager lrm = LocalRepoManager.get(SwapActivity.this);
- publishProgress(getString(R.string.deleting_repo));
- lrm.deleteRepo();
- for (String app : selectedApps) {
- publishProgress(String.format(getString(R.string.adding_apks_format), app));
- lrm.addApp(SwapActivity.this, app);
- }
- lrm.writeIndexPage(sharingUri.toString());
- publishProgress(getString(R.string.writing_index_jar));
- lrm.writeIndexJar();
- publishProgress(getString(R.string.linking_apks));
- lrm.copyApksToRepo();
- publishProgress(getString(R.string.copying_icons));
- // run the icon copy without progress, its not a blocker
- new AsyncTask() {
-
- @Override
- protected Void doInBackground(Void... params) {
- lrm.copyIconsToRepo();
- return null;
- }
- }.execute();
- } catch (Exception e) {
- Log.e(TAG, "An error occured while setting up the local repo", e);
- }
- return null;
- }
-
- @Override
- protected void onProgressUpdate(String... progress) {
- super.onProgressUpdate(progress);
- progressDialog.setMessage(progress[0]);
- }
-
- @Override
- protected void onPostExecute(Void result) {
- progressDialog.dismiss();
- Toast.makeText(SwapActivity.this, R.string.updated_local_repo, Toast.LENGTH_SHORT).show();
- onLocalRepoPrepared();
- }
- }
-
-}
diff --git a/F-Droid/src/org/fdroid/fdroid/views/swap/SwapAppListActivity.java b/F-Droid/src/org/fdroid/fdroid/views/swap/SwapAppListActivity.java
deleted file mode 100644
index 0406bb989..000000000
--- a/F-Droid/src/org/fdroid/fdroid/views/swap/SwapAppListActivity.java
+++ /dev/null
@@ -1,137 +0,0 @@
-package org.fdroid.fdroid.views.swap;
-
-import android.app.Activity;
-import android.content.Intent;
-import android.net.Uri;
-import android.os.Bundle;
-import android.os.Handler;
-import android.support.annotation.Nullable;
-import android.support.v4.app.NavUtils;
-import android.support.v7.app.ActionBarActivity;
-import android.util.Log;
-
-import org.fdroid.fdroid.AppDetails;
-import org.fdroid.fdroid.R;
-import org.fdroid.fdroid.data.AppProvider;
-import org.fdroid.fdroid.data.Repo;
-import org.fdroid.fdroid.data.RepoProvider;
-import org.fdroid.fdroid.views.AppListAdapter;
-import org.fdroid.fdroid.views.AvailableAppListAdapter;
-import org.fdroid.fdroid.views.fragments.AppListFragment;
-
-public class SwapAppListActivity extends ActionBarActivity {
-
- private static final String TAG = "SwapAppListActivity";
-
- public static final String EXTRA_REPO_ID = "repoId";
-
- private Repo repo;
-
- @Override
- public void onCreate(Bundle savedInstanceState) {
-
- super.onCreate(savedInstanceState);
-
- if (savedInstanceState == null) {
-
- // Necessary to run on an Android 2.3.[something] device.
- new Handler().post(new Runnable() {
- @Override
- public void run() {
- getSupportFragmentManager()
- .beginTransaction()
- .add(android.R.id.content, new SwapAppListFragment())
- .commit();
- }
- });
- }
-
- }
-
- @Override
- protected void onResume() {
- super.onResume();
-
- long repoAddress = getIntent().getLongExtra(EXTRA_REPO_ID, -1);
- repo = RepoProvider.Helper.findById(this, repoAddress);
- if (repo == null) {
- Log.e(TAG, "Couldn't show swap app list for repo " + repoAddress);
- finish();
- }
- }
-
- public Repo getRepo() {
- return repo;
- }
-
- public static class SwapAppListFragment extends AppListFragment {
-
- private Repo repo;
-
- @Override
- public void onAttach(Activity activity) {
- super.onAttach(activity);
- repo = ((SwapAppListActivity)activity).getRepo();
- }
-
- @Override
- protected int getHeaderLayout() {
- return R.layout.swap_success_header;
- }
-
- @Override
- protected AppListAdapter getAppListAdapter() {
- return new AvailableAppListAdapter(getActivity(), null);
- }
-
- @Nullable
- @Override
- protected String getEmptyMessage() {
- return getActivity().getString(R.string.empty_swap_app_list);
- }
-
- @Override
- protected String getFromTitle() {
- return getString(R.string.swap);
- }
-
- @Override
- protected Uri getDataUri() {
- return AppProvider.getRepoUri(repo);
- }
-
- protected Intent getAppDetailsIntent() {
- Intent intent = new Intent(getActivity(), SwapAppDetails.class);
- intent.putExtra(EXTRA_REPO_ID, repo.getId());
- return intent;
- }
-
- }
-
- /**
- * Only difference from base class is that it navigates up to a different task.
- * It will go to the {@link org.fdroid.fdroid.views.swap.SwapAppListActivity}
- * whereas the baseclass will go back to the main list of apps. Need to juggle
- * the repoId in order to be able to return to an appropriately configured swap
- * list (see {@link org.fdroid.fdroid.views.swap.SwapAppListActivity.SwapAppListFragment#getAppDetailsIntent()}).
- */
- public static class SwapAppDetails extends AppDetails {
-
- private long repoId;
-
- @Override
- protected void onResume() {
- super.onResume();
- repoId = getIntent().getLongExtra(EXTRA_REPO_ID, -1);
- }
-
- @Override
- protected void navigateUp() {
- Intent parentIntent = NavUtils.getParentActivityIntent(this);
- parentIntent.putExtra(EXTRA_REPO_ID, repoId);
- NavUtils.navigateUpTo(this, parentIntent);
- }
-
- }
-
-}
diff --git a/F-Droid/src/org/fdroid/fdroid/views/swap/SwapAppsView.java b/F-Droid/src/org/fdroid/fdroid/views/swap/SwapAppsView.java
new file mode 100644
index 000000000..63777cd4c
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/views/swap/SwapAppsView.java
@@ -0,0 +1,475 @@
+package org.fdroid.fdroid.views.swap;
+
+import android.annotation.TargetApi;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.support.annotation.ColorRes;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.v4.app.LoaderManager;
+import android.support.v4.content.CursorLoader;
+import android.support.v4.content.Loader;
+import android.support.v4.content.LocalBroadcastManager;
+import android.support.v4.view.MenuItemCompat;
+import android.support.v4.widget.CursorAdapter;
+import android.support.v7.widget.SearchView;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.Button;
+import android.widget.ImageView;
+import android.widget.ListView;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+
+import com.nostra13.universalimageloader.core.DisplayImageOptions;
+import com.nostra13.universalimageloader.core.ImageLoader;
+
+import org.fdroid.fdroid.R;
+import org.fdroid.fdroid.UpdateService;
+import org.fdroid.fdroid.Utils;
+import org.fdroid.fdroid.data.Apk;
+import org.fdroid.fdroid.data.ApkProvider;
+import org.fdroid.fdroid.data.App;
+import org.fdroid.fdroid.data.AppProvider;
+import org.fdroid.fdroid.data.Repo;
+import org.fdroid.fdroid.localrepo.SwapService;
+import org.fdroid.fdroid.net.ApkDownloader;
+import org.fdroid.fdroid.net.Downloader;
+
+import java.util.Timer;
+import java.util.TimerTask;
+
+public class SwapAppsView extends ListView implements
+ SwapWorkflowActivity.InnerView,
+ LoaderManager.LoaderCallbacks,
+ SearchView.OnQueryTextListener {
+
+ private DisplayImageOptions displayImageOptions;
+
+ public SwapAppsView(Context context) {
+ super(context);
+ }
+
+ public SwapAppsView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public SwapAppsView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ @TargetApi(Build.VERSION_CODES.LOLLIPOP)
+ public SwapAppsView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ private SwapWorkflowActivity getActivity() {
+ return (SwapWorkflowActivity)getContext();
+ }
+
+ private static final int LOADER_SWAPABLE_APPS = 759283741;
+ private static final String TAG = "SwapAppsView";
+
+ private Repo repo;
+ private AppListAdapter adapter;
+ private String mCurrentFilterString;
+
+ @Override
+ protected void onFinishInflate() {
+ super.onFinishInflate();
+ repo = getActivity().getState().getPeerRepo();
+
+ if (repo == null) {
+ // TODO: Uh oh, something stuffed up for this to happen.
+ // TODO: What is the best course of action from here?
+ }
+
+ adapter = new AppListAdapter(getContext(), getContext().getContentResolver().query(
+ AppProvider.getRepoUri(repo), AppProvider.DataColumns.ALL, null, null, null));
+
+ setAdapter(adapter);
+
+ // either reconnect with an existing loader or start a new one
+ getActivity().getSupportLoaderManager().initLoader(LOADER_SWAPABLE_APPS, null, this);
+
+ setOnItemClickListener(new OnItemClickListener() {
+ public void onItemClick(AdapterView> parent, View v, int position, long id) {
+ showAppDetails(position);
+ }
+ });
+
+ displayImageOptions = Utils.getImageLoadingOptions().build();
+
+ schedulePollForUpdates();
+ }
+
+ private BroadcastReceiver pollForUpdatesReceiver;
+
+ private void pollForUpdates() {
+ if (adapter.getCount() > 1 ||
+ (adapter.getCount() == 1 && !new App((Cursor)adapter.getItem(0)).id.equals("org.fdroid.fdroid"))) {
+ Log.d(TAG, "Not polling for new apps from swap repo, because we already have more than one.");
+ return;
+ }
+
+ Log.d(TAG, "Polling swap repo to see if it has any updates.");
+ getActivity().getService().refreshSwap();
+ if (pollForUpdatesReceiver != null) {
+ pollForUpdatesReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ int statusCode = intent.getIntExtra(UpdateService.EXTRA_STATUS_CODE, -1);
+ switch (statusCode) {
+ case UpdateService.STATUS_COMPLETE_WITH_CHANGES:
+ Log.d(TAG, "Swap repo has updates, notifying the list adapter.");
+ getActivity().runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ adapter.notifyDataSetChanged();
+ }
+ });
+ LocalBroadcastManager.getInstance(getActivity()).unregisterReceiver(pollForUpdatesReceiver);
+ break;
+
+ case UpdateService.STATUS_ERROR_GLOBAL:
+ // TODO: Well, if we can't get the index, we probably can't swapp apps.
+ // Tell the user somethign helpful?
+ break;
+
+ case UpdateService.STATUS_COMPLETE_AND_SAME:
+ schedulePollForUpdates();
+ break;
+ }
+ }
+ };
+ // TODO: Unregister this properly, not just when successful (see swithc statement above)
+ LocalBroadcastManager.getInstance(getActivity()).unregisterReceiver(pollForUpdatesReceiver);
+ }
+ }
+
+ private void schedulePollForUpdates() {
+ Log.d(TAG, "Scheduling poll for updated swap repo in 5 seconds.");
+ new Timer().schedule(new TimerTask() {
+ @Override
+ public void run() {
+ Looper.prepare();
+ pollForUpdates();
+ Looper.loop();
+ }
+ }, 5000);
+ }
+
+ @Override
+ public boolean buildMenu(Menu menu, @NonNull MenuInflater inflater) {
+
+ inflater.inflate(R.menu.swap_search, menu);
+
+ SearchView searchView = new SearchView(getActivity());
+
+ MenuItem searchMenuItem = menu.findItem(R.id.action_search);
+ MenuItemCompat.setActionView(searchMenuItem, searchView);
+ MenuItemCompat.setShowAsAction(searchMenuItem, MenuItemCompat.SHOW_AS_ACTION_ALWAYS);
+
+ searchView.setOnQueryTextListener(this);
+ return true;
+ }
+
+ @Override
+ public int getStep() {
+ return SwapService.STEP_SUCCESS;
+ }
+
+ @Override
+ public int getPreviousStep() {
+ return SwapService.STEP_INTRO;
+ }
+
+ @ColorRes
+ public int getToolbarColour() {
+ return R.color.swap_bright_blue;
+ }
+
+ @Override
+ public String getToolbarTitle() {
+ return getResources().getString(R.string.swap_success);
+ }
+
+ private void showAppDetails(int position) {
+ Cursor c = (Cursor) adapter.getItem(position);
+ App app = new App(c);
+ // TODO: Show app details screen.
+ }
+
+ @Override
+ public CursorLoader onCreateLoader(int id, Bundle args) {
+ Uri uri = TextUtils.isEmpty(mCurrentFilterString)
+ ? AppProvider.getRepoUri(repo)
+ : AppProvider.getSearchUri(repo, mCurrentFilterString);
+
+ return new CursorLoader(getActivity(), uri, AppProvider.DataColumns.ALL, null, null, AppProvider.DataColumns.NAME);
+ }
+
+ @Override
+ public void onLoadFinished(Loader loader, Cursor cursor) {
+ adapter.swapCursor(cursor);
+ }
+
+ @Override
+ public void onLoaderReset(Loader loader) {
+ adapter.swapCursor(null);
+ }
+
+ @Override
+ public boolean onQueryTextChange(String newText) {
+ String newFilter = !TextUtils.isEmpty(newText) ? newText : null;
+ if (mCurrentFilterString == null && newFilter == null) {
+ return true;
+ }
+ if (mCurrentFilterString != null && mCurrentFilterString.equals(newFilter)) {
+ return true;
+ }
+ mCurrentFilterString = newFilter;
+ getActivity().getSupportLoaderManager().restartLoader(LOADER_SWAPABLE_APPS, null, this);
+ return true;
+ }
+
+ @Override
+ public boolean onQueryTextSubmit(String query) {
+ // this is not needed since we respond to every change in text
+ return true;
+ }
+
+ private class AppListAdapter extends CursorAdapter {
+
+ @SuppressWarnings("UnusedDeclaration")
+ private static final String TAG = "AppListAdapter";
+
+ private class ViewHolder {
+
+ private App app;
+
+ @Nullable
+ private Apk apkToInstall;
+
+ ProgressBar progressView;
+ TextView nameView;
+ ImageView iconView;
+ Button btnInstall;
+ TextView btnAttemptInstall;
+ TextView statusInstalled;
+ TextView statusIncompatible;
+
+ private BroadcastReceiver downloadProgressReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ Apk apk = getApkToInstall();
+ String broadcastUrl = intent.getStringExtra(Downloader.EXTRA_ADDRESS);
+ if (!TextUtils.equals(Utils.getApkUrl(apk.repoAddress, apk), broadcastUrl)) {
+ return;
+ }
+
+ int read = intent.getIntExtra(Downloader.EXTRA_BYTES_READ, 0);
+ int total = intent.getIntExtra(Downloader.EXTRA_TOTAL_BYTES, 0);
+ if (total > 0) {
+ int progress = (int) ((double) read / total * 100);
+ progressView.setIndeterminate(false);
+ progressView.setMax(100);
+ progressView.setProgress(progress);
+ }
+ }
+ };
+
+ private BroadcastReceiver apkDownloadReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ Apk apk = getApkToInstall();
+
+ // Note: This can also be done by using the build in IntentFilter.matchData()
+ // functionality, matching against the Intent.getData() of the incoming intent.
+ // I've chosen to do this way, because otherwise we need to query the database
+ // once for each ViewHolder in order to get the repository address for the
+ // apkToInstall. This way, we can wait until we receive an incoming intent (if
+ // at all) and then lazily load the apk to install.
+ String broadcastUrl = intent.getStringExtra(ApkDownloader.EXTRA_URL);
+ if (!TextUtils.equals(Utils.getApkUrl(apk.repoAddress, apk), broadcastUrl)) {
+ return;
+ }
+
+ switch(intent.getStringExtra(ApkDownloader.EXTRA_TYPE)) {
+ // Fallthrough for each of these "downloader no longer going" events...
+ case ApkDownloader.EVENT_APK_DOWNLOAD_COMPLETE:
+ case ApkDownloader.EVENT_APK_DOWNLOAD_CANCELLED:
+ case ApkDownloader.EVENT_ERROR:
+ case ApkDownloader.EVENT_DATA_ERROR_TYPE:
+ resetView();
+ break;
+ }
+ }
+ };
+
+ private ContentObserver appObserver = new ContentObserver(new Handler()) {
+ @Override
+ public void onChange(boolean selfChange) {
+ app = AppProvider.Helper.findById(getActivity().getContentResolver(), app.id);
+ apkToInstall = null; // Force lazy loading to fetch correct apk next time.
+ resetView();
+ }
+ };
+
+ public ViewHolder() {
+ // TODO: Unregister receivers correctly...
+ IntentFilter apkFilter = new IntentFilter(ApkDownloader.ACTION_STATUS);
+ LocalBroadcastManager.getInstance(getActivity()).registerReceiver(apkDownloadReceiver, apkFilter);
+
+ IntentFilter progressFilter = new IntentFilter(Downloader.LOCAL_ACTION_PROGRESS);
+ LocalBroadcastManager.getInstance(getActivity()).registerReceiver(downloadProgressReceiver, progressFilter);
+ }
+
+ public void setApp(@NonNull App app) {
+ if (this.app == null || !this.app.id.equals(app.id)) {
+ this.app = app;
+ apkToInstall = null; // Force lazy loading to fetch the correct apk next time.
+
+ // NOTE: Instead of continually unregistering and reregistering the observer
+ // (with a different URI), this could equally be done by only having one
+ // registration in the constructor, and using the ContentObserver.onChange(boolean, URI)
+ // method and inspecting the URI to see if it maches. However, this was only
+ // implemented on API-16, so leaving like this for now.
+ getActivity().getContentResolver().unregisterContentObserver(appObserver);
+ getActivity().getContentResolver().registerContentObserver(
+ AppProvider.getContentUri(this.app.id), true, appObserver);
+ }
+ resetView();
+ }
+
+ /**
+ * Lazily load the apk from the database the first time it is requested. Means it wont
+ * be loaded unless we receive a download event from the {@link ApkDownloader}.
+ */
+ private Apk getApkToInstall() {
+ if (apkToInstall == null) {
+ apkToInstall = ApkProvider.Helper.find(getActivity(), app.id, app.suggestedVercode);
+ }
+ return apkToInstall;
+ }
+
+ private void resetView() {
+
+ progressView.setVisibility(View.GONE);
+ progressView.setIndeterminate(true);
+ nameView.setText(app.name);
+ ImageLoader.getInstance().displayImage(app.iconUrl, iconView, displayImageOptions);
+
+ btnInstall.setVisibility(View.GONE);
+ btnAttemptInstall.setVisibility(View.GONE);
+ statusInstalled.setVisibility(View.GONE);
+ statusIncompatible.setVisibility(View.GONE);
+
+ if (app.hasUpdates()) {
+ btnInstall.setText(R.string.menu_upgrade);
+ btnInstall.setVisibility(View.VISIBLE);
+ } else if (app.isInstalled()) {
+ statusInstalled.setVisibility(View.VISIBLE);
+ } else if (!app.compatible) {
+ btnAttemptInstall.setVisibility(View.VISIBLE);
+ statusIncompatible.setVisibility(View.VISIBLE);
+ } else {
+ btnInstall.setText(R.string.menu_install);
+ btnInstall.setVisibility(View.VISIBLE);
+ }
+
+ OnClickListener installListener = new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (app.hasUpdates() || app.compatible) {
+ getActivity().install(app);
+ showProgress();
+ }
+ }
+ };
+
+ btnInstall.setOnClickListener(installListener);
+ btnAttemptInstall.setOnClickListener(installListener);
+
+ }
+
+ private void showProgress() {
+ progressView.setVisibility(View.VISIBLE);
+ btnInstall.setVisibility(View.GONE);
+ btnAttemptInstall.setVisibility(View.GONE);
+ statusInstalled.setVisibility(View.GONE);
+ statusIncompatible.setVisibility(View.GONE);
+ }
+ }
+
+ @Nullable
+ private LayoutInflater inflater;
+
+ @Nullable
+ private Drawable defaultAppIcon;
+
+ public AppListAdapter(@NonNull Context context, @Nullable Cursor c) {
+ super(context, c, FLAG_REGISTER_CONTENT_OBSERVER);
+ }
+
+ @NonNull
+ private LayoutInflater getInflater(Context context) {
+ if (inflater == null) {
+ inflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ }
+ return inflater;
+ }
+
+ private Drawable getDefaultAppIcon(Context context) {
+ if (defaultAppIcon == null) {
+ defaultAppIcon = context.getResources().getDrawable(android.R.drawable.sym_def_app_icon);
+ }
+ return defaultAppIcon;
+ }
+
+ @Override
+ public View newView(Context context, Cursor cursor, ViewGroup parent) {
+ View view = getInflater(context).inflate(R.layout.swap_app_list_item, parent, false);
+
+ ViewHolder holder = new ViewHolder();
+
+ holder.progressView = (ProgressBar)view.findViewById(R.id.progress);
+ holder.nameView = (TextView)view.findViewById(R.id.name);
+ holder.iconView = (ImageView)view.findViewById(android.R.id.icon);
+ holder.btnInstall = (Button)view.findViewById(R.id.btn_install);
+ holder.btnAttemptInstall = (TextView)view.findViewById(R.id.btn_attempt_install);
+ holder.statusInstalled = (TextView)view.findViewById(R.id.status_installed);
+ holder.statusIncompatible = (TextView)view.findViewById(R.id.status_incompatible);
+
+ view.setTag(holder);
+ bindView(view, context, cursor);
+ return view;
+ }
+
+ @Override
+ public void bindView(final View view, final Context context, final Cursor cursor) {
+ ViewHolder holder = (ViewHolder)view.getTag();
+ final App app = new App(cursor);
+ holder.setApp(app);
+ }
+ }
+
+}
diff --git a/F-Droid/src/org/fdroid/fdroid/views/swap/SwapConnecting.java b/F-Droid/src/org/fdroid/fdroid/views/swap/SwapConnecting.java
new file mode 100644
index 000000000..b74b4539f
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/views/swap/SwapConnecting.java
@@ -0,0 +1,206 @@
+package org.fdroid.fdroid.views.swap;
+
+import android.annotation.TargetApi;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Build;
+import android.support.annotation.ColorRes;
+import android.support.annotation.NonNull;
+import android.support.v4.content.LocalBroadcastManager;
+import android.util.AttributeSet;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.View;
+import android.widget.Button;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import org.fdroid.fdroid.R;
+import org.fdroid.fdroid.UpdateService;
+import org.fdroid.fdroid.localrepo.SwapService;
+
+// TODO: Use this for the "Preparing local repo" dialog also.
+public class SwapConnecting extends LinearLayout implements SwapWorkflowActivity.InnerView {
+
+ @SuppressWarnings("unused")
+ private final static String TAG = "SwapConnecting";
+
+ public SwapConnecting(Context context) {
+ super(context);
+ }
+
+ public SwapConnecting(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @TargetApi(Build.VERSION_CODES.HONEYCOMB)
+ public SwapConnecting(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ @TargetApi(Build.VERSION_CODES.LOLLIPOP)
+ public SwapConnecting(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ private SwapWorkflowActivity getActivity() {
+ return (SwapWorkflowActivity)getContext();
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ super.onFinishInflate();
+
+ ((TextView) findViewById(R.id.heading)).setText(R.string.swap_connecting);
+ findViewById(R.id.back).setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ getActivity().showIntro();
+ }
+ });
+
+ // TODO: Unregister correctly, not just when being notified of completion or errors.
+ LocalBroadcastManager.getInstance(getActivity()).registerReceiver(
+ repoUpdateReceiver, new IntentFilter(UpdateService.LOCAL_ACTION_STATUS));
+ LocalBroadcastManager.getInstance(getActivity()).registerReceiver(
+ prepareSwapReceiver, new IntentFilter(SwapWorkflowActivity.PrepareSwapRepo.ACTION));
+ }
+
+ private BroadcastReceiver repoUpdateReceiver = new ConnectSwapReceiver();
+ private BroadcastReceiver prepareSwapReceiver = new PrepareSwapReceiver();
+
+ /**
+ * Listens for feedback about a local repository being prepared:
+ * * Apk files copied to the LocalHTTPD webroot
+ * * index.html file prepared
+ * * Icons will be copied to the webroot in the background and so are not part of this process.
+ */
+ class PrepareSwapReceiver extends Receiver {
+
+ @Override
+ protected String getMessageExtra() {
+ return SwapWorkflowActivity.PrepareSwapRepo.EXTRA_MESSAGE;
+ }
+
+ protected int getType(Intent intent) {
+ return intent.getIntExtra(SwapWorkflowActivity.PrepareSwapRepo.EXTRA_TYPE, -1);
+ }
+
+ @Override
+ protected boolean isComplete(Intent intent) {
+ return getType(intent) == SwapWorkflowActivity.PrepareSwapRepo.TYPE_COMPLETE;
+ }
+
+ @Override
+ protected boolean isError(Intent intent) {
+ return getType(intent) == SwapWorkflowActivity.PrepareSwapRepo.TYPE_ERROR;
+ }
+
+ @Override
+ protected void onComplete() {
+ getActivity().onLocalRepoPrepared();
+ }
+ }
+
+ /**
+ * Listens for feedback about a repo update process taking place.
+ * * Tracks an index.jar download and show the progress messages
+ */
+ class ConnectSwapReceiver extends Receiver {
+
+ @Override
+ protected String getMessageExtra() {
+ return UpdateService.EXTRA_MESSAGE;
+ }
+
+ protected int getStatusCode(Intent intent) {
+ return intent.getIntExtra(UpdateService.EXTRA_STATUS_CODE, -1);
+ }
+
+ @Override
+ protected boolean isComplete(Intent intent) {
+ int status = getStatusCode(intent);
+ return status == UpdateService.STATUS_COMPLETE_AND_SAME ||
+ status == UpdateService.STATUS_COMPLETE_WITH_CHANGES;
+ }
+ @Override
+ protected boolean isError(Intent intent) {
+ int status = getStatusCode(intent);
+ return status == UpdateService.STATUS_ERROR_GLOBAL ||
+ status == UpdateService.STATUS_ERROR_LOCAL ||
+ status == UpdateService.STATUS_ERROR_LOCAL_SMALL;
+ }
+
+ @Override
+ protected void onComplete() {
+ getActivity().showSwapConnected();
+ }
+
+ }
+
+ abstract class Receiver extends BroadcastReceiver {
+
+ protected abstract String getMessageExtra();
+ protected abstract boolean isComplete(Intent intent);
+ protected abstract boolean isError(Intent intent);
+ protected abstract void onComplete();
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+
+ TextView progressText = ((TextView) findViewById(R.id.heading));
+ TextView errorText = ((TextView) findViewById(R.id.error));
+ Button backButton = ((Button) findViewById(R.id.back));
+
+ String message;
+ if (intent.hasExtra(getMessageExtra())) {
+ message = intent.getStringExtra(getMessageExtra());
+ if (message != null) {
+ progressText.setText(message);
+ }
+ }
+
+ progressText.setVisibility(View.VISIBLE);
+ errorText.setVisibility(View.GONE);
+ backButton.setVisibility(View.GONE);
+
+ if (isError(intent)) {
+ progressText.setVisibility(View.GONE);
+ errorText.setVisibility(View.VISIBLE);
+ backButton.setVisibility(View.VISIBLE);
+ return;
+ }
+
+ if (isComplete(intent)) {
+ onComplete();
+ }
+ }
+ }
+
+ @Override
+ public boolean buildMenu(Menu menu, @NonNull MenuInflater inflater) {
+ return true;
+ }
+
+ @Override
+ public int getStep() {
+ return SwapService.STEP_CONNECTING;
+ }
+
+ @Override
+ public int getPreviousStep() {
+ return SwapService.STEP_SELECT_APPS;
+ }
+
+ @ColorRes
+ public int getToolbarColour() {
+ return R.color.swap_bright_blue;
+ }
+
+ @Override
+ public String getToolbarTitle() {
+ return getResources().getString(R.string.swap_connecting);
+ }
+}
diff --git a/F-Droid/src/org/fdroid/fdroid/views/swap/SwapProcessManager.java b/F-Droid/src/org/fdroid/fdroid/views/swap/SwapProcessManager.java
deleted file mode 100644
index cb9567bf9..000000000
--- a/F-Droid/src/org/fdroid/fdroid/views/swap/SwapProcessManager.java
+++ /dev/null
@@ -1,12 +0,0 @@
-package org.fdroid.fdroid.views.swap;
-
-/**
- * Defines the contract between the {@link org.fdroid.fdroid.views.swap.SwapActivity}
- * and the fragments which live in it. The fragments each have the responsibility of
- * moving to the next stage of the process, and are entitled to stop swapping too
- * (e.g. when a "Cancel" button is pressed).
- */
-public interface SwapProcessManager {
- void nextStep();
- void stopSwapping();
-}
diff --git a/F-Droid/src/org/fdroid/fdroid/views/swap/SwapWorkflowActivity.java b/F-Droid/src/org/fdroid/fdroid/views/swap/SwapWorkflowActivity.java
new file mode 100644
index 000000000..a9528bf9c
--- /dev/null
+++ b/F-Droid/src/org/fdroid/fdroid/views/swap/SwapWorkflowActivity.java
@@ -0,0 +1,825 @@
+package org.fdroid.fdroid.views.swap;
+
+import android.app.Activity;
+import android.content.ComponentName;
+import android.bluetooth.BluetoothAdapter;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.net.Uri;
+import android.net.wifi.WifiManager;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.support.annotation.ColorRes;
+import android.support.annotation.LayoutRes;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.v4.content.LocalBroadcastManager;
+import android.support.v7.app.AlertDialog;
+import android.support.v7.app.AppCompatActivity;
+import android.support.v7.widget.Toolbar;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Toast;
+
+import com.google.zxing.integration.android.IntentIntegrator;
+import com.google.zxing.integration.android.IntentResult;
+
+import org.fdroid.fdroid.FDroidApp;
+import org.fdroid.fdroid.NfcHelper;
+import org.fdroid.fdroid.Preferences;
+import org.fdroid.fdroid.ProgressListener;
+import org.fdroid.fdroid.R;
+import org.fdroid.fdroid.Utils;
+import org.fdroid.fdroid.data.Apk;
+import org.fdroid.fdroid.data.ApkProvider;
+import org.fdroid.fdroid.data.App;
+import org.fdroid.fdroid.data.NewRepoConfig;
+import org.fdroid.fdroid.installer.Installer;
+import org.fdroid.fdroid.localrepo.LocalRepoManager;
+import org.fdroid.fdroid.localrepo.SwapService;
+import org.fdroid.fdroid.localrepo.peers.Peer;
+import org.fdroid.fdroid.net.ApkDownloader;
+
+import java.io.File;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.Timer;
+import java.util.TimerTask;
+
+import cc.mvdan.accesspoint.WifiApControl;
+
+/**
+ * This activity will do its best to show the most relevant screen about swapping to the user.
+ * The problem comes when there are two competing goals - 1) Show the user a list of apps from another
+ * device to download and install, and 2) Prepare your own list of apps to share.
+ */
+public class SwapWorkflowActivity extends AppCompatActivity {
+
+ /**
+ * When connecting to a swap, we then go and initiate a connection with that
+ * device and ask if it would like to swap with us. Upon receiving that request
+ * and agreeing, we don't then want to be asked whether we want to swap back.
+ * This flag protects against two devices continually going back and forth
+ * among each other offering swaps.
+ */
+ public static final String EXTRA_PREVENT_FURTHER_SWAP_REQUESTS = "preventFurtherSwap";
+ public static final String EXTRA_CONFIRM = "EXTRA_CONFIRM";
+ public static final String EXTRA_REPO_ID = "repoId";
+
+ /**
+ * Ensure that we don't try to handle specific intents more than once in onResume()
+ * (e.g. the "Do you want to swap back with ..." intent).
+ */
+ public static final String EXTRA_HANDLED = "handled";
+
+ private ViewGroup container;
+
+ /**
+ * A UI component (subclass of {@link View}) which forms part of the swap workflow.
+ * There is a one to one mapping between an {@link org.fdroid.fdroid.views.swap.SwapWorkflowActivity.InnerView}
+ * and a {@link SwapService.SwapStep}, and these views know what
+ * the previous view before them should be.
+ */
+ public interface InnerView {
+ /** @return True if the menu should be shown. */
+ boolean buildMenu(Menu menu, @NonNull MenuInflater inflater);
+
+ /** @return The step that this view represents. */
+ @SwapService.SwapStep int getStep();
+
+ @SwapService.SwapStep int getPreviousStep();
+
+ @ColorRes int getToolbarColour();
+
+ String getToolbarTitle();
+ }
+
+ private static final String TAG = "SwapWorkflowActivity";
+
+ private static final int CONNECT_TO_SWAP = 1;
+ private static final int REQUEST_BLUETOOTH_ENABLE_FOR_SWAP = 2;
+ private static final int REQUEST_BLUETOOTH_DISCOVERABLE = 3;
+ private static final int REQUEST_BLUETOOTH_ENABLE_FOR_SEND = 4;
+
+ private Toolbar toolbar;
+ private InnerView currentView;
+ private boolean hasPreparedLocalRepo = false;
+ private PrepareSwapRepo updateSwappableAppsTask = null;
+ private NewRepoConfig confirmSwapConfig = null;
+
+ @NonNull
+ private final ServiceConnection serviceConnection = new ServiceConnection() {
+ @Override
+ public void onServiceConnected(ComponentName className, IBinder binder) {
+ Log.d(TAG, "Swap service connected. Will hold onto it so we can talk to it regularly.");
+ service = ((SwapService.Binder)binder).getService();
+ showRelevantView();
+ }
+
+ // TODO: What causes this? Do we need to stop swapping explicitly when this is invoked?
+ @Override
+ public void onServiceDisconnected(ComponentName className) {
+ Log.d(TAG, "Swap service disconnected");
+ service = null;
+ // TODO: What to do about the UI in this instance?
+ }
+ };
+
+ @Nullable
+ private SwapService service = null;
+
+ @NonNull
+ public SwapService getService() {
+ if (service == null) {
+ // *Slightly* more informative than a null-pointer error that would otherwise happen.
+ throw new IllegalStateException("Trying to access swap service before it was initialized.");
+ }
+ return service;
+ }
+
+ @Override
+ public void onBackPressed() {
+ if (currentView.getStep() == SwapService.STEP_INTRO) {
+ if (service != null) {
+ service.disableAllSwapping();
+ }
+ finish();
+ } else {
+ int nextStep = currentView.getPreviousStep();
+ getService().setStep(nextStep);
+ showRelevantView();
+ }
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ // The server should not be doing anything or occupying any (noticeable) resources
+ // until we actually ask it to enable swapping. Therefore, we will start it nice and
+ // early so we don't have to wait until it is connected later.
+ Intent service = new Intent(this, SwapService.class);
+ if (bindService(service, serviceConnection, Context.BIND_AUTO_CREATE)) {
+ startService(service);
+ }
+
+ setContentView(R.layout.swap_activity);
+
+ toolbar = (Toolbar) findViewById(R.id.toolbar);
+ toolbar.setTitleTextAppearance(getApplicationContext(), R.style.SwapTheme_Wizard_Text_Toolbar);
+ setSupportActionBar(toolbar);
+
+ container = (ViewGroup) findViewById(R.id.fragment_container);
+
+ new SwapDebug().logStatus();
+ }
+
+ @Override
+ protected void onDestroy() {
+ unbindService(serviceConnection);
+ super.onDestroy();
+ }
+
+ @Override
+ public boolean onPrepareOptionsMenu(Menu menu) {
+ menu.clear();
+ boolean parent = super.onPrepareOptionsMenu(menu);
+ boolean inner = currentView != null && currentView.buildMenu(menu, getMenuInflater());
+ return parent || inner;
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+
+ checkIncomingIntent();
+ showRelevantView();
+ }
+
+ private void checkIncomingIntent() {
+ Intent intent = getIntent();
+ if (intent.getBooleanExtra(EXTRA_CONFIRM, false) && !intent.getBooleanExtra(EXTRA_HANDLED, false)) {
+ // Storing config in this variable will ensure that when showRelevantView() is next
+ // run, it will show the connect swap view (if the service is available).
+ intent.putExtra(EXTRA_HANDLED, true);
+ confirmSwapConfig = new NewRepoConfig(this, intent);
+ }
+ }
+
+ public void promptToSelectWifiNetwork() {
+ //
+ // On Android >= 5.0, the neutral button is the one by itself off to the left of a dialog
+ // (not the negative button). Thus, the layout of this dialogs buttons should be:
+ //
+ // | |
+ // +---------------------------------+
+ // | Cancel Hotspot WiFi |
+ // +---------------------------------+
+ //
+ // TODO: Investigate if this should be set dynamically for earlier APIs.
+ //
+ new AlertDialog.Builder(this)
+ .setTitle(R.string.swap_join_same_wifi)
+ .setMessage(R.string.swap_join_same_wifi_desc)
+ .setNeutralButton(R.string.cancel, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ // Do nothing
+ }
+ }
+ ).setPositiveButton(R.string.wifi, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ startActivity(new Intent(WifiManager.ACTION_PICK_WIFI_NETWORK));
+ }
+ }
+ ).setNegativeButton(R.string.wifi_ap, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ promptToSetupWifiAP();
+ }
+ }
+ ).create().show();
+ }
+
+ private void promptToSetupWifiAP() {
+ WifiManager wifiManager = (WifiManager) getSystemService(Context.WIFI_SERVICE);
+ WifiApControl ap = WifiApControl.getInstance(this);
+ wifiManager.setWifiEnabled(false);
+ if (!ap.enable()) {
+ Log.e(TAG, "Could not enable WiFi AP.");
+ // TODO: Feedback to user?
+ } else {
+ Log.d(TAG, "WiFi AP enabled.");
+ // TODO: Seems to be broken some times...
+ }
+ }
+
+ private void showRelevantView() {
+ showRelevantView(false);
+ }
+
+ private void showRelevantView(boolean forceReload) {
+
+ if (service == null) {
+ showInitialLoading();
+ return;
+ }
+
+ // This is separate from the switch statement below, because it is usually populated
+ // during onResume, when there is a high probability of not having a swap service
+ // available. Thus, we were unable to set the state of the swap service appropriately.
+ if (confirmSwapConfig != null) {
+ showConfirmSwap(confirmSwapConfig);
+ confirmSwapConfig = null;
+ return;
+ }
+
+ if (!forceReload) {
+ if (container.getVisibility() == View.GONE || currentView != null && currentView.getStep() == service.getStep()) {
+ // Already showing the correct step, so don't bother changing anything.
+ return;
+ }
+ }
+
+ switch(service.getStep()) {
+ case SwapService.STEP_INTRO:
+ showIntro();
+ break;
+ case SwapService.STEP_SELECT_APPS:
+ showSelectApps();
+ break;
+ case SwapService.STEP_SHOW_NFC:
+ showNfc();
+ break;
+ case SwapService.STEP_JOIN_WIFI:
+ showJoinWifi();
+ break;
+ case SwapService.STEP_WIFI_QR:
+ showWifiQr();
+ break;
+ case SwapService.STEP_SUCCESS:
+ showSwapConnected();
+ break;
+ case SwapService.STEP_CONNECTING:
+ // TODO: Properly decide what to do here (i.e. returning to the activity after it was connecting)...
+ inflateInnerView(R.layout.swap_blank);
+ break;
+ }
+ }
+
+ public SwapService getState() {
+ return service;
+ }
+
+ private void showNfc() {
+ if (!attemptToShowNfc()) {
+ showWifiQr();
+ }
+ }
+
+ private InnerView inflateInnerView(@LayoutRes int viewRes) {
+ container.removeAllViews();
+ View view = ((LayoutInflater)getSystemService(LAYOUT_INFLATER_SERVICE)).inflate(viewRes, container, false);
+ currentView = (InnerView)view;
+
+ // Don't actually set the step to STEP_INITIAL_LOADING, as we are going to use this view
+ // purely as a placeholder for _whatever view is meant to be shown_.
+ if (currentView.getStep() != SwapService.STEP_INITIAL_LOADING) {
+ if (service == null) {
+ throw new IllegalStateException("We are not in the STEP_INITIAL_LOADING state, but the service is not ready.");
+ }
+ service.setStep(currentView.getStep());
+ }
+
+ toolbar.setBackgroundColor(getResources().getColor(currentView.getToolbarColour()));
+ toolbar.setTitle(currentView.getToolbarTitle());
+ toolbar.setNavigationIcon(R.drawable.ic_close_white);
+ toolbar.setNavigationOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ onToolbarCancel();
+ }
+ });
+ container.addView(view);
+ supportInvalidateOptionsMenu();
+
+ return currentView;
+ }
+
+ private void onToolbarCancel() {
+ getService().disableAllSwapping();
+ finish();
+ }
+
+ private void showInitialLoading() {
+ inflateInnerView(R.layout.swap_initial_loading);
+ }
+
+ public void showIntro() {
+ // If we were previously swapping with a specific client, forget that we were doing that,
+ // as we are starting over now.
+ getService().swapWith(null);
+
+ if (!getService().isEnabled()) {
+ prepareInitialRepo();
+ }
+ getService().scanForPeers();
+ inflateInnerView(R.layout.swap_blank);
+ }
+
+ private void showConfirmSwap(@NonNull NewRepoConfig config) {
+ ((ConfirmReceive)inflateInnerView(R.layout.swap_confirm_receive)).setup(config);
+ }
+
+ public void startQrWorkflow() {
+ if (!getService().isEnabled()) {
+ new AlertDialog.Builder(this)
+ .setTitle(R.string.swap_not_enabled)
+ .setMessage(R.string.swap_not_enabled_description)
+ .setCancelable(true)
+ .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ // Do nothing. The dialog will get dismissed anyway, which is all we ever wanted...
+ }
+ })
+ .create().show();
+ } else {
+ showSelectApps();
+ }
+ }
+
+ public void showSelectApps() {
+ inflateInnerView(R.layout.swap_select_apps);
+ }
+
+ public void sendFDroid() {
+ // If Bluetooth has not been enabled/turned on, then enabling device discoverability
+ // will automatically enable Bluetooth.
+ BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
+ if (adapter != null) {
+ if (adapter.getState() != BluetoothAdapter.STATE_ON) {
+ Intent discoverBt = new Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE);
+ discoverBt.putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, 120);
+ startActivityForResult(discoverBt, REQUEST_BLUETOOTH_ENABLE_FOR_SEND);
+ } else {
+ sendFDroidApk();
+ }
+ } else {
+ new AlertDialog.Builder(this)
+ .setTitle(R.string.bluetooth_unavailable)
+ .setMessage(R.string.swap_cant_send_no_bluetooth)
+ .setNegativeButton(
+ R.string.cancel,
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {}
+ }
+ ).create().show();
+ }
+ }
+
+ private void sendFDroidApk() {
+ ((FDroidApp) getApplication()).sendViaBluetooth(this, Activity.RESULT_OK, "org.fdroid.fdroid");
+ }
+
+ // TODO: Figure out whether they have changed since last time UpdateAsyncTask was run.
+ // If the local repo is running, then we can ask it what apps it is swapping and compare with that.
+ // Otherwise, probably will need to scan the file system.
+ public void onAppsSelected() {
+ if (updateSwappableAppsTask == null && !hasPreparedLocalRepo) {
+ updateSwappableAppsTask = new PrepareSwapRepo(getService().getAppsToSwap());
+ updateSwappableAppsTask.execute();
+ getService().setStep(SwapService.STEP_CONNECTING);
+ inflateInnerView(R.layout.swap_connecting);
+ } else {
+ onLocalRepoPrepared();
+ }
+ }
+
+ private void prepareInitialRepo() {
+ // TODO: Make it so that this and updateSwappableAppsTask (the _real_ swap repo task)
+ // don't stomp on eachothers toes. The other one should wait for this to finish, or cancel
+ // this, but this should never take precedence over the other.
+ // TODO: Also don't allow this to run multiple times (e.g. if a user keeps navigating back
+ // to the main screen.
+ Log.d(TAG, "Preparing initial repo with only F-Droid, until we have allowed the user to configure their own repo.");
+ new PrepareInitialSwapRepo().execute();
+ }
+
+ /**
+ * Once the UpdateAsyncTask has finished preparing our repository index, we can
+ * show the next screen to the user. This will be one of two things:
+ * * If we directly selected a peer to swap with initially, we will skip straight to getting
+ * the list of apps from that device.
+ * * Alternatively, if we didn't have a person to connect to, and instead clicked "Scan QR Code",
+ * then we want to show a QR code or NFC dialog.
+ */
+ public void onLocalRepoPrepared() {
+ updateSwappableAppsTask = null;
+ hasPreparedLocalRepo = true;
+ if (getService().isConnectingWithPeer()) {
+ startSwappingWithPeer();
+ } else if (!attemptToShowNfc()) {
+ showWifiQr();
+ }
+ }
+
+ private void startSwappingWithPeer() {
+ getService().connectToPeer();
+ inflateInnerView(R.layout.swap_connecting);
+ }
+
+ private void showJoinWifi() {
+ inflateInnerView(R.layout.swap_join_wifi);
+ }
+
+ public void showWifiQr() {
+ inflateInnerView(R.layout.swap_wifi_qr);
+ }
+
+ public void showSwapConnected() {
+ inflateInnerView(R.layout.swap_success);
+ }
+
+ private boolean attemptToShowNfc() {
+ // TODO: What if NFC is disabled? Hook up with NfcNotEnabledActivity? Or maybe only if they
+ // click a relevant button?
+
+ // Even if they opted to skip the message which says "Touch devices to swap",
+ // we still want to actually enable the feature, so that they could touch
+ // during the wifi qr code being shown too.
+ boolean nfcMessageReady = NfcHelper.setPushMessage(this, Utils.getSharingUri(FDroidApp.repo));
+
+ if (Preferences.get().showNfcDuringSwap() && nfcMessageReady) {
+ inflateInnerView(R.layout.swap_nfc);
+ return true;
+ }
+ return false;
+ }
+
+ public void swapWith(Peer peer) {
+ getService().stopScanningForPeers();
+ getService().swapWith(peer);
+ showSelectApps();
+ }
+
+ /**
+ * This is for when we initiate a swap by viewing the "Are you sure you want to swap with" view
+ * This can arise either:
+ * * As a result of scanning a QR code (in which case we likely already have a repo setup) or
+ * * As a result of the other device selecting our device in the "start swap" screen, in which
+ * case we are likely just sitting on the start swap screen also, and haven't configured
+ * anything yet.
+ */
+ public void swapWith(NewRepoConfig repoConfig) {
+ Peer peer = repoConfig.toPeer();
+ if (getService().getStep() == SwapService.STEP_INTRO || getService().getStep() == SwapService.STEP_CONFIRM_SWAP) {
+ // This will force the "Select apps to swap" workflow to begin.
+ // TODO: Find a better way to decide whether we need to select the apps. Not sure if we
+ // can or cannot be in STEP_INTRO with a full blown repo ready to swap.
+ swapWith(peer);
+ } else {
+ getService().swapWith(repoConfig.toPeer());
+ startSwappingWithPeer();
+ }
+ }
+
+ public void denySwap() {
+ showIntro();
+ }
+
+ /**
+ * Attempts to open a QR code scanner, in the hope a user will then scan the QR code of another
+ * device configured to swapp apps with us. Delegates to the zxing library to do so.
+ */
+ public void initiateQrScan() {
+ IntentIntegrator integrator = new IntentIntegrator(this);
+ integrator.initiateScan();
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent intent) {
+ IntentResult scanResult = IntentIntegrator.parseActivityResult(requestCode, resultCode, intent);
+ if (scanResult != null) {
+ if (scanResult.getContents() != null) {
+ NewRepoConfig repoConfig = new NewRepoConfig(this, scanResult.getContents());
+ if (repoConfig.isValidRepo()) {
+ confirmSwapConfig = repoConfig;
+ showRelevantView();
+ } else {
+ Toast.makeText(this, R.string.swap_qr_isnt_for_swap, Toast.LENGTH_SHORT).show();
+ }
+ }
+ } else if (requestCode == CONNECT_TO_SWAP && resultCode == Activity.RESULT_OK) {
+ finish();
+ } else if (requestCode == REQUEST_BLUETOOTH_ENABLE_FOR_SWAP) {
+
+ if (resultCode == RESULT_OK) {
+ Log.d(TAG, "User enabled Bluetooth, will make sure we are discoverable.");
+ ensureBluetoothDiscoverableThenStart();
+ } 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.");
+ getState().getBluetoothSwap().startInBackground();
+ } else {
+ Log.d(TAG, "User chose not to make Bluetooth discoverable, so doing nothing (i.e. sticking with wifi).");
+ }
+
+ } else if (requestCode == REQUEST_BLUETOOTH_ENABLE_FOR_SEND) {
+ sendFDroidApk();
+ }
+ }
+
+ /**
+ * 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).
+ * * 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 startBluetoothSwap() {
+
+ Log.d(TAG, "Initiating Bluetooth swap, will ensure the Bluetooth devices is enabled and discoverable before starting server.");
+ BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
+
+ if (adapter != null)
+ if (adapter.isEnabled()) {
+ Log.d(TAG, "Bluetooth enabled, will check if device is discoverable with device.");
+ ensureBluetoothDiscoverableThenStart();
+ } else {
+ Log.d(TAG, "Bluetooth disabled, asking user to enable it.");
+ Intent enableBtIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE);
+ startActivityForResult(enableBtIntent, REQUEST_BLUETOOTH_ENABLE_FOR_SWAP);
+ }
+ }
+
+ private void ensureBluetoothDiscoverableThenStart() {
+ 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); // TODO: What about when this expires? What if user manually disables discovery?
+ startActivityForResult(intent, REQUEST_BLUETOOTH_DISCOVERABLE);
+ }
+
+ if (service == null) {
+ throw new IllegalStateException("Can't start Bluetooth swap because service is null for some strange reason.");
+ }
+
+ service.getBluetoothSwap().startInBackground();
+ }
+
+ class PrepareInitialSwapRepo extends PrepareSwapRepo {
+ public PrepareInitialSwapRepo() {
+ super(new HashSet<>(Arrays.asList(new String[] { "org.fdroid.fdroid" })));
+ }
+ }
+
+ class PrepareSwapRepo extends AsyncTask {
+
+ public static final String ACTION = "PrepareSwapRepo.Action";
+ public static final String EXTRA_MESSAGE = "PrepareSwapRepo.Status.Message";
+ public static final String EXTRA_TYPE = "PrepareSwapRepo.Action.Type";
+ public static final int TYPE_STATUS = 0;
+ public static final int TYPE_COMPLETE = 1;
+ public static final int TYPE_ERROR = 2;
+
+ @SuppressWarnings("UnusedDeclaration")
+ private static final String TAG = "UpdateAsyncTask";
+
+ @NonNull
+ protected final Set selectedApps;
+
+ @NonNull
+ protected final Uri sharingUri;
+
+ @NonNull
+ protected final Context context;
+
+ public PrepareSwapRepo(@NonNull Set apps) {
+ context = SwapWorkflowActivity.this;
+ selectedApps = apps;
+ sharingUri = Utils.getSharingUri(FDroidApp.repo);
+ }
+
+ private void broadcast(int type) {
+ broadcast(type, null);
+ }
+
+ private void broadcast(int type, String message) {
+ Intent intent = new Intent(ACTION);
+ intent.putExtra(EXTRA_TYPE, type);
+ if (message != null) {
+ Log.d(TAG, "Preparing swap: " + message);
+ intent.putExtra(EXTRA_MESSAGE, message);
+ }
+ LocalBroadcastManager.getInstance(SwapWorkflowActivity.this).sendBroadcast(intent);
+ }
+
+ @Override
+ protected Void doInBackground(Void... params) {
+ try {
+ final LocalRepoManager lrm = LocalRepoManager.get(context);
+ broadcast(TYPE_STATUS, getString(R.string.deleting_repo));
+ lrm.deleteRepo();
+ for (String app : selectedApps) {
+ broadcast(TYPE_STATUS, String.format(getString(R.string.adding_apks_format), app));
+ lrm.addApp(context, app);
+ }
+ lrm.writeIndexPage(sharingUri.toString());
+ broadcast(TYPE_STATUS, getString(R.string.writing_index_jar));
+ lrm.writeIndexJar();
+ broadcast(TYPE_STATUS, getString(R.string.linking_apks));
+ lrm.copyApksToRepo();
+ broadcast(TYPE_STATUS, getString(R.string.copying_icons));
+ // run the icon copy without progress, its not a blocker
+ // TODO: Fix lint error about this being run from a worker thread, says it should be
+ // run on a main thread.
+ new AsyncTask() {
+
+ @Override
+ protected Void doInBackground(Void... params) {
+ lrm.copyIconsToRepo();
+ return null;
+ }
+ }.execute();
+
+ broadcast(TYPE_COMPLETE);
+ } catch (Exception e) {
+ broadcast(TYPE_ERROR);
+ e.printStackTrace();
+ }
+ return null;
+ }
+ }
+
+ /**
+ * Helper class to try and make sense of what the swap workflow is currently doing.
+ * The more technologies are involved in the process (e.g. Bluetooth/Wifi/NFC/etc)
+ * the harder it becomes to reason about and debug the whole thing. Thus,this class
+ * will periodically dump the state to logcat so that it is easier to see when certain
+ * protocols are enabled/disabled.
+ *
+ * To view only this output from logcat:
+ *
+ * adb logcat | grep 'Swap Status'
+ *
+ * To exclude this output from logcat (it is very noisy):
+ *
+ * adb logcat | grep -v 'Swap Status'
+ *
+ */
+ class SwapDebug {
+
+ public void logStatus() {
+
+ if (true) return;
+
+ String message = "";
+ if (service == null) {
+ message = "No swap service";
+ } else {
+ {
+ String bluetooth = service.getBluetoothSwap().isConnected() ? "Y" : " N";
+ String wifi = service.getWifiSwap().isConnected() ? "Y" : " N";
+ String mdns = service.getWifiSwap().getBonjour().isConnected() ? "Y" : " N";
+ message += "Swap { BT: " + bluetooth + ", WiFi: " + wifi + ", mDNS: " + mdns + "}, ";
+ }
+
+ {
+ BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
+ String bluetooth = "N/A";
+ if (adapter != null) {
+ Map scanModes = new HashMap<>(3);
+ scanModes.put(BluetoothAdapter.SCAN_MODE_CONNECTABLE, "CON");
+ scanModes.put(BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE, "CON_DISC");
+ scanModes.put(BluetoothAdapter.SCAN_MODE_NONE, "NONE");
+ bluetooth = "\"" + adapter.getName() + "\" - " + scanModes.get(adapter.getScanMode());
+ }
+
+ String wifi = service.getBonjourFinder().isScanning() ? "Y" : " N";
+ message += "Find { BT: " + bluetooth + ", WiFi: " + wifi + "}";
+ }
+ }
+
+ Date now = new Date();
+ Log.d("Swap Status", now.getHours() + ":" + now.getMinutes() + ":" + now.getSeconds() + " " + message);
+
+ new Timer().schedule(new TimerTask() {
+ @Override
+ public void run() {
+ new SwapDebug().logStatus();
+ }
+ },
+ 1000
+ );
+ }
+ }
+
+ public void install(@NonNull final App app) {
+ final Apk apkToInstall = ApkProvider.Helper.find(this, app.id, app.suggestedVercode);
+ final ApkDownloader downloader = new ApkDownloader(this, apkToInstall, apkToInstall.repoAddress);
+ downloader.setProgressListener(new ProgressListener() {
+ @Override
+ public void onProgress(Event event) {
+ switch (event.type) {
+ case ApkDownloader.EVENT_APK_DOWNLOAD_COMPLETE:
+ handleDownloadComplete(downloader.localFile());
+ break;
+ case ApkDownloader.EVENT_ERROR:
+ break;
+ }
+ }
+ });
+ downloader.download();
+ }
+
+ private void handleDownloadComplete(File apkFile) {
+
+ try {
+ Installer.getActivityInstaller(SwapWorkflowActivity.this, new Installer.InstallerCallback() {
+ @Override
+ public void onSuccess(int operation) {
+ // TODO: Don't reload the view weely-neely, but rather get the view to listen
+ // for broadcasts that say the install was complete.
+ showRelevantView(true);
+ }
+
+ @Override
+ public void onError(int operation, int errorCode) {
+ // TODO: Boo!
+ }
+ }).installPackage(apkFile);
+ } catch (Installer.AndroidNotCompatibleException e) {
+ // TODO: Handle exception properly
+ }
+ }
+
+}
diff --git a/F-Droid/src/org/fdroid/fdroid/views/swap/WifiQrFragment.java b/F-Droid/src/org/fdroid/fdroid/views/swap/WifiQrView.java
similarity index 55%
rename from F-Droid/src/org/fdroid/fdroid/views/swap/WifiQrFragment.java
rename to F-Droid/src/org/fdroid/fdroid/views/swap/WifiQrView.java
index 6714fe8eb..e1eeda31f 100644
--- a/F-Droid/src/org/fdroid/fdroid/views/swap/WifiQrFragment.java
+++ b/F-Droid/src/org/fdroid/fdroid/views/swap/WifiQrView.java
@@ -1,126 +1,134 @@
package org.fdroid.fdroid.views.swap;
-import android.app.Activity;
+import android.annotation.TargetApi;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.graphics.LightingColorFilter;
import android.net.Uri;
-import android.os.Bundle;
-import android.support.v4.app.Fragment;
+import android.os.Build;
+import android.support.annotation.ColorRes;
+import android.support.annotation.NonNull;
import android.support.v4.content.LocalBroadcastManager;
import android.text.TextUtils;
+import android.util.AttributeSet;
import android.util.Log;
-import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
import android.view.View;
-import android.view.ViewGroup;
import android.widget.Button;
import android.widget.ImageView;
+import android.widget.ScrollView;
import android.widget.TextView;
-import android.widget.Toast;
import com.google.zxing.integration.android.IntentIntegrator;
-import com.google.zxing.integration.android.IntentResult;
import org.apache.http.NameValuePair;
import org.apache.http.client.utils.URLEncodedUtils;
-import org.fdroid.fdroid.FDroid;
import org.fdroid.fdroid.FDroidApp;
import org.fdroid.fdroid.Preferences;
import org.fdroid.fdroid.QrGenAsyncTask;
import org.fdroid.fdroid.R;
import org.fdroid.fdroid.Utils;
-import org.fdroid.fdroid.data.NewRepoConfig;
+import org.fdroid.fdroid.localrepo.SwapService;
import org.fdroid.fdroid.net.WifiStateChangeService;
import java.net.URI;
import java.util.List;
import java.util.Locale;
-public class WifiQrFragment extends Fragment {
+public class WifiQrView extends ScrollView implements SwapWorkflowActivity.InnerView {
- private static final int CONNECT_TO_SWAP = 1;
+ private static final String TAG = "WifiQrView";
- private static final String TAG = "WifiQrFragment";
+ public WifiQrView(Context context) {
+ super(context);
+ }
- private final BroadcastReceiver onWifiChange = new BroadcastReceiver() {
- @Override
- public void onReceive(Context context, Intent i) {
- setUIFromWifi();
- }
- };
+ public WifiQrView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
- private SwapProcessManager swapManager;
+ public WifiQrView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ @TargetApi(Build.VERSION_CODES.LOLLIPOP)
+ public WifiQrView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ private SwapWorkflowActivity getActivity() {
+ return (SwapWorkflowActivity)getContext();
+ }
@Override
- public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
- View view = inflater.inflate(R.layout.swap_wifi_qr, container, false);
- ImageView qrImage = (ImageView)view.findViewById(R.id.wifi_qr_code);
+ protected void onFinishInflate() {
+ super.onFinishInflate();
+ setUIFromWifi();
+
+ ImageView qrImage = (ImageView)findViewById(R.id.wifi_qr_code);
// Replace all blacks with the background blue.
qrImage.setColorFilter(new LightingColorFilter(0xffffffff, getResources().getColor(R.color.swap_blue)));
- Button openQr = (Button)view.findViewById(R.id.btn_qr_scanner);
+ Button openQr = (Button)findViewById(R.id.btn_qr_scanner);
openQr.setOnClickListener(new Button.OnClickListener() {
@Override
public void onClick(View v) {
- IntentIntegrator integrator = new IntentIntegrator(WifiQrFragment.this);
- integrator.initiateScan();
+ getActivity().initiateQrScan();
}
});
- Button cancel = (Button)view.findViewById(R.id.btn_cancel_swap);
- cancel.setOnClickListener(new Button.OnClickListener() {
- @Override
- public void onClick(View v) {
- swapManager.stopSwapping();
- }
- });
- return view;
+ // TODO: Unregister this receiver properly.
+ LocalBroadcastManager.getInstance(getActivity()).registerReceiver(
+ new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ setUIFromWifi();
+ }
+ },
+ new IntentFilter(WifiStateChangeService.BROADCAST)
+ );
}
@Override
- public void onAttach(Activity activity) {
- super.onAttach(activity);
- swapManager = (SwapProcessManager)activity;
+ public boolean buildMenu(Menu menu, @NonNull MenuInflater inflater) {
+ return false;
}
@Override
- public void onActivityResult(int requestCode, int resultCode, Intent intent) {
- IntentResult scanResult = IntentIntegrator.parseActivityResult(requestCode, resultCode, intent);
- if (scanResult != null) {
- if (scanResult.getContents() != null) {
- NewRepoConfig repoConfig = new NewRepoConfig(getActivity(), scanResult.getContents());
- if (repoConfig.isValidRepo()) {
- startActivityForResult(new Intent(FDroid.ACTION_ADD_REPO, Uri.parse(scanResult.getContents()), getActivity(), ConnectSwapActivity.class), CONNECT_TO_SWAP);
- } else {
- Toast.makeText(getActivity(), "The QR code you scanned doesn't look like a swap code.", Toast.LENGTH_SHORT).show();
- }
- }
- } else if (requestCode == CONNECT_TO_SWAP && resultCode == Activity.RESULT_OK) {
- getActivity().finish();
- }
+ public int getStep() {
+ return SwapService.STEP_WIFI_QR;
}
- public void onResume() {
- super.onResume();
- setUIFromWifi();
+ @Override
+ public int getPreviousStep() {
+ // TODO: Find a way to make this optionally go back to the NFC screen if appropriate.
+ return SwapService.STEP_JOIN_WIFI;
+ }
- LocalBroadcastManager.getInstance(getActivity()).registerReceiver(onWifiChange,
- new IntentFilter(WifiStateChangeService.BROADCAST));
+ @ColorRes
+ public int getToolbarColour() {
+ return R.color.swap_blue;
+ }
+
+ @Override
+ public String getToolbarTitle() {
+ return getResources().getString(R.string.swap_scan_qr);
}
private void setUIFromWifi() {
- if (TextUtils.isEmpty(FDroidApp.repo.address) || getView() == null)
+ if (TextUtils.isEmpty(FDroidApp.repo.address))
return;
String scheme = Preferences.get().isLocalRepoHttpsEnabled() ? "https://" : "http://";
// the fingerprint is not useful on the button label
String buttonLabel = scheme + FDroidApp.ipAddressString + ":" + FDroidApp.port;
- TextView ipAddressView = (TextView) getView().findViewById(R.id.device_ip_address);
+ TextView ipAddressView = (TextView) findViewById(R.id.device_ip_address);
ipAddressView.setText(buttonLabel);
/*
@@ -151,7 +159,7 @@ public class WifiQrFragment extends Fragment {
qrUriString += "&";
}
qrUriString += parameter.getName().toUpperCase(Locale.ENGLISH) + "=" +
- parameter.getValue().toUpperCase(Locale.ENGLISH);
+ parameter.getValue().toUpperCase(Locale.ENGLISH);
}
}
diff --git a/F-Droid/src/sun/net/www/protocol/bluetooth/Handler.java b/F-Droid/src/sun/net/www/protocol/bluetooth/Handler.java
new file mode 100644
index 000000000..18ce3d877
--- /dev/null
+++ b/F-Droid/src/sun/net/www/protocol/bluetooth/Handler.java
@@ -0,0 +1,17 @@
+package sun.net.www.protocol.bluetooth;
+
+import java.io.IOException;
+import java.net.URL;
+import java.net.URLConnection;
+import java.net.URLStreamHandler;
+
+/**
+ * This class is added so that the bluetooth:// scheme we use for the {@link org.fdroid.fdroid.net.BluetoothDownloader}
+ * is not treated as invalid by the {@link URL} class.
+ */
+public class Handler extends URLStreamHandler {
+ @Override
+ protected URLConnection openConnection(URL u) throws IOException {
+ throw new UnsupportedOperationException("openConnection() not supported on bluetooth:// URLs");
+ }
+}
diff --git a/F-Droid/tools/svg-to-drawables.sh b/F-Droid/tools/svg-to-drawables.sh
new file mode 100755
index 000000000..5dfd44840
--- /dev/null
+++ b/F-Droid/tools/svg-to-drawables.sh
@@ -0,0 +1,78 @@
+#!/bin/bash
+
+# Originally by https://github.com/vitriolix/storymaker-art/blob/3ee3b4aad8db4fd24290b0173e8129a3d0e5299d/original/convert.sh,
+# then modified to make it generic and able to be used by any Android project.
+#
+# Requires ImageMagick to be installed.
+# Some builds of ImageMagick on OSX have problems generating the images correctly.
+#
+# This script scales and creates images at the correct dpi level for Android.
+# When creating svg files set the image size to the size that you want your hdpi images to be.
+
+function usage {
+ echo $1
+ echo ""
+ echo "USAGE: svg-to-drawable.sh svg-image res-directory [manually-scaled-res-directory]"
+ echo " svg-image Path to the .svg image to convert"
+ echo " res-directory Usually \"res\" in your android project"
+ echo " manually-scaled-res-directory Put manually scaled images in this dir, useful for, e.g. differing levels of details"
+ exit
+}
+
+SVG_FILE=$1
+OUTPUT_RES_DIR=$2
+SCALED_RES_DIR=$3
+
+RES_NORMAL=drawable
+RES_XXXHDPI=drawable-xxxhdpi
+RES_XXHDPI=drawable-xxhdpi
+RES_XHDPI=drawable-xhdpi
+RES_HDPI=drawable-hdpi
+RES_MDPI=drawable-mdpi
+RES_LDPI=drawable-ldpi
+
+if (( $# < 2 ))
+then
+ usage "ERROR: Requires at least svg-image and res-directory to be passed to script"
+elif [ ! -d $OUTPUT_RES_DIR ]
+then
+ usage "ERROR: $OUTPUT_RES_DIR is not a directory"
+fi
+
+function convert_drawable {
+ DIR=$1
+ FILE_PATH=$2
+ FILE_NAME=`basename $FILE_PATH`
+ SCALE=$3
+ PNG_FILE=${FILE_NAME/.svg}.png
+ DRAWABLE_DIR=$OUTPUT_RES_DIR/$DIR
+ OUTPUT_PATH=$DRAWABLE_DIR/$PNG_FILE
+
+ if [ ! -d $DRAWABLE_DIR ]; then
+ mkdir $DRAWABLE_DIR
+ fi
+
+ if [ -f $OUTPUT_PATH ]; then
+ rm $OUTPUT_PATH
+ fi
+
+ INFO=""
+ if [ -f $SCALED_RES_DIR/$DIR/$FILE_NAME ]; then
+ INFO=" (Using manually scaled file from $DIR/$FILE_NAME)"
+ convert -background none $SCALED_RES_DIR/$DIR/$FILE $OUTPUT_PATH || exit
+ else
+ INFO=" (Scaled by $SCALE%)"
+ convert -background none $FILE_PATH[$SCALE%] $OUTPUT_PATH || exit
+ fi
+ echo " $OUTPUT_PATH$INFO"
+
+}
+
+echo "Processing $SVG_FILE"
+convert_drawable $RES_NORMAL $SVG_FILE 100
+convert_drawable $RES_XXXHDPI $SVG_FILE 150
+convert_drawable $RES_XXHDPI $SVG_FILE 125
+convert_drawable $RES_XHDPI $SVG_FILE 100
+convert_drawable $RES_HDPI $SVG_FILE 75
+convert_drawable $RES_MDPI $SVG_FILE 50
+convert_drawable $RES_LDPI $SVG_FILE 37.5
diff --git a/README.md b/README.md
index 7f2d01364..fd786cb42 100644
--- a/README.md
+++ b/README.md
@@ -140,4 +140,4 @@ Some icons are made by [Picol](http://www.flaticon.com/authors/picol),
Other icons are from the
[Material Design Icon set](https://github.com/google/material-design-icons)
released under an
-[Attribution 4.0 International license](http://creativecommons.org/licenses/by/4.0/).
+[Attribution 4.0 International license](http://creativecommons.org/licenses/by/4.0/).
\ No newline at end of file