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