Merge branch 'use-repos-from-usb-and-sdcard' into 'master'
use repos from USB-OTG Drives and SDCards Closes #1377 and #656 See merge request fdroid/fdroidclient!769
This commit is contained in:
		
						commit
						8c5263c5c5
					
				| @ -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())); | ||||
|  | ||||
| @ -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) { | ||||
|     } | ||||
| } | ||||
| @ -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"); | ||||
|     } | ||||
| } | ||||
| @ -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" | ||||
|  | ||||
| @ -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); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| @ -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 | ||||
|     } | ||||
| } | ||||
| @ -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); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  | ||||
| @ -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); | ||||
|                     } | ||||
|                 } | ||||
|             }); | ||||
| 
 | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -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" | ||||
|  | ||||
| @ -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 --> | ||||
|  | ||||
							
								
								
									
										147
									
								
								app/src/main/java/org/fdroid/fdroid/AddRepoIntentService.java
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										147
									
								
								app/src/main/java/org/fdroid/fdroid/AddRepoIntentService.java
									
									
									
									
									
										Normal file
									
								
							| @ -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(); | ||||
|     } | ||||
| } | ||||
| @ -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); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  | ||||
| @ -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() { | ||||
|  | ||||
| @ -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 { | ||||
|  | ||||
| @ -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); | ||||
|     } | ||||
|  | ||||
| @ -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 | ||||
|  | ||||
| @ -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); | ||||
|  | ||||
| @ -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; | ||||
|         } | ||||
|  | ||||
| @ -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 { | ||||
|  | ||||
| @ -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); | ||||
|  | ||||
| @ -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); | ||||
|     } | ||||
|  | ||||
| @ -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); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										105
									
								
								app/src/main/java/org/fdroid/fdroid/net/TreeUriDownloader.java
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								app/src/main/java/org/fdroid/fdroid/net/TreeUriDownloader.java
									
									
									
									
									
										Normal file
									
								
							| @ -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() { | ||||
|     } | ||||
| } | ||||
| @ -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); | ||||
|  | ||||
| @ -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) { | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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"> | ||||
|  | ||||
| @ -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"); | ||||
|     } | ||||
| 
 | ||||
|  | ||||
| @ -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 { | ||||
|  | ||||
| @ -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"> | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 Hans-Christoph Steiner
						Hans-Christoph Steiner