diff --git a/app/src/androidTest/java/org/fdroid/fdroid/MainActivityEspressoTest.java b/app/src/androidTest/java/org/fdroid/fdroid/MainActivityEspressoTest.java
index 29ef5621a..346a65e78 100644
--- a/app/src/androidTest/java/org/fdroid/fdroid/MainActivityEspressoTest.java
+++ b/app/src/androidTest/java/org/fdroid/fdroid/MainActivityEspressoTest.java
@@ -1,5 +1,6 @@
 package org.fdroid.fdroid;
 
+import android.Manifest;
 import android.app.Instrumentation;
 import android.os.Build;
 import android.support.test.InstrumentationRegistry;
@@ -7,6 +8,7 @@ import android.support.test.espresso.IdlingPolicies;
 import android.support.test.espresso.ViewInteraction;
 import android.support.test.filters.LargeTest;
 import android.support.test.rule.ActivityTestRule;
+import android.support.test.rule.GrantPermissionRule;
 import android.support.test.runner.AndroidJUnit4;
 import android.support.test.uiautomator.UiDevice;
 import android.support.test.uiautomator.UiObject;
@@ -120,6 +122,14 @@ public class MainActivityEspressoTest {
     public ActivityTestRule<MainActivity> activityTestRule =
             new ActivityTestRule<>(MainActivity.class);
 
+    @Rule
+    public GrantPermissionRule accessCoarseLocationPermissionRule = GrantPermissionRule.grant(
+            Manifest.permission.ACCESS_COARSE_LOCATION);
+
+    @Rule
+    public GrantPermissionRule writeExternalStoragePermissionRule = GrantPermissionRule.grant(
+            Manifest.permission.WRITE_EXTERNAL_STORAGE);
+
     @Test
     public void bottomNavFlavorCheck() {
         onView(withText(R.string.updates)).check(matches(isDisplayed()));
diff --git a/app/src/basic/java/org/fdroid/fdroid/localrepo/SDCardScannerService.java b/app/src/basic/java/org/fdroid/fdroid/localrepo/SDCardScannerService.java
new file mode 100644
index 000000000..3fc00c4ce
--- /dev/null
+++ b/app/src/basic/java/org/fdroid/fdroid/localrepo/SDCardScannerService.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright (C) 2018 Hans-Christoph Steiner <hans@eds.org>
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * as published by the Free Software Foundation; either version 3
+ * of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
+ * MA 02110-1301, USA.
+ */
+
+package org.fdroid.fdroid.localrepo;
+
+import android.content.Context;
+
+/**
+ * Dummy version for basic app flavor.
+ */
+public class SDCardScannerService {
+    public static void scan(Context context) {
+    }
+}
diff --git a/app/src/basic/java/org/fdroid/fdroid/views/main/NearbyViewBinder.java b/app/src/basic/java/org/fdroid/fdroid/views/main/NearbyViewBinder.java
new file mode 100644
index 000000000..f3e21451a
--- /dev/null
+++ b/app/src/basic/java/org/fdroid/fdroid/views/main/NearbyViewBinder.java
@@ -0,0 +1,10 @@
+package org.fdroid.fdroid.views.main;
+
+import android.app.Activity;
+import android.content.Intent;
+
+class NearbyViewBinder {
+    static void onActivityResult(Activity activity, Intent data) {
+        throw new IllegalStateException("unimplemented");
+    }
+}
diff --git a/app/src/full/AndroidManifest.xml b/app/src/full/AndroidManifest.xml
index 0cee2ee2c..70c2bbe0f 100644
--- a/app/src/full/AndroidManifest.xml
+++ b/app/src/full/AndroidManifest.xml
@@ -38,11 +38,14 @@
     <uses-permission android:name="android.permission.BLUETOOTH"/>
     <uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
     <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
+    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
     <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
     <uses-permission android:name="android.permission.WRITE_SETTINGS"/>
     <uses-permission android:name="android.permission.NFC"/>
     <uses-permission android:name="android.permission.WAKE_LOCK"/>
 
+    <uses-permission-sdk-23 android:name="android.permission.ACCESS_COARSE_LOCATION"/>
+
     <application>
 
         <activity
@@ -77,6 +80,12 @@
         <service
                 android:name=".localrepo.CacheSwapAppsService"
                 android:exported="false"/>
+        <service
+                android:name=".localrepo.TreeUriScannerIntentService"
+                android:exported="false"/>
+        <service
+                android:name=".localrepo.SDCardScannerService"
+                android:exported="false"/>
 
         <activity
                 android:name=".views.panic.PanicPreferencesActivity"
diff --git a/app/src/full/java/org/fdroid/fdroid/localrepo/SDCardScannerService.java b/app/src/full/java/org/fdroid/fdroid/localrepo/SDCardScannerService.java
new file mode 100644
index 000000000..6a021cbb0
--- /dev/null
+++ b/app/src/full/java/org/fdroid/fdroid/localrepo/SDCardScannerService.java
@@ -0,0 +1,176 @@
+/*
+ * Copyright (C) 2018 Hans-Christoph Steiner <hans@eds.org>
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * as published by the Free Software Foundation; either version 3
+ * of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
+ * MA 02110-1301, USA.
+ */
+
+package org.fdroid.fdroid.localrepo;
+
+import android.Manifest;
+import android.app.IntentService;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Environment;
+import android.os.Process;
+import android.support.v4.content.ContextCompat;
+import android.util.Log;
+import org.fdroid.fdroid.IndexUpdater;
+import org.fdroid.fdroid.IndexV1Updater;
+import org.fdroid.fdroid.Preferences;
+import org.fdroid.fdroid.Utils;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+
+/**
+ * An {@link IntentService} subclass for scanning removable "external storage"
+ * for F-Droid package repos, e.g. SD Cards. This is intented to support
+ * sharable package repos, so it ignores non-removable storage, like the fake
+ * emulated sdcard from devices with only built-in storage.  This method will
+ * only ever allow for reading repos, never writing.  It also will not work
+ * for removeable storage devices plugged in via USB, since do not show up as
+ * "External Storage"
+ * <p>
+ * Scanning the removable storage requires that the user allowed it.  This
+ * requires both the {@link Preferences#isScanRemovableStorageEnabled()}
+ * and the {@link android.Manifest.permission#READ_EXTERNAL_STORAGE}
+ * permission to be enabled.
+ *
+ * @see TreeUriScannerIntentService TreeUri method for writing repos to be shared
+ * @see <a href="https://stackoverflow.com/a/40201333">Universal way to write to external SD card on Android</a>
+ * @see <a href="https://commonsware.com/blog/2017/11/14/storage-situation-external-storage.html"> The Storage Situation: External Storage </a>
+ */
+public class SDCardScannerService extends IntentService {
+    public static final String TAG = "SDCardScannerService";
+
+    private static final String ACTION_SCAN = "org.fdroid.fdroid.localrepo.SCAN";
+
+    private static final List<String> SKIP_DIRS = Arrays.asList(".android_secure", "LOST.DIR");
+
+    public SDCardScannerService() {
+        super("SDCardScannerService");
+    }
+
+    public static void scan(Context context) {
+        if (Preferences.get().isScanRemovableStorageEnabled()) {
+            Intent intent = new Intent(context, SDCardScannerService.class);
+            intent.setAction(ACTION_SCAN);
+            context.startService(intent);
+        }
+    }
+
+    @Override
+    protected void onHandleIntent(Intent intent) {
+        if (intent == null || !ACTION_SCAN.equals(intent.getAction())) {
+            return;
+        }
+        Process.setThreadPriority(Process.THREAD_PRIORITY_LOWEST);
+
+        HashSet<File> files = new HashSet<>();
+        if (Build.VERSION.SDK_INT < 21) {
+            if (Environment.isExternalStorageRemovable()) {
+                File sdcard = Environment.getExternalStorageDirectory();
+                String state = Environment.getExternalStorageState();
+                Collections.addAll(files, checkExternalStorage(sdcard, state));
+            }
+        } else {
+            for (File f : getExternalFilesDirs(null)) {
+                Log.i(TAG, "getExternalFilesDirs " + f);
+                if (f == null || !f.isDirectory()) {
+                    continue;
+                }
+                Log.i(TAG, "getExternalFilesDirs " + f);
+                if (Environment.isExternalStorageRemovable(f)) {
+                    String state = Environment.getExternalStorageState(f);
+                    if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
+                            == PackageManager.PERMISSION_GRANTED) {
+                        // remove Android/data/org.fdroid.fdroid/files to get root
+                        File sdcard = f.getParentFile().getParentFile().getParentFile().getParentFile();
+                        Collections.addAll(files, checkExternalStorage(sdcard, state));
+                    } else {
+                        Collections.addAll(files, checkExternalStorage(f, state));
+                    }
+                }
+            }
+        }
+
+        Log.i(TAG, "sdcard files " + files.toString());
+        ArrayList<String> filesList = new ArrayList<>();
+        for (File dir : files) {
+            if (!dir.isDirectory()) {
+                continue;
+            }
+            searchDirectory(dir);
+        }
+    }
+
+    private File[] checkExternalStorage(File sdcard, String state) {
+        File[] files = null;
+        if (sdcard != null &&
+                (Environment.MEDIA_MOUNTED_READ_ONLY.equals(state) || Environment.MEDIA_MOUNTED.equals(state))) {
+            files = sdcard.listFiles();
+        }
+
+        if (files == null) {
+            Utils.debugLog(TAG, "checkExternalStorage returned blank, F-Droid probaby doesn't have Storage perm!");
+            return new File[0];
+        } else {
+            return files;
+        }
+    }
+
+    private void searchDirectory(File dir) {
+        if (SKIP_DIRS.contains(dir.getName())) {
+            return;
+        }
+        File[] files = dir.listFiles();
+        if (files == null) {
+            return;
+        }
+        for (File file : files) {
+            if (file.isDirectory()) {
+                searchDirectory(file);
+            } else {
+                if (IndexV1Updater.SIGNED_FILE_NAME.equals(file.getName())) {
+                    registerRepo(file);
+                }
+            }
+        }
+    }
+
+    private void registerRepo(File file) {
+        InputStream inputStream = null;
+        try {
+            inputStream = new FileInputStream(file);
+            TreeUriScannerIntentService.registerRepo(this, inputStream, Uri.fromFile(file.getParentFile()));
+        } catch (IOException | IndexUpdater.SigningException e) {
+            e.printStackTrace();
+        } finally {
+            Utils.closeQuietly(inputStream);
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/app/src/full/java/org/fdroid/fdroid/localrepo/TreeUriScannerIntentService.java b/app/src/full/java/org/fdroid/fdroid/localrepo/TreeUriScannerIntentService.java
new file mode 100644
index 000000000..33eb0374c
--- /dev/null
+++ b/app/src/full/java/org/fdroid/fdroid/localrepo/TreeUriScannerIntentService.java
@@ -0,0 +1,165 @@
+/*
+ * Copyright (C) 2018 Hans-Christoph Steiner <hans@eds.org>
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * as published by the Free Software Foundation; either version 3
+ * of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
+ * MA 02110-1301, USA.
+ */
+
+package org.fdroid.fdroid.localrepo;
+
+import android.annotation.TargetApi;
+import android.app.IntentService;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Process;
+import android.support.v4.provider.DocumentFile;
+import android.util.Log;
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.IOUtils;
+import org.fdroid.fdroid.AddRepoIntentService;
+import org.fdroid.fdroid.IndexUpdater;
+import org.fdroid.fdroid.IndexV1Updater;
+import org.fdroid.fdroid.Preferences;
+import org.fdroid.fdroid.Utils;
+import org.fdroid.fdroid.data.Repo;
+import org.fdroid.fdroid.data.RepoProvider;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.security.cert.Certificate;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.jar.JarInputStream;
+
+/**
+ * An {@link IntentService} subclass for handling asynchronous scanning of a
+ * removable storage device like an SD Card or USB OTG thumb drive using the
+ * Storage Access Framework.  Permission must first be granted by the user
+ * {@link android.content.Intent#ACTION_OPEN_DOCUMENT_TREE} or
+ * {@link android.os.storage.StorageVolume#createAccessIntent(String)}request,
+ * then F-Droid will have permanent access to that{@link Uri}.
+ * <p>
+ * Even though the Storage Access Framework was introduced in
+ * {@link android.os.Build.VERSION_CODES#KITKAT android-19}, this approach is only
+ * workable if {@link android.content.Intent#ACTION_OPEN_DOCUMENT_TREE} is available.
+ * It was added in {@link android.os.Build.VERSION_CODES#LOLLIPOP android-21}.
+ *
+ * @see <a href="https://commonsware.com/blog/2017/11/15/storage-situation-removable-storage.html"> The Storage Situation: Removable Storage </a>
+ * @see <a href="https://developer.android.com/training/articles/scoped-directory-access.html">Using Scoped Directory Access</a>
+ * @see <a href="https://developer.android.com/guide/topics/providers/document-provider.html">Open Files using Storage Access Framework</a>
+ */
+@TargetApi(21)
+public class TreeUriScannerIntentService extends IntentService {
+    public static final String TAG = "TreeUriScannerIntentSer";
+
+    private static final String ACTION_SCAN_TREE_URI = "org.fdroid.fdroid.localrepo.action.SCAN_TREE_URI";
+
+    public TreeUriScannerIntentService() {
+        super("TreeUriScannerIntentService");
+    }
+
+    public static void scan(Context context, Uri data) {
+        if (Preferences.get().isScanRemovableStorageEnabled()) {
+            Intent intent = new Intent(context, TreeUriScannerIntentService.class);
+            intent.setAction(ACTION_SCAN_TREE_URI);
+            intent.setData(data);
+            context.startService(intent);
+        }
+    }
+
+    @Override
+    protected void onHandleIntent(Intent intent) {
+        if (intent == null || !ACTION_SCAN_TREE_URI.equals(intent.getAction())) {
+            return;
+        }
+        Uri treeUri = intent.getData();
+        if (treeUri == null) {
+            return;
+        }
+        Process.setThreadPriority(Process.THREAD_PRIORITY_LOWEST);
+        DocumentFile treeFile = DocumentFile.fromTreeUri(this, treeUri);
+        searchDirectory(treeFile);
+    }
+
+    private void searchDirectory(DocumentFile documentFileDir) {
+        DocumentFile[] documentFiles = documentFileDir.listFiles();
+        if (documentFiles == null) {
+            return;
+        }
+        for (DocumentFile documentFile : documentFiles) {
+            if (documentFile.isDirectory()) {
+                searchDirectory(documentFile);
+            } else {
+                if (IndexV1Updater.SIGNED_FILE_NAME.equals(documentFile.getName())) {
+                    registerRepo(documentFile);
+                }
+            }
+        }
+    }
+
+    /**
+     * For all files called {@link IndexV1Updater#SIGNED_FILE_NAME} found, check
+     * the JAR signature and read the fingerprint of the signing certificate.
+     * The fingerprint is then used to find whether this local repo is a mirror
+     * of an existing repo, or a totally new repo.  In order to verify the
+     * signatures in the JAR, the whole file needs to be read in first.
+     *
+     * @see JarInputStream#JarInputStream(InputStream, boolean)
+     */
+    private void registerRepo(DocumentFile index) {
+        InputStream inputStream = null;
+        try {
+            Log.i(TAG, "FOUND: " + index.getUri());
+            inputStream = getContentResolver().openInputStream(index.getUri());
+            Log.i(TAG, "repo URL: " + index.getParentFile().getUri());
+            registerRepo(this, inputStream, index.getParentFile().getUri());
+        } catch (IOException | IndexUpdater.SigningException e) {
+            e.printStackTrace();
+        } finally {
+            Utils.closeQuietly(inputStream);
+        }
+    }
+
+    public static void registerRepo(Context context, InputStream inputStream, Uri repoUri)
+            throws IOException, IndexUpdater.SigningException {
+        if (inputStream == null) {
+            return;
+        }
+        File destFile = File.createTempFile("dl-", IndexV1Updater.SIGNED_FILE_NAME, context.getCacheDir());
+        FileUtils.copyInputStreamToFile(inputStream, destFile);
+        JarFile jarFile = new JarFile(destFile, true);
+        JarEntry indexEntry = (JarEntry) jarFile.getEntry(IndexV1Updater.DATA_FILE_NAME);
+        IOUtils.readLines(jarFile.getInputStream(indexEntry));
+        Certificate certificate = IndexUpdater.getSigningCertFromJar(indexEntry);
+        Log.i(TAG, "Got certificate: " + certificate);
+        String fingerprint = Utils.calcFingerprint(certificate);
+        Log.i(TAG, "Got fingerprint: " + fingerprint);
+        destFile.delete();
+
+        Log.i(TAG, "Found a valid, signed index-v1.json");
+        for (Repo repo : RepoProvider.Helper.all(context)) {
+            if (fingerprint.equals(repo.fingerprint)) {
+                Log.i(TAG, repo.address + " has the SAME fingerprint: " + fingerprint);
+            } else {
+                Log.i(TAG, repo.address + " different fingerprint");
+            }
+        }
+
+        AddRepoIntentService.addRepo(context, repoUri, fingerprint);
+        // TODO rework IndexUpdater.getSigningCertFromJar to work for here
+    }
+}
diff --git a/app/src/full/java/org/fdroid/fdroid/views/main/MainViewController.java b/app/src/full/java/org/fdroid/fdroid/views/main/MainViewController.java
index 849a15a4b..447ec21f1 100644
--- a/app/src/full/java/org/fdroid/fdroid/views/main/MainViewController.java
+++ b/app/src/full/java/org/fdroid/fdroid/views/main/MainViewController.java
@@ -1,17 +1,12 @@
 package org.fdroid.fdroid.views.main;
 
-import android.content.Intent;
 import android.support.annotation.Nullable;
 import android.support.v4.app.Fragment;
 import android.support.v7.app.AppCompatActivity;
 import android.support.v7.widget.RecyclerView;
-import android.view.View;
-import android.widget.Button;
 import android.widget.FrameLayout;
-import android.widget.TextView;
 import org.fdroid.fdroid.R;
 import org.fdroid.fdroid.views.PreferencesFragment;
-import org.fdroid.fdroid.views.swap.SwapWorkflowActivity;
 import org.fdroid.fdroid.views.updates.UpdatesViewBinder;
 
 /**
@@ -65,29 +60,8 @@ class MainViewController extends RecyclerView.ViewHolder {
         new CategoriesViewBinder(activity, frame);
     }
 
-    /**
-     * A splash screen encouraging people to start the swap process.
-     * The swap process is quite heavy duty in that it fires up Bluetooth and/or WiFi in
-     * order to scan for peers. As such, it is quite convenient to have a more lightweight view to show
-     * in the main navigation that doesn't automatically start doing things when the user touches the
-     * navigation menu in the bottom navigation.
-     */
     public void bindSwapView() {
-        View swapView = activity.getLayoutInflater().inflate(R.layout.main_tab_swap, frame, true);
-
-        // To allow for whitelabel versions of F-Droid, make sure not to hardcode "F-Droid" into our
-        // translation here.
-        TextView subtext = (TextView) swapView.findViewById(R.id.text2);
-        subtext.setText(activity.getString(R.string.nearby_splash__both_parties_need_fdroid,
-                activity.getString(R.string.app_name)));
-
-        Button startButton = (Button) swapView.findViewById(R.id.button);
-        startButton.setOnClickListener(new View.OnClickListener() {
-            @Override
-            public void onClick(View v) {
-                activity.startActivity(new Intent(activity, SwapWorkflowActivity.class));
-            }
-        });
+        new NearbyViewBinder(activity, frame);
     }
 
     /**
diff --git a/app/src/full/java/org/fdroid/fdroid/views/main/NearbyViewBinder.java b/app/src/full/java/org/fdroid/fdroid/views/main/NearbyViewBinder.java
new file mode 100644
index 000000000..0df89f884
--- /dev/null
+++ b/app/src/full/java/org/fdroid/fdroid/views/main/NearbyViewBinder.java
@@ -0,0 +1,134 @@
+package org.fdroid.fdroid.views.main;
+
+import android.Manifest;
+import android.app.Activity;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.os.Build;
+import android.os.Environment;
+import android.support.annotation.RequiresApi;
+import android.support.v4.app.ActivityCompat;
+import android.support.v4.content.ContextCompat;
+import android.util.Log;
+import android.view.View;
+import android.widget.Button;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.TextView;
+import android.widget.Toast;
+import org.fdroid.fdroid.R;
+import org.fdroid.fdroid.localrepo.SDCardScannerService;
+import org.fdroid.fdroid.localrepo.TreeUriScannerIntentService;
+import org.fdroid.fdroid.views.swap.SwapWorkflowActivity;
+
+import java.io.File;
+
+/**
+ * A splash screen encouraging people to start the swap process. The swap
+ * process is quite heavy duty in that it fires up Bluetooth and/or WiFi
+ * in  order to scan for peers. As such, it is quite convenient to have a
+ * more lightweight view to show in the main navigation that doesn't
+ * automatically start doing things when the user touches the navigation
+ * menu in the bottom navigation.
+ * <p>
+ * Lots of pieces of the nearby/swap functionality require that the user grant
+ * F-Droid permissions at runtime on {@code android-23} and higher. On devices
+ * that have a removable SD Card that is currently mounted, this will request
+ * permission to read it, so that F-Droid can look for repos on the SD Card.
+ * <p>
+ * Once {@link Manifest.permission#READ_EXTERNAL_STORAGE} or
+ * {@link Manifest.permission#WRITE_EXTERNAL_STORAGE} is granted for F-Droid,
+ * then it can read any file on an SD Card and no more prompts are needed. For
+ * USB OTG drives, the only way to get read permissions is to prompt the user
+ * via {@link Intent#ACTION_OPEN_DOCUMENT_TREE}.
+ * <p>
+ * For write permissions, {@code android-19} and {@code android-20} devices are
+ * basically screwed here.  {@link Intent#ACTION_OPEN_DOCUMENT_TREE} was added
+ * in {@code android-21}, and there does not seem to be any other way to get
+ * write access to the the removable storage.
+ *
+ * @see TreeUriScannerIntentService
+ * @see org.fdroid.fdroid.localrepo.SDCardScannerService
+ */
+class NearbyViewBinder {
+    public static final String TAG = "NearbyViewBinder";
+
+    static File externalStorage = null;
+
+    NearbyViewBinder(final Activity activity, FrameLayout parent) {
+        View swapView = activity.getLayoutInflater().inflate(R.layout.main_tab_swap, parent, true);
+
+        TextView subtext = swapView.findViewById(R.id.text2);
+        subtext.setText(activity.getString(R.string.nearby_splash__both_parties_need_fdroid,
+                activity.getString(R.string.app_name)));
+
+        ImageView nearbySplash = swapView.findViewById(R.id.image);
+
+        Button startButton = swapView.findViewById(R.id.button);
+        startButton.setOnClickListener(new View.OnClickListener() {
+            @Override
+            public void onClick(View v) {
+                final String coarseLocation = Manifest.permission.ACCESS_COARSE_LOCATION;
+                if (Build.VERSION.SDK_INT >= 23
+                        && PackageManager.PERMISSION_GRANTED
+                        != ContextCompat.checkSelfPermission(activity, coarseLocation)) {
+                    ActivityCompat.requestPermissions(activity, new String[]{coarseLocation},
+                            MainActivity.REQUEST_LOCATION_PERMISSIONS);
+                } else {
+                    activity.startActivity(new Intent(activity, SwapWorkflowActivity.class));
+                }
+            }
+        });
+
+        if (Build.VERSION.SDK_INT >= 21) {
+            Log.i(TAG, "Environment.isExternalStorageRemovable(activity.getExternalFilesDir(\"\")) " +
+                    Environment.isExternalStorageRemovable(activity.getExternalFilesDir("")));
+            File[] dirs = activity.getExternalFilesDirs("");
+            if (dirs != null) {
+                for (File f : dirs) {
+                    if (f != null && Environment.isExternalStorageRemovable(f)) {
+                        // remove Android/data/org.fdroid.fdroid/files to get root
+                        externalStorage = f.getParentFile().getParentFile().getParentFile().getParentFile();
+                        break;
+                    }
+                }
+            }
+        } else if (Environment.isExternalStorageRemovable() &&
+                (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED_READ_ONLY)
+                        || Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED))) {
+            Log.i(TAG, "<21 isExternalStorageRemovable MEDIA_MOUNTED");
+            externalStorage = Environment.getExternalStorageDirectory();
+        }
+
+        if (externalStorage != null) {
+            nearbySplash.setVisibility(View.GONE);
+            View readExternalStorage = swapView.findViewById(R.id.readExternalStorage);
+            readExternalStorage.setVisibility(View.VISIBLE);
+            Button requestReadExternalStorage = swapView.findViewById(R.id.requestReadExternalStorage);
+            requestReadExternalStorage.setOnClickListener(new View.OnClickListener() {
+                @RequiresApi(api = 21)
+                @Override
+                public void onClick(View v) {
+                    File storage = externalStorage.getParentFile();
+                    File[] files = storage.listFiles();
+                    String msg = "";
+                    if (files != null) for (File f : files) {
+                        msg += "|" + f.getName();
+                    }
+                    Toast.makeText(activity, msg, Toast.LENGTH_LONG).show();
+                    final String writeExternalStorage = Manifest.permission.WRITE_EXTERNAL_STORAGE;
+                    if (Build.VERSION.SDK_INT >= 23
+                            && !externalStorage.canRead()
+                            && PackageManager.PERMISSION_GRANTED
+                            != ContextCompat.checkSelfPermission(activity, writeExternalStorage)) {
+                        ActivityCompat.requestPermissions(activity, new String[]{writeExternalStorage},
+                                MainActivity.REQUEST_STORAGE_PERMISSIONS);
+                    } else {
+                        SDCardScannerService.scan(activity);
+                    }
+                }
+            });
+
+        }
+    }
+}
diff --git a/app/src/full/res/layout/main_tab_swap.xml b/app/src/full/res/layout/main_tab_swap.xml
index 020fb9140..a187febb2 100644
--- a/app/src/full/res/layout/main_tab_swap.xml
+++ b/app/src/full/res/layout/main_tab_swap.xml
@@ -54,6 +54,37 @@
         android:layout_marginStart="48dp"
         android:layout_marginLeft="48dp" />
 
+    <LinearLayout
+            android:id="@+id/readExternalStorage"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:visibility="gone"
+            android:orientation="horizontal"
+            app:layout_constraintTop_toBottomOf="@+id/text2"
+            app:layout_constraintRight_toRightOf="parent"
+            app:layout_constraintLeft_toLeftOf="parent"
+            android:layout_marginTop="48dp"
+            android:layout_marginEnd="24dp"
+            android:layout_marginRight="24dp"
+            android:layout_marginStart="24dp"
+            android:layout_marginLeft="24dp">
+        <TextView
+                android:layout_width="wrap_content"
+                android:layout_height="match_parent"
+                android:text="@string/nearby_splash__read_external_storage"
+                android:gravity="fill"
+                android:paddingRight="5dp"
+                android:paddingEnd="5dp"
+                android:textSize="17sp"
+                android:textColor="?attr/lightGrayTextColor"/>
+        <Button
+                android:id="@+id/requestReadExternalStorage"
+                android:layout_width="80dp"
+                android:layout_height="match_parent"
+                android:text="@string/nearby_splash__request_permission"
+                style="@style/SwapTheme.Wizard.OptionButton"/>
+    </LinearLayout>
+
     <ImageView
         android:id="@+id/image"
         android:layout_width="0dp"
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d11fd12df..c255a38dd 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -271,6 +271,9 @@
                 android:name=".data.InstalledAppProviderService"
                 android:permission="android.permission.BIND_JOB_SERVICE"
                 android:exported="false"/>
+        <service
+                android:name=".AddRepoIntentService"
+                android:exported="false"/>
 
 
         <!-- Warning: Please add all new services to HidingManager -->
diff --git a/app/src/main/java/org/fdroid/fdroid/AddRepoIntentService.java b/app/src/main/java/org/fdroid/fdroid/AddRepoIntentService.java
new file mode 100644
index 000000000..9530429b3
--- /dev/null
+++ b/app/src/main/java/org/fdroid/fdroid/AddRepoIntentService.java
@@ -0,0 +1,147 @@
+package org.fdroid.fdroid;
+
+import android.app.IntentService;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.text.TextUtils;
+import android.util.Log;
+import org.fdroid.fdroid.data.Repo;
+import org.fdroid.fdroid.data.RepoProvider;
+import org.fdroid.fdroid.views.ManageReposActivity;
+import org.fdroid.fdroid.views.main.MainActivity;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.Locale;
+
+/**
+ * Handles requests to add new repos via URLs.  This is an {@code IntentService}
+ * so that requests are queued, which is necessary when either
+ * {@link org.fdroid.fdroid.localrepo.TreeUriScannerIntentService} or
+ * {@link org.fdroid.fdroid.localrepo.SDCardScannerService} finds multiple
+ * repos on a disk.  This should hopefully also serve as the beginnings of
+ * a new architecture for handling these requests.  This does all the
+ * processing first, up front, then only launches UI as needed.
+ * {@link org.fdroid.fdroid.views.ManageReposActivity} currently does the
+ * opposite.
+ * <p>
+ * This only really properly queues {@link Intent}s that get filtered out. The
+ * {@code Intent}s that go on to {@code ManageReposActivity} will not wait
+ * until for that {@code Activity} to be ready to handle the next.  So when
+ * multiple mirrors are discovered at once, only one in that session will
+ * likely be added.
+ */
+public class AddRepoIntentService extends IntentService {
+    public static final String TAG = "AddRepoIntentService";
+
+    private static final String ACTION_ADD_REPO = "org.fdroid.fdroid.action.ADD_REPO";
+
+    public AddRepoIntentService() {
+        super("AddRepoIntentService");
+    }
+
+    public static void addRepo(Context context, @NonNull Uri repoUri, @Nullable String fingerprint) {
+        Intent intent = new Intent(context, AddRepoIntentService.class);
+        intent.setAction(ACTION_ADD_REPO);
+        if (TextUtils.isEmpty(fingerprint)) {
+            intent.setData(repoUri);
+        } else {
+            intent.setData(repoUri.buildUpon()
+                    .appendQueryParameter("fingerprint", fingerprint)
+                    .build());
+        }
+        context.startService(intent);
+    }
+
+    @Override
+    protected void onHandleIntent(Intent intent) {
+        android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_LOWEST);
+        if (intent == null || intent.getData() == null) {
+            return;
+        }
+
+        Uri uri = intent.getData();
+        String urlString;
+        try {
+            urlString = normalizeUrl(uri);
+        } catch (URISyntaxException e) {
+            Log.i(TAG, e.getLocalizedMessage());
+            return;
+        }
+
+        String fingerprint = uri.getQueryParameter("fingerprint");
+        for (Repo repo : RepoProvider.Helper.all(this)) {
+            if (repo.inuse && TextUtils.equals(fingerprint, repo.fingerprint)) {
+                if (TextUtils.equals(urlString, repo.address)) {
+                    Utils.debugLog(TAG, urlString + " already added as a repo");
+                    return;
+                } else {
+                    for (String mirrorUrl : repo.getMirrorList()) {
+                        if (urlString.startsWith(mirrorUrl)) {
+                            Utils.debugLog(TAG, urlString + " already added as a mirror");
+                            return;
+                        }
+                    }
+                }
+            }
+        }
+        intent.putExtra(ManageReposActivity.EXTRA_FINISH_AFTER_ADDING_REPO, false);
+        intent.setComponent(new ComponentName(this, MainActivity.class));
+        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+        startActivity(intent);
+    }
+
+    /**
+     * Some basic sanitization of URLs, so that two URLs which have the same semantic meaning
+     * are represented by the exact same string by F-Droid. This will help to make sure that,
+     * e.g. "http://10.0.1.50" and "http://10.0.1.50/" are not two different repositories.
+     * <p>
+     * Currently it normalizes the path so that "/./" are removed and "test/../" is collapsed.
+     * This is done using {@link URI#normalize()}. It also removes multiple consecutive forward
+     * slashes in the path and replaces them with one. Finally, it removes trailing slashes.
+     * <p>
+     * {@code content://} URLs used for repos stored on removable storage get messed up by
+     * {@link URI}.
+     */
+    public static String normalizeUrl(String urlString) throws URISyntaxException {
+        if (TextUtils.isEmpty(urlString)) {
+            return null;
+        }
+        return normalizeUrl(Uri.parse(urlString));
+    }
+
+    public static String normalizeUrl(Uri uri) throws URISyntaxException {
+        if (!uri.isAbsolute()) {
+            throw new URISyntaxException(uri.toString(), "Must provide an absolute URI for repositories");
+        }
+        if (!uri.isHierarchical()) {
+            throw new URISyntaxException(uri.toString(), "Must provide an hierarchical URI for repositories");
+        }
+        if ("content".equals(uri.getScheme())) {
+            return uri.toString();
+        }
+        String path = uri.getPath();
+        if (path != null) {
+            path = path.replaceAll("//*/", "/"); // Collapse multiple forward slashes into 1.
+            if (path.length() > 0 && path.charAt(path.length() - 1) == '/') {
+                path = path.substring(0, path.length() - 1);
+            }
+        }
+        String scheme = uri.getScheme();
+        String host = uri.getHost();
+        if (TextUtils.isEmpty(scheme) || TextUtils.isEmpty(host)) {
+            return uri.toString();
+        }
+        return new URI(scheme.toLowerCase(Locale.ENGLISH),
+                uri.getUserInfo(),
+                host.toLowerCase(Locale.ENGLISH),
+                uri.getPort(),
+                path,
+                uri.getQuery(),
+                uri.getFragment()).normalize().toString();
+    }
+}
diff --git a/app/src/main/java/org/fdroid/fdroid/FDroidApp.java b/app/src/main/java/org/fdroid/fdroid/FDroidApp.java
index 3ea9f5990..9c8567ffa 100644
--- a/app/src/main/java/org/fdroid/fdroid/FDroidApp.java
+++ b/app/src/main/java/org/fdroid/fdroid/FDroidApp.java
@@ -69,6 +69,7 @@ import org.fdroid.fdroid.data.Repo;
 import org.fdroid.fdroid.data.RepoProvider;
 import org.fdroid.fdroid.installer.ApkFileProvider;
 import org.fdroid.fdroid.installer.InstallHistoryService;
+import org.fdroid.fdroid.localrepo.SDCardScannerService;
 import org.fdroid.fdroid.net.ConnectivityMonitorService;
 import org.fdroid.fdroid.net.HttpDownloader;
 import org.fdroid.fdroid.net.ImageLoaderForUIL;
@@ -502,6 +503,8 @@ public class FDroidApp extends Application {
         } else {
             atStartTime.edit().remove(queryStringKey).apply();
         }
+
+        SDCardScannerService.scan(this);
     }
 
     /**
diff --git a/app/src/main/java/org/fdroid/fdroid/IndexUpdater.java b/app/src/main/java/org/fdroid/fdroid/IndexUpdater.java
index cf4260353..b67b3e908 100644
--- a/app/src/main/java/org/fdroid/fdroid/IndexUpdater.java
+++ b/app/src/main/java/org/fdroid/fdroid/IndexUpdater.java
@@ -43,6 +43,7 @@ import org.fdroid.fdroid.installer.InstallManagerService;
 import org.fdroid.fdroid.installer.InstallerService;
 import org.fdroid.fdroid.net.Downloader;
 import org.fdroid.fdroid.net.DownloaderFactory;
+import org.fdroid.fdroid.net.TreeUriDownloader;
 import org.xml.sax.InputSource;
 import org.xml.sax.SAXException;
 import org.xml.sax.XMLReader;
@@ -114,7 +115,11 @@ public class IndexUpdater {
     }
 
     protected String getIndexUrl(@NonNull Repo repo) {
-        return repo.address + "/index.jar";
+        if (repo.address.startsWith("content://")) {
+            return repo.address + TreeUriDownloader.ESCAPED_SLASH + SIGNED_FILE_NAME;
+        } else {
+            return repo.address + "/" + SIGNED_FILE_NAME;
+        }
     }
 
     public boolean hasChanged() {
diff --git a/app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java b/app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java
index 08f16430b..1ada06ab3 100644
--- a/app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java
+++ b/app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java
@@ -62,8 +62,10 @@ import java.net.SocketTimeoutException;
 import java.net.UnknownHostException;
 import java.security.cert.X509Certificate;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.Date;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.jar.JarEntry;
@@ -87,7 +89,7 @@ import java.util.jar.JarFile;
 public class IndexV1Updater extends IndexUpdater {
     public static final String TAG = "IndexV1Updater";
 
-    private static final String SIGNED_FILE_NAME = "index-v1.jar";
+    public static final String SIGNED_FILE_NAME = "index-v1.jar";
     public static final String DATA_FILE_NAME = "index-v1.json";
 
     public IndexV1Updater(@NonNull Context context, @NonNull Repo repo) {
@@ -95,8 +97,15 @@ public class IndexV1Updater extends IndexUpdater {
     }
 
     @Override
+    /**
+     * Storage Access Framework URLs have a crazy encoded path within the URL path.
+     */
     protected String getIndexUrl(@NonNull Repo repo) {
-        return Uri.parse(repo.address).buildUpon().appendPath(SIGNED_FILE_NAME).build().toString();
+        if (repo.address.startsWith("content://")) {
+            return repo.address + "%2F" + SIGNED_FILE_NAME;
+        } else {
+            return Uri.parse(repo.address).buildUpon().appendPath(SIGNED_FILE_NAME).build().toString();
+        }
     }
 
     /**
@@ -284,7 +293,14 @@ public class IndexV1Updater extends IndexUpdater {
         repo.name = getStringRepoValue(repoMap, "name");
         repo.icon = getStringRepoValue(repoMap, "icon");
         repo.description = getStringRepoValue(repoMap, "description");
-        repo.mirrors = getStringArrayRepoValue(repoMap, "mirrors");
+
+        // ensure the canonical URL is included in the "mirrors" list
+        List<String> mirrorsList = getStringListRepoValue(repoMap, "mirrors");
+        HashSet<String> mirrors = new HashSet<>(mirrorsList.size() + 1);
+        mirrors.addAll(mirrorsList);
+        mirrors.add(repo.address);
+        repo.mirrors = mirrors.toArray(new String[mirrors.size()]);
+
         // below are optional, can be default value
         repo.maxage = getIntRepoValue(repoMap, "maxage");
         repo.version = getIntRepoValue(repoMap, "version");
@@ -372,13 +388,12 @@ public class IndexV1Updater extends IndexUpdater {
     }
 
     @SuppressWarnings("unchecked")
-    private String[] getStringArrayRepoValue(Map<String, Object> repoMap, String key) {
+    private List<String> getStringListRepoValue(Map<String, Object> repoMap, String key) {
         Object value = repoMap.get(key);
         if (value != null && value instanceof ArrayList) {
-            ArrayList<String> list = (ArrayList<String>) value;
-            return list.toArray(new String[list.size()]);
+            return (List<String>) value;
         }
-        return null;
+        return Collections.emptyList();
     }
 
     private HashMap<String, Object> parseRepo(ObjectMapper mapper, JsonParser parser) throws IOException {
diff --git a/app/src/main/java/org/fdroid/fdroid/Preferences.java b/app/src/main/java/org/fdroid/fdroid/Preferences.java
index 9a3fc4b09..9dc93a1ab 100644
--- a/app/src/main/java/org/fdroid/fdroid/Preferences.java
+++ b/app/src/main/java/org/fdroid/fdroid/Preferences.java
@@ -92,6 +92,7 @@ public final class Preferences implements SharedPreferences.OnSharedPreferenceCh
     public static final String PREF_PRIVILEGED_INSTALLER = "privilegedInstaller";
     public static final String PREF_LOCAL_REPO_NAME = "localRepoName";
     public static final String PREF_LOCAL_REPO_HTTPS = "localRepoHttps";
+    public static final String PREF_SCAN_REMOVABLE_STORAGE = "scanRemovableStorage";
     public static final String PREF_LANGUAGE = "language";
     public static final String PREF_USE_TOR = "useTor";
     public static final String PREF_ENABLE_PROXY = "enableProxy";
@@ -400,6 +401,10 @@ public final class Preferences implements SharedPreferences.OnSharedPreferenceCh
         return preferences.getString(PREF_LOCAL_REPO_NAME, getDefaultLocalRepoName());
     }
 
+    public boolean isScanRemovableStorageEnabled() {
+        return preferences.getBoolean(PREF_SCAN_REMOVABLE_STORAGE, true);
+    }
+
     public boolean isUpdateNotificationEnabled() {
         return preferences.getBoolean(PREF_UPDATE_NOTIFICATION_ENABLED, true);
     }
diff --git a/app/src/main/java/org/fdroid/fdroid/UpdateService.java b/app/src/main/java/org/fdroid/fdroid/UpdateService.java
index dc16ae13c..82a74fade 100644
--- a/app/src/main/java/org/fdroid/fdroid/UpdateService.java
+++ b/app/src/main/java/org/fdroid/fdroid/UpdateService.java
@@ -26,6 +26,7 @@ import android.app.job.JobInfo;
 import android.app.job.JobScheduler;
 import android.content.BroadcastReceiver;
 import android.content.ComponentName;
+import android.content.ContentResolver;
 import android.content.Context;
 import android.content.Intent;
 import android.content.IntentFilter;
@@ -406,6 +407,13 @@ public class UpdateService extends JobIntentService {
         });
     }
 
+    private static boolean isLocalRepoAddress(String address) {
+        return address != null &&
+                (address.startsWith(BluetoothDownloader.SCHEME)
+                        || address.startsWith(ContentResolver.SCHEME_CONTENT)
+                        || address.startsWith(ContentResolver.SCHEME_FILE));
+    }
+
     @Override
     protected void onHandleWork(@NonNull Intent intent) {
         Process.setThreadPriority(Process.THREAD_PRIORITY_LOWEST);
@@ -417,16 +425,38 @@ public class UpdateService extends JobIntentService {
 
         try {
             final Preferences fdroidPrefs = Preferences.get();
+
+            // Grab some preliminary information, then we can release the
+            // database while we do all the downloading, etc...
+            List<Repo> repos = RepoProvider.Helper.all(this);
+
             // See if it's time to actually do anything yet...
             int netState = ConnectivityMonitorService.getNetworkState(this);
-            if (address != null && address.startsWith(BluetoothDownloader.SCHEME)) {
-                Utils.debugLog(TAG, "skipping internet check, this is bluetooth");
+            if (isLocalRepoAddress(address)) {
+                Utils.debugLog(TAG, "skipping internet check, this is local: " + address);
             } else if (netState == ConnectivityMonitorService.FLAG_NET_UNAVAILABLE) {
-                Utils.debugLog(TAG, "No internet, cannot update");
-                if (manualUpdate) {
-                    sendNoInternetToast();
+                boolean foundLocalRepo = false;
+                for (Repo repo : repos) {
+                    if (isLocalRepoAddress(repo.address)) {
+                        foundLocalRepo = true;
+                    } else {
+                        for (String mirrorAddress : repo.getMirrorList()) {
+                            if (isLocalRepoAddress(mirrorAddress)) {
+                                foundLocalRepo = true;
+                                //localRepos.add(repo);
+                                //FDroidApp.setLastWorkingMirror(repo.getId(), mirrorAddress);
+                                break;
+                            }
+                        }
+                    }
+                }
+                if (!foundLocalRepo) {
+                    Utils.debugLog(TAG, "No internet, cannot update");
+                    if (manualUpdate) {
+                        sendNoInternetToast();
+                    }
+                    return;
                 }
-                return;
             } else if ((manualUpdate || forcedUpdate) && fdroidPrefs.isOnDemandDownloadAllowed()) {
                 Utils.debugLog(TAG, "manually requested or forced update");
                 if (forcedUpdate) {
@@ -442,10 +472,6 @@ public class UpdateService extends JobIntentService {
             LocalBroadcastManager.getInstance(this).registerReceiver(updateStatusReceiver,
                     new IntentFilter(LOCAL_ACTION_STATUS));
 
-            // Grab some preliminary information, then we can release the
-            // database while we do all the downloading, etc...
-            List<Repo> repos = RepoProvider.Helper.all(this);
-
             int unchangedRepos = 0;
             int updatedRepos = 0;
             int errorRepos = 0;
@@ -482,7 +508,8 @@ public class UpdateService extends JobIntentService {
                 } catch (IndexUpdater.UpdateException e) {
                     errorRepos++;
                     repoErrors.add(e.getMessage());
-                    Log.e(TAG, "Error updating repository " + repo.address, e);
+                    Log.e(TAG, "Error updating repository " + repo.address);
+                    e.printStackTrace();
                 }
 
                 // now that downloading the index is done, start downloading updates
diff --git a/app/src/main/java/org/fdroid/fdroid/data/DBHelper.java b/app/src/main/java/org/fdroid/fdroid/data/DBHelper.java
index 9eaad2d44..e56a0d139 100644
--- a/app/src/main/java/org/fdroid/fdroid/data/DBHelper.java
+++ b/app/src/main/java/org/fdroid/fdroid/data/DBHelper.java
@@ -1394,6 +1394,12 @@ public class DBHelper extends SQLiteOpenHelper {
         return exists;
     }
 
+    /**
+     * Insert a new repo into the database.  This also initializes the list of
+     * "mirror" URLs. There should always be at least one URL there, since the
+     * logic in {@link org.fdroid.fdroid.FDroidApp#getMirror(String, Repo)}
+     * expects at least one entry in the mirrors list.
+     */
     private void insertRepo(SQLiteDatabase db, String name, String address,
                             String description, String version, String enabled,
                             String priority, String pushRequests, String pubKey) {
@@ -1410,6 +1416,9 @@ public class DBHelper extends SQLiteOpenHelper {
         values.put(RepoTable.Cols.LAST_ETAG, (String) null);
         values.put(RepoTable.Cols.TIMESTAMP, 0);
 
+        String[] initializeMirrors = {address};
+        values.put(Schema.RepoTable.Cols.MIRRORS, Utils.serializeCommaSeparatedString(initializeMirrors));
+
         switch (pushRequests) {
             case "ignore":
                 values.put(RepoTable.Cols.PUSH_REQUESTS, Repo.PUSH_REQUEST_IGNORE);
diff --git a/app/src/main/java/org/fdroid/fdroid/data/NewRepoConfig.java b/app/src/main/java/org/fdroid/fdroid/data/NewRepoConfig.java
index 8bf572b2c..c4411321b 100644
--- a/app/src/main/java/org/fdroid/fdroid/data/NewRepoConfig.java
+++ b/app/src/main/java/org/fdroid/fdroid/data/NewRepoConfig.java
@@ -4,6 +4,7 @@ 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.localrepo.peers.WifiPeer;
@@ -53,8 +54,9 @@ public class NewRepoConfig {
         String scheme = uri.getScheme();
         host = uri.getHost();
         port = uri.getPort();
-        if (TextUtils.isEmpty(scheme) || TextUtils.isEmpty(host)) {
+        if (TextUtils.isEmpty(scheme) || (TextUtils.isEmpty(host) && !"file".equals(scheme))) {
             errorMessage = String.format(context.getString(R.string.malformed_repo_uri), uri);
+            Log.i(TAG, errorMessage);
             isValidRepo = false;
             return;
         }
@@ -82,7 +84,7 @@ public class NewRepoConfig {
         host = host.toLowerCase(Locale.ENGLISH);
 
         if (uri.getPath() == null
-                || !Arrays.asList("https", "http", "fdroidrepos", "fdroidrepo").contains(scheme)) {
+                || !Arrays.asList("https", "http", "fdroidrepos", "fdroidrepo", "content", "file").contains(scheme)) {
             isValidRepo = false;
             return;
         }
diff --git a/app/src/main/java/org/fdroid/fdroid/data/Repo.java b/app/src/main/java/org/fdroid/fdroid/data/Repo.java
index b3b35f4b7..1a9929789 100644
--- a/app/src/main/java/org/fdroid/fdroid/data/Repo.java
+++ b/app/src/main/java/org/fdroid/fdroid/data/Repo.java
@@ -37,6 +37,7 @@ import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.Date;
+import java.util.HashSet;
 import java.util.List;
 
 
@@ -338,20 +339,29 @@ public class Repo extends ValueObject {
         }
     }
 
+    /**
+     * The main repo URL is included in the mirror list, so it only makes
+     * sense to activate this logic if there are more than one entry in the
+     * mirror list.
+     */
     public boolean hasMirrors() {
         return (mirrors != null && mirrors.length > 1)
                 || (userMirrors != null && userMirrors.length > 0);
     }
 
+    /**
+     * @return {@link List} of valid URLs to reach this repo, including the canonical URL
+     */
     public List<String> getMirrorList() {
-        final ArrayList<String> allMirrors = new ArrayList<String>();
+        final HashSet<String> allMirrors = new HashSet<>();
         if (userMirrors != null) {
             allMirrors.addAll(Arrays.asList(userMirrors));
         }
         if (mirrors != null) {
             allMirrors.addAll(Arrays.asList(mirrors));
         }
-        return allMirrors;
+        allMirrors.add(address);
+        return new ArrayList<>(allMirrors);
     }
 
     /**
@@ -360,19 +370,26 @@ public class Repo extends ValueObject {
     public int getMirrorCount() {
         int count = 0;
         for (String m : getMirrorList()) {
-            if (!m.equals(address)) {
-                if (FDroidApp.isUsingTor()) {
-                    count++;
-                } else {
-                    if (!m.contains(".onion")) {
-                        count++;
-                    }
-                }
+            if (FDroidApp.isUsingTor()) {
+                count++;
+            } else if (!m.contains(".onion")) {
+                count++;
             }
         }
         return count;
     }
 
+    /**
+     * The mirror logic assumes that it has a mirrors list with at least once
+     * valid entry in it.  In the index format as defined by {@code fdroid update},
+     * there is always at least one valid URL: the canonical URL.  That also means
+     * if there is only one item in the mirrors list, there are no other URLs to try.
+     * <p>
+     * The initial state of the repos in the database also include the canonical
+     * URL in the mirrors list so the mirror logic works on the first index
+     * update.  That makes it possible to do the first index update via SD Card
+     * or USB OTG drive.
+     */
     public String getMirror(String lastWorkingMirror) {
         if (TextUtils.isEmpty(lastWorkingMirror)) {
             lastWorkingMirror = address;
@@ -382,7 +399,7 @@ public class Repo extends ValueObject {
         if (shuffledMirrors.size() > 1) {
             for (String m : shuffledMirrors) {
                 // Return a non default, and not last used mirror
-                if (!m.equals(address) && !m.equals(lastWorkingMirror)) {
+                if (!m.equals(lastWorkingMirror)) {
                     if (FDroidApp.isUsingTor()) {
                         return m;
                     } else {
diff --git a/app/src/main/java/org/fdroid/fdroid/net/DownloaderFactory.java b/app/src/main/java/org/fdroid/fdroid/net/DownloaderFactory.java
index 4704ae279..2a13e947a 100644
--- a/app/src/main/java/org/fdroid/fdroid/net/DownloaderFactory.java
+++ b/app/src/main/java/org/fdroid/fdroid/net/DownloaderFactory.java
@@ -31,6 +31,10 @@ public class DownloaderFactory {
         String scheme = uri.getScheme();
         if ("bluetooth".equals(scheme)) {
             downloader = new BluetoothDownloader(uri, destFile);
+        } else if ("content".equals(scheme)) {
+            downloader = new TreeUriDownloader(uri, destFile);
+        } else if ("file".equals(scheme)) {
+            downloader = new LocalFileDownloader(uri, destFile);
         } else {
             final String[] projection = {Schema.RepoTable.Cols.USERNAME, Schema.RepoTable.Cols.PASSWORD};
             Repo repo = RepoProvider.Helper.findByUrl(context, uri, projection);
diff --git a/app/src/main/java/org/fdroid/fdroid/net/ImageLoaderForUIL.java b/app/src/main/java/org/fdroid/fdroid/net/ImageLoaderForUIL.java
index 883c2d9d2..1bd8d60a6 100644
--- a/app/src/main/java/org/fdroid/fdroid/net/ImageLoaderForUIL.java
+++ b/app/src/main/java/org/fdroid/fdroid/net/ImageLoaderForUIL.java
@@ -1,6 +1,7 @@
 package org.fdroid.fdroid.net;
 
 import android.content.Context;
+import android.os.Build;
 import com.nostra13.universalimageloader.core.download.BaseImageDownloader;
 
 import java.io.IOException;
@@ -28,6 +29,10 @@ public class ImageLoaderForUIL implements com.nostra13.universalimageloader.core
             case HTTP:
             case HTTPS:
                 return DownloaderFactory.create(context, imageUri).getInputStream();
+            case CONTENT:
+                if (Build.VERSION.SDK_INT >= 19) {
+                    return DownloaderFactory.create(context, imageUri).getInputStream();
+                }
         }
         return new BaseImageDownloader(context).getStream(imageUri, extra);
     }
diff --git a/app/src/main/java/org/fdroid/fdroid/net/LocalFileDownloader.java b/app/src/main/java/org/fdroid/fdroid/net/LocalFileDownloader.java
new file mode 100644
index 000000000..9701eae39
--- /dev/null
+++ b/app/src/main/java/org/fdroid/fdroid/net/LocalFileDownloader.java
@@ -0,0 +1,86 @@
+package org.fdroid.fdroid.net;
+
+import android.net.Uri;
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.IOUtils;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.ConnectException;
+import java.net.ProtocolException;
+
+/**
+ * "Downloads" files from {@code file:///} {@link Uri}s.  Even though it is
+ * obviously unnecessary to download a file that is locally available, this
+ * class is here so that the whole security-sensitive installation process is
+ * the same, no matter where the files are downloaded from.  Also, for things
+ * like icons and graphics, it makes sense to have them copied to the cache so
+ * that they are available even after removable storage is no longer present.
+ */
+public class LocalFileDownloader extends Downloader {
+
+    private InputStream inputStream;
+    private final File sourceFile;
+
+    LocalFileDownloader(Uri uri, File destFile) {
+        super(uri, destFile);
+        sourceFile = new File(uri.getPath());
+    }
+
+    /**
+     * This needs to convert {@link FileNotFoundException}
+     * and {@link SecurityException} to {@link ProtocolException} since the
+     * mirror failover logic expects network errors, not filesystem or other
+     * errors.  In the downloading logic, filesystem errors are related to the
+     * file as it is being downloaded and written to disk.  Things can fail
+     * here if the SDCard is not longer mounted, the files were deleted by
+     * some other process, etc.
+     */
+    @Override
+    protected InputStream getDownloadersInputStream() throws IOException {
+        try {
+            inputStream = new FileInputStream(sourceFile);
+            return inputStream;
+        } catch (FileNotFoundException | SecurityException e) {
+            throw new ProtocolException(e.getLocalizedMessage());
+        }
+    }
+
+    @Override
+    protected void close() {
+        IOUtils.closeQuietly(inputStream);
+    }
+
+    @Override
+    public boolean hasChanged() {
+        return true;
+    }
+
+    @Override
+    protected long totalDownloadSize() {
+        return sourceFile.length();
+    }
+
+    @Override
+    public void download() throws ConnectException, IOException, InterruptedException {
+        if (!sourceFile.exists()) {
+            notFound = true;
+            throw new ConnectException(sourceFile + " does not exist, try a mirror");
+        }
+
+        boolean resumable = false;
+        long contentLength = sourceFile.length();
+        long fileLength = outputFile.length();
+        if (fileLength > contentLength) {
+            FileUtils.deleteQuietly(outputFile);
+        } else if (fileLength == contentLength && outputFile.isFile()) {
+            return; // already have it!
+        } else if (fileLength > 0) {
+            resumable = true;
+        }
+        downloadFromStream(8192, resumable);
+    }
+}
diff --git a/app/src/main/java/org/fdroid/fdroid/net/TreeUriDownloader.java b/app/src/main/java/org/fdroid/fdroid/net/TreeUriDownloader.java
new file mode 100644
index 000000000..93ab66f6c
--- /dev/null
+++ b/app/src/main/java/org/fdroid/fdroid/net/TreeUriDownloader.java
@@ -0,0 +1,105 @@
+package org.fdroid.fdroid.net;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.net.Uri;
+import android.support.v4.provider.DocumentFile;
+import org.fdroid.fdroid.FDroidApp;
+
+import java.io.BufferedInputStream;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.ProtocolException;
+
+/**
+ * An {@link Downloader} subclass for downloading files from a repo on a
+ * removable storage device like an SD Card or USB OTG thumb drive using the
+ * Storage Access Framework.  Permission must first be granted by the user via a
+ * {@link android.content.Intent#ACTION_OPEN_DOCUMENT_TREE} or
+ * {@link android.os.storage.StorageVolume#createAccessIntent(String)}request,
+ * then F-Droid will have permanent access to that{@link android.net.Uri}.
+ * <p>
+ * The base repo URL of such a repo looks like:
+ * {@code content://com.android.externalstorage.documents/tree/1AFB-2402%3A/document/1AFB-2402%3Atesty.at.or.at%2Ffdroid%2Frepo}
+ *
+ * @see android.support.v4.provider.DocumentFile#fromTreeUri(Context, Uri)
+ * @see <a href="https://developer.android.com/guide/topics/providers/document-provider.html">Open Files using Storage Access Framework</a>
+ * @see <a href="https://developer.android.com/training/articles/scoped-directory-access.html">Using Scoped Directory Access</a>
+ */
+@TargetApi(21)
+public class TreeUriDownloader extends Downloader {
+    public static final String TAG = "TreeUriDownloader";
+
+    /**
+     * Whoever designed this {@link android.provider.DocumentsContract#isTreeUri(Uri) URI system}
+     * was smoking crack, it escapes <b>part</b> of the URI path, but not all.
+     * So crazy tricks are required.
+     */
+    public static final String ESCAPED_SLASH = "%2F";
+
+    private final Context context;
+    private final Uri treeUri;
+    private final DocumentFile documentFile;
+
+    TreeUriDownloader(Uri uri, File destFile)
+            throws FileNotFoundException, MalformedURLException {
+        super(uri, destFile);
+        context = FDroidApp.getInstance();
+        String path = uri.getEncodedPath();
+        int lastEscapedSlash = path.lastIndexOf(ESCAPED_SLASH);
+        String pathChunkToEscape = path.substring(lastEscapedSlash + ESCAPED_SLASH.length());
+        String escapedPathChunk = Uri.encode(pathChunkToEscape);
+        treeUri = uri.buildUpon().encodedPath(path.replace(pathChunkToEscape, escapedPathChunk)).build();
+        documentFile = DocumentFile.fromTreeUri(context, treeUri);
+    }
+
+    /**
+     * This needs to convert {@link FileNotFoundException} and
+     * {@link IllegalArgumentException} to {@link ProtocolException} since the mirror
+     * failover logic expects network errors, not filesystem or other errors.
+     * In the downloading logic, filesystem errors are related to the file as it is
+     * being downloaded and written to disk.  Things can fail here if the USB stick is
+     * not longer plugged in, the files were deleted by some other process, etc.
+     * <p>
+     * Example: {@code IllegalArgumentException: Failed to determine if
+     * 6EED-6A10:guardianproject.info/wind-demo/fdroid/repo/index-v1.jar is child of
+     * 6EED-6A10:: java.io.File NotFoundException: No root for 6EED-6A10}
+     * <p>
+     * Example:
+     */
+    @Override
+    protected InputStream getDownloadersInputStream() throws IOException {
+        try {
+            InputStream inputStream = context.getContentResolver().openInputStream(treeUri);
+            if (inputStream == null) {
+                return null;
+            } else {
+                return new BufferedInputStream(inputStream);
+            }
+        } catch (FileNotFoundException | IllegalArgumentException e) {
+            throw new ProtocolException(e.getLocalizedMessage());
+        }
+    }
+
+    @Override
+    public boolean hasChanged() {
+        return true;  // TODO how should this actually be implemented?
+    }
+
+    @Override
+    protected long totalDownloadSize() {
+        return documentFile.length();
+    }
+
+    @Override
+    public void download() throws IOException, InterruptedException {
+        downloadFromStream(8192, false);
+    }
+
+    @Override
+    protected void close() {
+    }
+}
diff --git a/app/src/main/java/org/fdroid/fdroid/views/ManageReposActivity.java b/app/src/main/java/org/fdroid/fdroid/views/ManageReposActivity.java
index aba9492ff..8e044c17e 100644
--- a/app/src/main/java/org/fdroid/fdroid/views/ManageReposActivity.java
+++ b/app/src/main/java/org/fdroid/fdroid/views/ManageReposActivity.java
@@ -22,6 +22,7 @@ package org.fdroid.fdroid.views;
 import android.annotation.SuppressLint;
 import android.content.ClipData;
 import android.content.ClipboardManager;
+import android.content.ContentResolver;
 import android.content.ContentValues;
 import android.content.Context;
 import android.content.DialogInterface;
@@ -32,9 +33,12 @@ import android.net.Uri;
 import android.net.wifi.WifiInfo;
 import android.net.wifi.WifiManager;
 import android.os.AsyncTask;
+import android.os.Build;
 import android.os.Bundle;
 import android.support.annotation.NonNull;
 import android.support.v4.app.LoaderManager;
+import android.support.v4.app.NavUtils;
+import android.support.v4.app.TaskStackBuilder;
 import android.support.v4.content.CursorLoader;
 import android.support.v4.content.Loader;
 import android.support.v7.app.AlertDialog;
@@ -53,6 +57,7 @@ import android.widget.EditText;
 import android.widget.ListView;
 import android.widget.TextView;
 import android.widget.Toast;
+import org.fdroid.fdroid.AddRepoIntentService;
 import org.fdroid.fdroid.FDroidApp;
 import org.fdroid.fdroid.IndexUpdater;
 import org.fdroid.fdroid.R;
@@ -68,7 +73,6 @@ import java.io.File;
 import java.io.IOException;
 import java.net.HttpURLConnection;
 import java.net.MalformedURLException;
-import java.net.URI;
 import java.net.URISyntaxException;
 import java.net.URL;
 import java.util.Arrays;
@@ -79,10 +83,12 @@ public class ManageReposActivity extends AppCompatActivity
         implements LoaderManager.LoaderCallbacks<Cursor>, RepoAdapter.EnabledListener {
     private static final String TAG = "ManageReposActivity";
 
+    public static final String EXTRA_FINISH_AFTER_ADDING_REPO = "finishAfterAddingRepo";
+
     private static final String DEFAULT_NEW_REPO_TEXT = "https://";
 
     private enum AddRepoState {
-        DOESNT_EXIST, EXISTS_FINGERPRINT_MISMATCH, EXISTS_ADD_MIRROR,
+        DOESNT_EXIST, EXISTS_FINGERPRINT_MISMATCH, EXISTS_ADD_MIRROR, EXISTS_ALREADY_MIRROR,
         EXISTS_DISABLED, EXISTS_ENABLED, EXISTS_UPGRADABLE_TO_SIGNED, INVALID_URL,
         IS_SWAP
     }
@@ -93,7 +99,7 @@ public class ManageReposActivity extends AppCompatActivity
      * True if activity started with an intent such as from QR code. False if
      * opened from, e.g. the main menu.
      */
-    private boolean isImportingRepo;
+    private boolean finishAfterAddingRepo;
 
     @Override
     protected void onCreate(Bundle savedInstanceState) {
@@ -156,6 +162,16 @@ public class ManageReposActivity extends AppCompatActivity
             case R.id.action_add_repo:
                 showAddRepo();
                 return true;
+            case android.R.id.home:
+                Intent upIntent = NavUtils.getParentActivityIntent(this);
+                if (NavUtils.shouldUpRecreateTask(this, upIntent) || isTaskRoot()) {
+                    TaskStackBuilder.create(this)
+                            .addNextIntentWithParentStack(upIntent)
+                            .startActivities();
+                } else {
+                    NavUtils.navigateUpTo(this, upIntent);
+                }
+                return true;
         }
         return super.onOptionsItemSelected(item);
     }
@@ -272,7 +288,7 @@ public class ManageReposActivity extends AppCompatActivity
                         @Override
                         public void onClick(DialogInterface dialog, int which) {
                             dialog.dismiss();
-                            if (isImportingRepo) {
+                            if (finishAfterAddingRepo) {
                                 ManageReposActivity.this.finish();
                             }
                         }
@@ -311,7 +327,7 @@ public class ManageReposActivity extends AppCompatActivity
                             String url = uriEditText.getText().toString();
 
                             try {
-                                url = normalizeUrl(url);
+                                url = AddRepoIntentService.normalizeUrl(url);
                             } catch (URISyntaxException e) {
                                 invalidUrl();
                                 return;
@@ -415,7 +431,7 @@ public class ManageReposActivity extends AppCompatActivity
         private void validateRepoDetails(@NonNull String uri, @NonNull String fingerprint) {
 
             try {
-                uri = normalizeUrl(uri);
+                uri = AddRepoIntentService.normalizeUrl(uri);
             } catch (URISyntaxException e) {
                 // Don't bother dealing with this exception yet, as this is called every time
                 // a letter is added to the repo URL text input. We don't want to display a message
@@ -437,8 +453,9 @@ public class ManageReposActivity extends AppCompatActivity
                 } else if (repo.fingerprint != null && !repo.fingerprint.equalsIgnoreCase(fingerprint)) {
                     repoFingerprintDoesntMatch(repo);
                 } else {
-                    if (!TextUtils.equals(repo.address, uri)
-                            && !repo.getMirrorList().contains(uri)) {
+                    if (repo.getMirrorList().contains(uri) && !TextUtils.equals(repo.address, uri) && repo.inuse) {
+                        repoExistsAlreadyMirror(repo);
+                    } else if (!TextUtils.equals(repo.address, uri) && repo.inuse) {
                         repoExistsAddMirror(repo);
                     } else if (repo.inuse) {
                         repoExistsAndEnabled(repo);
@@ -487,6 +504,10 @@ public class ManageReposActivity extends AppCompatActivity
                     R.string.repo_add_mirror, true);
         }
 
+        private void repoExistsAlreadyMirror(Repo repo) {
+            updateUi(repo, AddRepoState.EXISTS_ALREADY_MIRROR, 0, false, R.string.ok, true);
+        }
+
         private void upgradingToSigned(Repo repo) {
             updateUi(repo, AddRepoState.EXISTS_UPGRADABLE_TO_SIGNED, R.string.repo_exists_add_fingerprint,
                     false, R.string.add_key, true);
@@ -518,6 +539,13 @@ public class ManageReposActivity extends AppCompatActivity
 
                 addButton.setText(addTextRes);
                 addButton.setEnabled(addEnabled);
+
+                if (Build.VERSION.SDK_INT >= 15 && addRepoState == AddRepoState.EXISTS_ALREADY_MIRROR) {
+                    addButton.callOnClick();
+                    editRepo(repo);
+                    String msg = getString(R.string.repo_exists_and_enabled, repo.address);
+                    Toast.makeText(context, msg, Toast.LENGTH_LONG).show();
+                }
             }
         }
 
@@ -555,6 +583,12 @@ public class ManageReposActivity extends AppCompatActivity
                         return originalAddress;
                     }
 
+                    if (originalAddress.startsWith(ContentResolver.SCHEME_CONTENT)
+                            || originalAddress.startsWith(ContentResolver.SCHEME_FILE)) {
+                        // TODO check whether there is read access
+                        return originalAddress;
+                    }
+
                     final String[] pathsToCheck = {"", "fdroid/repo", "repo"};
                     for (final String path : pathsToCheck) {
 
@@ -683,53 +717,6 @@ public class ManageReposActivity extends AppCompatActivity
             checker.execute(originalAddress);
         }
 
-        /**
-         * Some basic sanitization of URLs, so that two URLs which have the same semantic meaning
-         * are represented by the exact same string by F-Droid. This will help to make sure that,
-         * e.g. "http://10.0.1.50" and "http://10.0.1.50/" are not two different repositories.
-         * <p>
-         * Currently it normalizes the path so that "/./" are removed and "test/../" is collapsed.
-         * This is done using {@link URI#normalize()}. It also removes multiple consecutive forward
-         * slashes in the path and replaces them with one. Finally, it removes trailing slashes.
-         * <p>
-         * {@code content://} URLs used for repos stored on removable storage get messed up by
-         * {@link URI}.
-         */
-        private String normalizeUrl(String urlString) throws URISyntaxException {
-            if (urlString == null) {
-                return null;
-            }
-            Uri uri = Uri.parse(urlString);
-            if (!uri.isAbsolute()) {
-                throw new URISyntaxException(urlString, "Must provide an absolute URI for repositories");
-            }
-            if (!uri.isHierarchical()) {
-                throw new URISyntaxException(urlString, "Must provide an hierarchical URI for repositories");
-            }
-            if ("content".equals(uri.getScheme())) {
-                return uri.toString();
-            }
-            String path = uri.getPath();
-            if (path != null) {
-                path = path.replaceAll("//*/", "/"); // Collapse multiple forward slashes into 1.
-                if (path.length() > 0 && path.charAt(path.length() - 1) == '/') {
-                    path = path.substring(0, path.length() - 1);
-                }
-            }
-            String scheme = uri.getScheme();
-            String host = uri.getHost();
-            if (TextUtils.isEmpty(scheme) || TextUtils.isEmpty(host)) {
-                return urlString;
-            }
-            return new URI(scheme.toLowerCase(Locale.ENGLISH),
-                    uri.getUserInfo(),
-                    host.toLowerCase(Locale.ENGLISH),
-                    uri.getPort(),
-                    path,
-                    uri.getQuery(),
-                    uri.getFragment()).normalize().toString();
-        }
-
         /**
          * Create a repository without a username or password.
          */
@@ -740,7 +727,7 @@ public class ManageReposActivity extends AppCompatActivity
         private void createNewRepo(String address, String fingerprint,
                                    final String username, final String password) {
             try {
-                address = normalizeUrl(address);
+                address = AddRepoIntentService.normalizeUrl(address);
             } catch (URISyntaxException e) {
                 // Leave address as it was.
             }
@@ -816,7 +803,7 @@ public class ManageReposActivity extends AppCompatActivity
             if (addRepoDialog.isShowing()) {
                 addRepoDialog.dismiss();
             }
-            if (isImportingRepo) {
+            if (finishAfterAddingRepo) {
                 setResult(RESULT_OK);
                 finish();
             }
@@ -827,7 +814,7 @@ public class ManageReposActivity extends AppCompatActivity
         /* an URL from a click, NFC, QRCode scan, etc */
         NewRepoConfig newRepoConfig = new NewRepoConfig(this, intent);
         if (newRepoConfig.isValidRepo()) {
-            isImportingRepo = true;
+            finishAfterAddingRepo = intent.getBooleanExtra(EXTRA_FINISH_AFTER_ADDING_REPO, true);
             showAddRepo(newRepoConfig.getRepoUriString(), newRepoConfig.getFingerprint(),
                     newRepoConfig.getUsername(), newRepoConfig.getPassword());
             checkIfNewRepoOnSameWifi(newRepoConfig);
diff --git a/app/src/main/java/org/fdroid/fdroid/views/main/MainActivity.java b/app/src/main/java/org/fdroid/fdroid/views/main/MainActivity.java
index 089643b30..50b47cdfb 100644
--- a/app/src/main/java/org/fdroid/fdroid/views/main/MainActivity.java
+++ b/app/src/main/java/org/fdroid/fdroid/views/main/MainActivity.java
@@ -30,6 +30,7 @@ import android.content.IntentFilter;
 import android.net.Uri;
 import android.os.Build;
 import android.os.Bundle;
+import android.support.annotation.NonNull;
 import android.support.annotation.Nullable;
 import android.support.v4.content.LocalBroadcastManager;
 import android.support.v7.app.AppCompatActivity;
@@ -41,7 +42,6 @@ import android.widget.Toast;
 import com.ashokvarma.bottomnavigation.BottomNavigationBar;
 import com.ashokvarma.bottomnavigation.BottomNavigationItem;
 import com.ashokvarma.bottomnavigation.TextBadgeItem;
-import org.fdroid.fdroid.views.AppDetailsActivity;
 import org.fdroid.fdroid.AppUpdateStatusManager;
 import org.fdroid.fdroid.AppUpdateStatusManager.AppUpdateStatus;
 import org.fdroid.fdroid.BuildConfig;
@@ -52,6 +52,7 @@ 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.views.AppDetailsActivity;
 import org.fdroid.fdroid.views.ManageReposActivity;
 import org.fdroid.fdroid.views.apps.AppListActivity;
 import org.fdroid.fdroid.views.swap.SwapWorkflowActivity;
@@ -77,6 +78,9 @@ public class MainActivity extends AppCompatActivity implements BottomNavigationB
     public static final String EXTRA_VIEW_UPDATES = "org.fdroid.fdroid.views.main.MainActivity.VIEW_UPDATES";
     public static final String EXTRA_VIEW_SETTINGS = "org.fdroid.fdroid.views.main.MainActivity.VIEW_SETTINGS";
 
+    static final int REQUEST_LOCATION_PERMISSIONS = 0xEF0F;
+    static final int REQUEST_STORAGE_PERMISSIONS = 0xB004;
+
     private static final String ADD_REPO_INTENT_HANDLED = "addRepoIntentHandled";
 
     private static final String ACTION_ADD_REPO = "org.fdroid.fdroid.MainActivity.ACTION_ADD_REPO";
@@ -206,6 +210,14 @@ public class MainActivity extends AppCompatActivity implements BottomNavigationB
         checkForAddRepoIntent(intent);
     }
 
+    @Override
+    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { // NOCHECKSTYLE LineLength
+        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+        if (requestCode == REQUEST_LOCATION_PERMISSIONS) {
+            startActivity(new Intent(this, SwapWorkflowActivity.class));
+        }
+    }
+
     @Override
     public void onTabSelected(int position) {
         pager.scrollToPosition(position);
@@ -341,7 +353,12 @@ public class MainActivity extends AppCompatActivity implements BottomNavigationB
                     confirmIntent.setData(intent.getData());
                     startActivityForResult(confirmIntent, REQUEST_SWAP);
                 } else {
-                    startActivity(new Intent(ACTION_ADD_REPO, intent.getData(), this, ManageReposActivity.class));
+                    Intent clean = new Intent(ACTION_ADD_REPO, intent.getData(), this, ManageReposActivity.class);
+                    if (intent.hasExtra(ManageReposActivity.EXTRA_FINISH_AFTER_ADDING_REPO)) {
+                        clean.putExtra(ManageReposActivity.EXTRA_FINISH_AFTER_ADDING_REPO,
+                                intent.getBooleanExtra(ManageReposActivity.EXTRA_FINISH_AFTER_ADDING_REPO, true));
+                    }
+                    startActivity(clean);
                 }
                 finish();
             } else if (parser.getErrorMessage() != null) {
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index cf96f5297..076eba5ee 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -59,6 +59,10 @@
     <string name="local_repo_name">Name of your Local Repo</string>
     <string name="local_repo_name_summary">The advertised title of your local repo: %s</string>
     <string name="local_repo_https_on">Use encrypted HTTPS:// connection for local repo</string>
+    <string name="scan_removable_storage_title">Scan removable storage</string>
+    <string name="scan_removable_storage_summary">Look for package repos on removable storage like SD Cards
+        and USB thumb drives
+    </string>
 
     <string name="login_title">Authentication required</string>
     <string name="login_name">Username</string>
@@ -406,6 +410,8 @@ This often occurs with apps installed via Google Play or other sources, if they
     <string name="nearby_splash__download_apps_from_people_nearby">No Internet? Get apps from people near you!</string>
     <string name="nearby_splash__find_people_button">Find people nearby</string>
     <string name="nearby_splash__both_parties_need_fdroid">Both parties need %1$s to use nearby.</string>
+    <string name="nearby_splash__read_external_storage">SD Cards can be used to swap!</string>
+    <string name="nearby_splash__request_permission">Try it</string>
 
     <string name="swap_nfc_title">Touch to swap</string>
     <string name="swap_nfc_description">If your friend has F-Droid and NFC turned on touch your devices together.
@@ -452,6 +458,10 @@ This often occurs with apps installed via Google Play or other sources, if they
     <string name="swap_connection_misc_error">Error occurred while connecting to device, can\'t swap with it!</string>
     <string name="swap_not_enabled">Swapping not enabled</string>
     <string name="swap_not_enabled_description">Before swapping, your device must be made visible.</string>
+    <string name="swap_toast_using_path">Using %1$s</string>
+    <string name="swap_toast_not_removable_storage">That choice did not match any removeable storage devices, try
+        again!</string>
+    <string name="swap_toast_find_removeable_storage">Choose your removeable SD Card or USB</string>
     <string name="swap_toast_invalid_url">Invalid URL for swapping: %1$s</string>
     <string name="swap_toast_hotspot_enabled">Wi-Fi Hotspot enabled</string>
     <string name="swap_toast_could_not_enable_hotspot">Could not enable Wi-Fi Hotspot!</string>
diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml
index 2eb46455d..21e0a423c 100644
--- a/app/src/main/res/xml/preferences.xml
+++ b/app/src/main/res/xml/preferences.xml
@@ -96,6 +96,11 @@
         <EditTextPreference
                 android:key="localRepoName"
                 android:title="@string/local_repo_name"/>
+        <SwitchPreference
+                android:key="scanRemovableStorage"
+                android:defaultValue="true"
+                android:title="@string/scan_removable_storage_title"
+                android:summary="@string/scan_removable_storage_summary"/>
     </android.support.v7.preference.PreferenceCategory>
 
     <android.support.v7.preference.PreferenceCategory android:title="@string/proxy">
diff --git a/app/src/test/java/org/fdroid/fdroid/updater/FDroidRepoUpdateTest.java b/app/src/test/java/org/fdroid/fdroid/updater/FDroidRepoUpdateTest.java
index c34c085d3..898f6d7fb 100644
--- a/app/src/test/java/org/fdroid/fdroid/updater/FDroidRepoUpdateTest.java
+++ b/app/src/test/java/org/fdroid/fdroid/updater/FDroidRepoUpdateTest.java
@@ -34,13 +34,13 @@ public class FDroidRepoUpdateTest extends MultiIndexUpdaterTest {
 
     protected void updateEarlier() throws IndexUpdater.UpdateException {
         Utils.debugLog(TAG, "Updating earlier version of F-Droid repo");
-        updateRepo(createRepoUpdater(REPO_FDROID, REPO_FDROID_URI, context, REPO_FDROID_PUB_KEY),
+        updateRepo(createIndexUpdater(REPO_FDROID, REPO_FDROID_URI, context, REPO_FDROID_PUB_KEY),
                 "index.fdroid.2016-10-30.jar");
     }
 
     protected void updateLater() throws IndexUpdater.UpdateException {
         Utils.debugLog(TAG, "Updating later version of F-Droid repo");
-        updateRepo(createRepoUpdater(REPO_FDROID, REPO_FDROID_URI, context, REPO_FDROID_PUB_KEY),
+        updateRepo(createIndexUpdater(REPO_FDROID, REPO_FDROID_URI, context, REPO_FDROID_PUB_KEY),
                 "index.fdroid.2016-11-10.jar");
     }
 
diff --git a/app/src/test/java/org/fdroid/fdroid/updater/MultiIndexUpdaterTest.java b/app/src/test/java/org/fdroid/fdroid/updater/MultiIndexUpdaterTest.java
index 5a1b9e158..2742dec5e 100644
--- a/app/src/test/java/org/fdroid/fdroid/updater/MultiIndexUpdaterTest.java
+++ b/app/src/test/java/org/fdroid/fdroid/updater/MultiIndexUpdaterTest.java
@@ -171,11 +171,11 @@ public abstract class MultiIndexUpdaterTest extends FDroidProviderTest {
         return RepoProvider.Helper.findByAddress(context, uri);
     }
 
-    protected IndexUpdater createRepoUpdater(String name, String uri, Context context) {
+    protected IndexUpdater createIndexUpdater(String name, String uri, Context context) {
         return new IndexUpdater(context, createRepo(name, uri, context));
     }
 
-    protected IndexUpdater createRepoUpdater(String name, String uri, Context context, String signingCert) {
+    protected IndexUpdater createIndexUpdater(String name, String uri, Context context, String signingCert) {
         return new IndexUpdater(context, createRepo(name, uri, context, signingCert));
     }
 
@@ -184,15 +184,15 @@ public abstract class MultiIndexUpdaterTest extends FDroidProviderTest {
     }
 
     protected void updateConflicting() throws UpdateException {
-        updateRepo(createRepoUpdater(REPO_CONFLICTING, REPO_CONFLICTING_URI, context), "multiRepo.conflicting.jar");
+        updateRepo(createIndexUpdater(REPO_CONFLICTING, REPO_CONFLICTING_URI, context), "multiRepo.conflicting.jar");
     }
 
     protected void updateMain() throws UpdateException {
-        updateRepo(createRepoUpdater(REPO_MAIN, REPO_MAIN_URI, context), "multiRepo.normal.jar");
+        updateRepo(createIndexUpdater(REPO_MAIN, REPO_MAIN_URI, context), "multiRepo.normal.jar");
     }
 
     protected void updateArchive() throws UpdateException {
-        updateRepo(createRepoUpdater(REPO_ARCHIVE, REPO_ARCHIVE_URI, context), "multiRepo.archive.jar");
+        updateRepo(createIndexUpdater(REPO_ARCHIVE, REPO_ARCHIVE_URI, context), "multiRepo.archive.jar");
     }
 
     protected void updateRepo(IndexUpdater updater, String indexJarPath) throws UpdateException {
diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml
index d14e701a3..bea13222a 100644
--- a/config/checkstyle/checkstyle.xml
+++ b/config/checkstyle/checkstyle.xml
@@ -17,7 +17,7 @@
         <module name="FileContentsHolder" />
         <module name="LineLength">
             <property name="max" value="118"/>
-            <property name="ignorePattern" value="https?://"/>
+            <property name="ignorePattern" value="[a-z]+://"/>
         </module>
 
         <module name="ConstantName">