From ac1a5e0ad8bed1c658885c8f6142367a3812815d Mon Sep 17 00:00:00 2001
From: Hans-Christoph Steiner <hans@eds.org>
Date: Mon, 17 Dec 2018 23:00:38 +0100
Subject: [PATCH 1/9] ensure the canonical repo URL is always included in
 mirrors list

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 `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.

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.
---
 .../org/fdroid/fdroid/IndexV1Updater.java     | 18 ++++++---
 .../java/org/fdroid/fdroid/data/DBHelper.java |  9 +++++
 .../java/org/fdroid/fdroid/data/Repo.java     | 39 +++++++++++++------
 3 files changed, 50 insertions(+), 16 deletions(-)

diff --git a/app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java b/app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java
index 08f16430b..63ee68901 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;
@@ -284,7 +286,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 +381,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/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/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 {

From 1571e28f68090b148b61296456dfceef0330bcf2 Mon Sep 17 00:00:00 2001
From: Hans-Christoph Steiner <hans@eds.org>
Date: Fri, 21 Dec 2018 00:03:39 +0100
Subject: [PATCH 2/9] support swapping with removable storage on android-21+

This uses the new Storage Access Framework, which was required for
accessing files on the SD Card starting in android-19.  But the API
was really limited until android-21, and not really complete until
android-23 or even android-26.  So the levels of usability will vary a
lot based on how new the version of Android is.
---
 .../fdroid/MainActivityEspressoTest.java      |  10 +
 .../fdroid/views/main/NearbyViewBinder.java   |  10 +
 app/src/full/AndroidManifest.xml              |   3 +
 .../TreeUriScannerIntentService.java          | 171 ++++++++++++++++++
 .../fdroid/views/main/MainViewController.java |  28 +--
 .../fdroid/views/main/NearbyViewBinder.java   | 124 +++++++++++++
 app/src/full/res/layout/main_tab_swap.xml     |  31 ++++
 .../java/org/fdroid/fdroid/IndexUpdater.java  |   7 +-
 .../org/fdroid/fdroid/IndexV1Updater.java     |  11 +-
 .../org/fdroid/fdroid/data/NewRepoConfig.java |   2 +-
 .../fdroid/fdroid/net/DownloaderFactory.java  |   2 +
 .../fdroid/fdroid/net/ImageLoaderForUIL.java  |   5 +
 .../fdroid/fdroid/net/TreeUriDownloader.java  | 105 +++++++++++
 .../fdroid/views/ManageReposActivity.java     |   6 +
 .../fdroid/views/main/MainActivity.java       |   4 +-
 app/src/main/res/values/strings.xml           |   6 +
 config/checkstyle/checkstyle.xml              |   2 +-
 17 files changed, 494 insertions(+), 33 deletions(-)
 create mode 100644 app/src/basic/java/org/fdroid/fdroid/views/main/NearbyViewBinder.java
 create mode 100644 app/src/full/java/org/fdroid/fdroid/localrepo/TreeUriScannerIntentService.java
 create mode 100644 app/src/full/java/org/fdroid/fdroid/views/main/NearbyViewBinder.java
 create mode 100644 app/src/main/java/org/fdroid/fdroid/net/TreeUriDownloader.java

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/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..5bdd57aec 100644
--- a/app/src/full/AndroidManifest.xml
+++ b/app/src/full/AndroidManifest.xml
@@ -77,6 +77,9 @@
         <service
                 android:name=".localrepo.CacheSwapAppsService"
                 android:exported="false"/>
+        <service
+                android:name=".localrepo.TreeUriScannerIntentService"
+                android:exported="false"/>
 
         <activity
                 android:name=".views.panic.PanicPreferencesActivity"
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..399e6477e
--- /dev/null
+++ b/app/src/full/java/org/fdroid/fdroid/localrepo/TreeUriScannerIntentService.java
@@ -0,0 +1,171 @@
+/*
+ * 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.IndexV1Updater;
+import org.fdroid.fdroid.IndexUpdater;
+import org.fdroid.fdroid.Utils;
+import org.fdroid.fdroid.data.Repo;
+import org.fdroid.fdroid.data.RepoProvider;
+import org.fdroid.fdroid.views.main.MainActivity;
+
+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) {
+        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");
+            }
+        }
+
+        Intent intent = new Intent(context, MainActivity.class);
+        intent.setAction(Intent.ACTION_VIEW);
+        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+        intent.setData(repoUri.buildUpon()
+                .appendQueryParameter("fingerprint", fingerprint)
+                .build());
+        context.startActivity(intent);
+        // TODO parse repo URL/mirrors/fingerprint using Jackson
+        //      https://stackoverflow.com/questions/24835431/use-jackson-to-stream-parse-an-array-of-json-objects#
+        // TODO rework IndexUpdater.getSigningCertFromJar to work for here
+        // TODO check whether fingerprint is already in the database
+    }
+}
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..8840b915c
--- /dev/null
+++ b/app/src/full/java/org/fdroid/fdroid/views/main/NearbyViewBinder.java
@@ -0,0 +1,124 @@
+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.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
+ */
+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) {
+                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 {
+                        // TODO do something
+                    }
+                }
+            });
+
+        }
+    }
+}
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/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 63ee68901..1ada06ab3 100644
--- a/app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java
+++ b/app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java
@@ -89,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) {
@@ -97,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();
+        }
     }
 
     /**
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..f7efb42ba 100644
--- a/app/src/main/java/org/fdroid/fdroid/data/NewRepoConfig.java
+++ b/app/src/main/java/org/fdroid/fdroid/data/NewRepoConfig.java
@@ -82,7 +82,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").contains(scheme)) {
             isValidRepo = false;
             return;
         }
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..9fcd1a2ea 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,8 @@ 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 {
             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/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..6949f3f7c 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;
@@ -555,6 +556,11 @@ public class ManageReposActivity extends AppCompatActivity
                         return originalAddress;
                     }
 
+                    if (originalAddress.startsWith(ContentResolver.SCHEME_CONTENT)) {
+                        // TODO check whether there is read access
+                        return originalAddress;
+                    }
+
                     final String[] pathsToCheck = {"", "fdroid/repo", "repo"};
                     for (final String path : pathsToCheck) {
 
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..6fa949376 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
@@ -41,7 +41,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 +51,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 +77,8 @@ 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_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";
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index cf96f5297..9b4f9d958 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -406,6 +406,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 +454,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/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">

From f9bc21907386a6e9c24c69ddb900debb358503d0 Mon Sep 17 00:00:00 2001
From: Hans-Christoph Steiner <hans@eds.org>
Date: Thu, 20 Dec 2018 23:35:00 +0100
Subject: [PATCH 3/9] SDCardScannerService for using repos from SD Cards

Creates an 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"

* https://stackoverflow.com/a/40201333
* https://commonsware.com/blog/2017/11/14/storage-situation-external-storage.html

closes #1377
---
 .../localrepo/SDCardScannerService.java       |  30 ++++
 app/src/full/AndroidManifest.xml              |   4 +
 .../localrepo/SDCardScannerService.java       | 168 ++++++++++++++++++
 .../fdroid/views/main/NearbyViewBinder.java   |   4 +-
 .../java/org/fdroid/fdroid/FDroidApp.java     |   3 +
 .../org/fdroid/fdroid/data/NewRepoConfig.java |   6 +-
 .../fdroid/fdroid/net/DownloaderFactory.java  |   2 +
 .../fdroid/net/LocalFileDownloader.java       |  86 +++++++++
 .../fdroid/views/ManageReposActivity.java     |   3 +-
 9 files changed, 302 insertions(+), 4 deletions(-)
 create mode 100644 app/src/basic/java/org/fdroid/fdroid/localrepo/SDCardScannerService.java
 create mode 100644 app/src/full/java/org/fdroid/fdroid/localrepo/SDCardScannerService.java
 create mode 100644 app/src/main/java/org/fdroid/fdroid/net/LocalFileDownloader.java

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/full/AndroidManifest.xml b/app/src/full/AndroidManifest.xml
index 5bdd57aec..a98dd272d 100644
--- a/app/src/full/AndroidManifest.xml
+++ b/app/src/full/AndroidManifest.xml
@@ -38,6 +38,7 @@
     <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"/>
@@ -80,6 +81,9 @@
         <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..a242b081e
--- /dev/null
+++ b/app/src/full/java/org/fdroid/fdroid/localrepo/SDCardScannerService.java
@@ -0,0 +1,168 @@
+/*
+ * 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.IndexV1Updater;
+import org.fdroid.fdroid.IndexUpdater;
+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"
+ *
+ * @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) {
+        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/views/main/NearbyViewBinder.java b/app/src/full/java/org/fdroid/fdroid/views/main/NearbyViewBinder.java
index 8840b915c..be8b0e7b6 100644
--- a/app/src/full/java/org/fdroid/fdroid/views/main/NearbyViewBinder.java
+++ b/app/src/full/java/org/fdroid/fdroid/views/main/NearbyViewBinder.java
@@ -17,6 +17,7 @@ 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;
 
@@ -47,6 +48,7 @@ import java.io.File;
  * write access to the the removable storage.
  *
  * @see TreeUriScannerIntentService
+ * @see org.fdroid.fdroid.localrepo.SDCardScannerService
  */
 class NearbyViewBinder {
     public static final String TAG = "NearbyViewBinder";
@@ -114,7 +116,7 @@ class NearbyViewBinder {
                         ActivityCompat.requestPermissions(activity, new String[]{writeExternalStorage},
                                 MainActivity.REQUEST_STORAGE_PERMISSIONS);
                     } else {
-                        // TODO do something
+                        SDCardScannerService.scan(activity);
                     }
                 }
             });
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/data/NewRepoConfig.java b/app/src/main/java/org/fdroid/fdroid/data/NewRepoConfig.java
index f7efb42ba..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", "content").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/net/DownloaderFactory.java b/app/src/main/java/org/fdroid/fdroid/net/DownloaderFactory.java
index 9fcd1a2ea..2a13e947a 100644
--- a/app/src/main/java/org/fdroid/fdroid/net/DownloaderFactory.java
+++ b/app/src/main/java/org/fdroid/fdroid/net/DownloaderFactory.java
@@ -33,6 +33,8 @@ public class DownloaderFactory {
             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/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/views/ManageReposActivity.java b/app/src/main/java/org/fdroid/fdroid/views/ManageReposActivity.java
index 6949f3f7c..012fffd16 100644
--- a/app/src/main/java/org/fdroid/fdroid/views/ManageReposActivity.java
+++ b/app/src/main/java/org/fdroid/fdroid/views/ManageReposActivity.java
@@ -556,7 +556,8 @@ public class ManageReposActivity extends AppCompatActivity
                         return originalAddress;
                     }
 
-                    if (originalAddress.startsWith(ContentResolver.SCHEME_CONTENT)) {
+                    if (originalAddress.startsWith(ContentResolver.SCHEME_CONTENT)
+                            || originalAddress.startsWith(ContentResolver.SCHEME_FILE)) {
                         // TODO check whether there is read access
                         return originalAddress;
                     }

From c4b0955c96672ffec08f25642bb74c0372e860e5 Mon Sep 17 00:00:00 2001
From: Hans-Christoph Steiner <hans@eds.org>
Date: Wed, 1 Aug 2018 15:59:15 +0200
Subject: [PATCH 4/9] add preference to disable removable storage scanning

---
 .../fdroid/localrepo/SDCardScannerService.java   | 16 ++++++++++++----
 .../localrepo/TreeUriScannerIntentService.java   | 13 ++++++++-----
 .../main/java/org/fdroid/fdroid/Preferences.java |  5 +++++
 app/src/main/res/values/strings.xml              |  4 ++++
 app/src/main/res/xml/preferences.xml             |  5 +++++
 5 files changed, 34 insertions(+), 9 deletions(-)

diff --git a/app/src/full/java/org/fdroid/fdroid/localrepo/SDCardScannerService.java b/app/src/full/java/org/fdroid/fdroid/localrepo/SDCardScannerService.java
index a242b081e..6a021cbb0 100644
--- a/app/src/full/java/org/fdroid/fdroid/localrepo/SDCardScannerService.java
+++ b/app/src/full/java/org/fdroid/fdroid/localrepo/SDCardScannerService.java
@@ -30,8 +30,9 @@ import android.os.Environment;
 import android.os.Process;
 import android.support.v4.content.ContextCompat;
 import android.util.Log;
-import org.fdroid.fdroid.IndexV1Updater;
 import org.fdroid.fdroid.IndexUpdater;
+import org.fdroid.fdroid.IndexV1Updater;
+import org.fdroid.fdroid.Preferences;
 import org.fdroid.fdroid.Utils;
 
 import java.io.File;
@@ -52,6 +53,11 @@ import java.util.List;
  * 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>
@@ -69,9 +75,11 @@ public class SDCardScannerService extends IntentService {
     }
 
     public static void scan(Context context) {
-        Intent intent = new Intent(context, SDCardScannerService.class);
-        intent.setAction(ACTION_SCAN);
-        context.startService(intent);
+        if (Preferences.get().isScanRemovableStorageEnabled()) {
+            Intent intent = new Intent(context, SDCardScannerService.class);
+            intent.setAction(ACTION_SCAN);
+            context.startService(intent);
+        }
     }
 
     @Override
diff --git a/app/src/full/java/org/fdroid/fdroid/localrepo/TreeUriScannerIntentService.java b/app/src/full/java/org/fdroid/fdroid/localrepo/TreeUriScannerIntentService.java
index 399e6477e..0c90fcd2f 100644
--- a/app/src/full/java/org/fdroid/fdroid/localrepo/TreeUriScannerIntentService.java
+++ b/app/src/full/java/org/fdroid/fdroid/localrepo/TreeUriScannerIntentService.java
@@ -29,8 +29,9 @@ 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.IndexV1Updater;
 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;
@@ -72,10 +73,12 @@ public class TreeUriScannerIntentService extends IntentService {
     }
 
     public static void scan(Context context, Uri data) {
-        Intent intent = new Intent(context, TreeUriScannerIntentService.class);
-        intent.setAction(ACTION_SCAN_TREE_URI);
-        intent.setData(data);
-        context.startService(intent);
+        if (Preferences.get().isScanRemovableStorageEnabled()) {
+            Intent intent = new Intent(context, TreeUriScannerIntentService.class);
+            intent.setAction(ACTION_SCAN_TREE_URI);
+            intent.setData(data);
+            context.startService(intent);
+        }
     }
 
     @Override
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/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 9b4f9d958..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>
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">

From 1d1f489d85a595d3c4d0547ce1883f59d7e992fc Mon Sep 17 00:00:00 2001
From: Hans-Christoph Steiner <hans@eds.org>
Date: Thu, 19 Apr 2018 10:31:48 +0200
Subject: [PATCH 5/9] handle repo Intents for mirrors that are already enabled

This adds a new IntentService to pre-process Intents that request a
new repo is added.  Right now, this only handles Intents that come
from the new storage scanners.

This also adds a new case to the AddRepo UI logic to cover when an
incoming Intent is for a mirror that is already included in an enabled
repo.  In that case, the user is show the Repo Details screen for the
repo that includes that mirror.  This is done is a hacky way right now
since the only path through is to click the button.  So this clicks
the button in code.
---
 .../TreeUriScannerIntentService.java          |  13 +-
 app/src/main/AndroidManifest.xml              |   3 +
 .../fdroid/fdroid/AddRepoIntentService.java   | 145 ++++++++++++++++++
 .../fdroid/views/ManageReposActivity.java     |  74 +++------
 4 files changed, 170 insertions(+), 65 deletions(-)
 create mode 100644 app/src/main/java/org/fdroid/fdroid/AddRepoIntentService.java

diff --git a/app/src/full/java/org/fdroid/fdroid/localrepo/TreeUriScannerIntentService.java b/app/src/full/java/org/fdroid/fdroid/localrepo/TreeUriScannerIntentService.java
index 0c90fcd2f..33eb0374c 100644
--- a/app/src/full/java/org/fdroid/fdroid/localrepo/TreeUriScannerIntentService.java
+++ b/app/src/full/java/org/fdroid/fdroid/localrepo/TreeUriScannerIntentService.java
@@ -29,13 +29,13 @@ 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 org.fdroid.fdroid.views.main.MainActivity;
 
 import java.io.File;
 import java.io.IOException;
@@ -159,16 +159,7 @@ public class TreeUriScannerIntentService extends IntentService {
             }
         }
 
-        Intent intent = new Intent(context, MainActivity.class);
-        intent.setAction(Intent.ACTION_VIEW);
-        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
-        intent.setData(repoUri.buildUpon()
-                .appendQueryParameter("fingerprint", fingerprint)
-                .build());
-        context.startActivity(intent);
-        // TODO parse repo URL/mirrors/fingerprint using Jackson
-        //      https://stackoverflow.com/questions/24835431/use-jackson-to-stream-parse-an-array-of-json-objects#
+        AddRepoIntentService.addRepo(context, repoUri, fingerprint);
         // TODO rework IndexUpdater.getSigningCertFromJar to work for here
-        // TODO check whether fingerprint is already in the database
     }
 }
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..37ce63101
--- /dev/null
+++ b/app/src/main/java/org/fdroid/fdroid/AddRepoIntentService.java
@@ -0,0 +1,145 @@
+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.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.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/views/ManageReposActivity.java b/app/src/main/java/org/fdroid/fdroid/views/ManageReposActivity.java
index 012fffd16..2f8cdb777 100644
--- a/app/src/main/java/org/fdroid/fdroid/views/ManageReposActivity.java
+++ b/app/src/main/java/org/fdroid/fdroid/views/ManageReposActivity.java
@@ -33,6 +33,7 @@ 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;
@@ -54,6 +55,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;
@@ -69,7 +71,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;
@@ -83,7 +84,7 @@ public class ManageReposActivity extends AppCompatActivity
     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
     }
@@ -312,7 +313,7 @@ public class ManageReposActivity extends AppCompatActivity
                             String url = uriEditText.getText().toString();
 
                             try {
-                                url = normalizeUrl(url);
+                                url = AddRepoIntentService.normalizeUrl(url);
                             } catch (URISyntaxException e) {
                                 invalidUrl();
                                 return;
@@ -416,7 +417,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
@@ -438,8 +439,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);
@@ -488,6 +490,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);
@@ -519,6 +525,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();
+                }
             }
         }
 
@@ -690,53 +703,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.
          */
@@ -747,7 +713,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.
             }

From 90c3baf5afe6ac074b2953fc61a2216ac65cb2d4 Mon Sep 17 00:00:00 2001
From: Hans-Christoph Steiner <hans@eds.org>
Date: Thu, 12 Apr 2018 17:28:23 +0200
Subject: [PATCH 6/9] scanning WiFi/Bluetooth in android-23 requires location
 permission

This requires that admin#65 is fixed, otherwise every F-Droid update will
require Unknown Sources with Privileged Extension.

https://developer.android.com/about/versions/marshmallow/android-6.0-changes.html#behavior-hardware-id
https://stackoverflow.com/a/44200390

closes #656
---
 app/src/full/AndroidManifest.xml                       |  2 ++
 .../org/fdroid/fdroid/views/main/NearbyViewBinder.java | 10 +++++++++-
 .../org/fdroid/fdroid/views/main/MainActivity.java     | 10 ++++++++++
 3 files changed, 21 insertions(+), 1 deletion(-)

diff --git a/app/src/full/AndroidManifest.xml b/app/src/full/AndroidManifest.xml
index a98dd272d..70c2bbe0f 100644
--- a/app/src/full/AndroidManifest.xml
+++ b/app/src/full/AndroidManifest.xml
@@ -44,6 +44,8 @@
     <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
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
index be8b0e7b6..0df89f884 100644
--- a/app/src/full/java/org/fdroid/fdroid/views/main/NearbyViewBinder.java
+++ b/app/src/full/java/org/fdroid/fdroid/views/main/NearbyViewBinder.java
@@ -68,7 +68,15 @@ class NearbyViewBinder {
         startButton.setOnClickListener(new View.OnClickListener() {
             @Override
             public void onClick(View v) {
-                activity.startActivity(new Intent(activity, SwapWorkflowActivity.class));
+                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));
+                }
             }
         });
 
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 6fa949376..d30c736e2 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;
@@ -77,6 +78,7 @@ 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";
@@ -208,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);

From 0e5dd45859ac655f8d971628f531fa1d3524c9fa Mon Sep 17 00:00:00 2001
From: Hans-Christoph Steiner <hans@eds.org>
Date: Tue, 18 Dec 2018 23:06:59 +0100
Subject: [PATCH 7/9] fix a couple missed renames for IndexUpdater

* 0e6b4acabf72050e47dc80a273f5a5d1ec4ce222
* fdroid/fdroidclient!767
---
 .../fdroid/fdroid/updater/FDroidRepoUpdateTest.java    |  4 ++--
 .../fdroid/fdroid/updater/MultiIndexUpdaterTest.java   | 10 +++++-----
 2 files changed, 7 insertions(+), 7 deletions(-)

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 {

From 69e2ca428382ea69144144abc845b3f7e65d96c6 Mon Sep 17 00:00:00 2001
From: Hans-Christoph Steiner <hans@eds.org>
Date: Thu, 9 Aug 2018 16:59:08 +0200
Subject: [PATCH 8/9] keep F-Droid visible after adding removeable storage
 mirror

---
 .../fdroid/fdroid/AddRepoIntentService.java   |  2 ++
 .../fdroid/views/ManageReposActivity.java     | 22 +++++++++++++++----
 .../fdroid/views/main/MainActivity.java       |  7 +++++-
 3 files changed, 26 insertions(+), 5 deletions(-)

diff --git a/app/src/main/java/org/fdroid/fdroid/AddRepoIntentService.java b/app/src/main/java/org/fdroid/fdroid/AddRepoIntentService.java
index 37ce63101..9530429b3 100644
--- a/app/src/main/java/org/fdroid/fdroid/AddRepoIntentService.java
+++ b/app/src/main/java/org/fdroid/fdroid/AddRepoIntentService.java
@@ -11,6 +11,7 @@ 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;
@@ -88,6 +89,7 @@ public class AddRepoIntentService extends IntentService {
                 }
             }
         }
+        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);
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 2f8cdb777..8e044c17e 100644
--- a/app/src/main/java/org/fdroid/fdroid/views/ManageReposActivity.java
+++ b/app/src/main/java/org/fdroid/fdroid/views/ManageReposActivity.java
@@ -37,6 +37,8 @@ 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;
@@ -81,6 +83,8 @@ 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 {
@@ -95,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) {
@@ -158,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);
     }
@@ -274,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();
                             }
                         }
@@ -789,7 +803,7 @@ public class ManageReposActivity extends AppCompatActivity
             if (addRepoDialog.isShowing()) {
                 addRepoDialog.dismiss();
             }
-            if (isImportingRepo) {
+            if (finishAfterAddingRepo) {
                 setResult(RESULT_OK);
                 finish();
             }
@@ -800,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 d30c736e2..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
@@ -353,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) {

From f95af36140270ab4fc3535462a797a4b5adb0c54 Mon Sep 17 00:00:00 2001
From: Hans-Christoph Steiner <hans@eds.org>
Date: Thu, 20 Dec 2018 23:13:07 +0100
Subject: [PATCH 9/9] allow repos on removable storage to work without any
 internet

---
 .../java/org/fdroid/fdroid/UpdateService.java | 49 ++++++++++++++-----
 1 file changed, 38 insertions(+), 11 deletions(-)

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