diff --git a/app/src/androidTest/java/org/fdroid/fdroid/MainActivityEspressoTest.java b/app/src/androidTest/java/org/fdroid/fdroid/MainActivityEspressoTest.java index 29ef5621a..346a65e78 100644 --- a/app/src/androidTest/java/org/fdroid/fdroid/MainActivityEspressoTest.java +++ b/app/src/androidTest/java/org/fdroid/fdroid/MainActivityEspressoTest.java @@ -1,5 +1,6 @@ package org.fdroid.fdroid; +import android.Manifest; import android.app.Instrumentation; import android.os.Build; import android.support.test.InstrumentationRegistry; @@ -7,6 +8,7 @@ import android.support.test.espresso.IdlingPolicies; import android.support.test.espresso.ViewInteraction; import android.support.test.filters.LargeTest; import android.support.test.rule.ActivityTestRule; +import android.support.test.rule.GrantPermissionRule; import android.support.test.runner.AndroidJUnit4; import android.support.test.uiautomator.UiDevice; import android.support.test.uiautomator.UiObject; @@ -120,6 +122,14 @@ public class MainActivityEspressoTest { public ActivityTestRule activityTestRule = new ActivityTestRule<>(MainActivity.class); + @Rule + public GrantPermissionRule accessCoarseLocationPermissionRule = GrantPermissionRule.grant( + Manifest.permission.ACCESS_COARSE_LOCATION); + + @Rule + public GrantPermissionRule writeExternalStoragePermissionRule = GrantPermissionRule.grant( + Manifest.permission.WRITE_EXTERNAL_STORAGE); + @Test public void bottomNavFlavorCheck() { onView(withText(R.string.updates)).check(matches(isDisplayed())); diff --git a/app/src/basic/java/org/fdroid/fdroid/views/main/NearbyViewBinder.java b/app/src/basic/java/org/fdroid/fdroid/views/main/NearbyViewBinder.java new file mode 100644 index 000000000..f3e21451a --- /dev/null +++ b/app/src/basic/java/org/fdroid/fdroid/views/main/NearbyViewBinder.java @@ -0,0 +1,10 @@ +package org.fdroid.fdroid.views.main; + +import android.app.Activity; +import android.content.Intent; + +class NearbyViewBinder { + static void onActivityResult(Activity activity, Intent data) { + throw new IllegalStateException("unimplemented"); + } +} diff --git a/app/src/full/AndroidManifest.xml b/app/src/full/AndroidManifest.xml index 0cee2ee2c..5bdd57aec 100644 --- a/app/src/full/AndroidManifest.xml +++ b/app/src/full/AndroidManifest.xml @@ -77,6 +77,9 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 3 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, + * MA 02110-1301, USA. + */ + +package org.fdroid.fdroid.localrepo; + +import android.annotation.TargetApi; +import android.app.IntentService; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Process; +import android.support.v4.provider.DocumentFile; +import android.util.Log; +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.IOUtils; +import org.fdroid.fdroid.IndexV1Updater; +import org.fdroid.fdroid.IndexUpdater; +import org.fdroid.fdroid.Utils; +import org.fdroid.fdroid.data.Repo; +import org.fdroid.fdroid.data.RepoProvider; +import org.fdroid.fdroid.views.main.MainActivity; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.security.cert.Certificate; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.JarInputStream; + +/** + * An {@link IntentService} subclass for handling asynchronous scanning of a + * removable storage device like an SD Card or USB OTG thumb drive using the + * Storage Access Framework. Permission must first be granted by the user + * {@link android.content.Intent#ACTION_OPEN_DOCUMENT_TREE} or + * {@link android.os.storage.StorageVolume#createAccessIntent(String)}request, + * then F-Droid will have permanent access to that{@link Uri}. + *

+ * 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 The Storage Situation: Removable Storage + * @see Using Scoped Directory Access + * @see Open Files using Storage Access Framework + */ +@TargetApi(21) +public class TreeUriScannerIntentService extends IntentService { + public static final String TAG = "TreeUriScannerIntentSer"; + + private static final String ACTION_SCAN_TREE_URI = "org.fdroid.fdroid.localrepo.action.SCAN_TREE_URI"; + + public TreeUriScannerIntentService() { + super("TreeUriScannerIntentService"); + } + + public static void scan(Context context, Uri data) { + Intent intent = new Intent(context, TreeUriScannerIntentService.class); + intent.setAction(ACTION_SCAN_TREE_URI); + intent.setData(data); + context.startService(intent); + } + + @Override + protected void onHandleIntent(Intent intent) { + if (intent == null || !ACTION_SCAN_TREE_URI.equals(intent.getAction())) { + return; + } + Uri treeUri = intent.getData(); + if (treeUri == null) { + return; + } + Process.setThreadPriority(Process.THREAD_PRIORITY_LOWEST); + DocumentFile treeFile = DocumentFile.fromTreeUri(this, treeUri); + searchDirectory(treeFile); + } + + private void searchDirectory(DocumentFile documentFileDir) { + DocumentFile[] documentFiles = documentFileDir.listFiles(); + if (documentFiles == null) { + return; + } + for (DocumentFile documentFile : documentFiles) { + if (documentFile.isDirectory()) { + searchDirectory(documentFile); + } else { + if (IndexV1Updater.SIGNED_FILE_NAME.equals(documentFile.getName())) { + registerRepo(documentFile); + } + } + } + } + + /** + * For all files called {@link IndexV1Updater#SIGNED_FILE_NAME} found, check + * the JAR signature and read the fingerprint of the signing certificate. + * The fingerprint is then used to find whether this local repo is a mirror + * of an existing repo, or a totally new repo. In order to verify the + * signatures in the JAR, the whole file needs to be read in first. + * + * @see JarInputStream#JarInputStream(InputStream, boolean) + */ + private void registerRepo(DocumentFile index) { + InputStream inputStream = null; + try { + Log.i(TAG, "FOUND: " + index.getUri()); + inputStream = getContentResolver().openInputStream(index.getUri()); + Log.i(TAG, "repo URL: " + index.getParentFile().getUri()); + registerRepo(this, inputStream, index.getParentFile().getUri()); + } catch (IOException | IndexUpdater.SigningException e) { + e.printStackTrace(); + } finally { + Utils.closeQuietly(inputStream); + } + } + + public static void registerRepo(Context context, InputStream inputStream, Uri repoUri) + throws IOException, IndexUpdater.SigningException { + if (inputStream == null) { + return; + } + File destFile = File.createTempFile("dl-", IndexV1Updater.SIGNED_FILE_NAME, context.getCacheDir()); + FileUtils.copyInputStreamToFile(inputStream, destFile); + JarFile jarFile = new JarFile(destFile, true); + JarEntry indexEntry = (JarEntry) jarFile.getEntry(IndexV1Updater.DATA_FILE_NAME); + IOUtils.readLines(jarFile.getInputStream(indexEntry)); + Certificate certificate = IndexUpdater.getSigningCertFromJar(indexEntry); + Log.i(TAG, "Got certificate: " + certificate); + String fingerprint = Utils.calcFingerprint(certificate); + Log.i(TAG, "Got fingerprint: " + fingerprint); + destFile.delete(); + + Log.i(TAG, "Found a valid, signed index-v1.json"); + for (Repo repo : RepoProvider.Helper.all(context)) { + if (fingerprint.equals(repo.fingerprint)) { + Log.i(TAG, repo.address + " has the SAME fingerprint: " + fingerprint); + } else { + Log.i(TAG, repo.address + " different fingerprint"); + } + } + + Intent intent = new Intent(context, MainActivity.class); + intent.setAction(Intent.ACTION_VIEW); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.setData(repoUri.buildUpon() + .appendQueryParameter("fingerprint", fingerprint) + .build()); + context.startActivity(intent); + // TODO parse repo URL/mirrors/fingerprint using Jackson + // https://stackoverflow.com/questions/24835431/use-jackson-to-stream-parse-an-array-of-json-objects# + // TODO rework IndexUpdater.getSigningCertFromJar to work for here + // TODO check whether fingerprint is already in the database + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/views/main/MainViewController.java b/app/src/full/java/org/fdroid/fdroid/views/main/MainViewController.java index 849a15a4b..447ec21f1 100644 --- a/app/src/full/java/org/fdroid/fdroid/views/main/MainViewController.java +++ b/app/src/full/java/org/fdroid/fdroid/views/main/MainViewController.java @@ -1,17 +1,12 @@ package org.fdroid.fdroid.views.main; -import android.content.Intent; import android.support.annotation.Nullable; import android.support.v4.app.Fragment; import android.support.v7.app.AppCompatActivity; import android.support.v7.widget.RecyclerView; -import android.view.View; -import android.widget.Button; import android.widget.FrameLayout; -import android.widget.TextView; import org.fdroid.fdroid.R; import org.fdroid.fdroid.views.PreferencesFragment; -import org.fdroid.fdroid.views.swap.SwapWorkflowActivity; import org.fdroid.fdroid.views.updates.UpdatesViewBinder; /** @@ -65,29 +60,8 @@ class MainViewController extends RecyclerView.ViewHolder { new CategoriesViewBinder(activity, frame); } - /** - * A splash screen encouraging people to start the swap process. - * The swap process is quite heavy duty in that it fires up Bluetooth and/or WiFi in - * order to scan for peers. As such, it is quite convenient to have a more lightweight view to show - * in the main navigation that doesn't automatically start doing things when the user touches the - * navigation menu in the bottom navigation. - */ public void bindSwapView() { - View swapView = activity.getLayoutInflater().inflate(R.layout.main_tab_swap, frame, true); - - // To allow for whitelabel versions of F-Droid, make sure not to hardcode "F-Droid" into our - // translation here. - TextView subtext = (TextView) swapView.findViewById(R.id.text2); - subtext.setText(activity.getString(R.string.nearby_splash__both_parties_need_fdroid, - activity.getString(R.string.app_name))); - - Button startButton = (Button) swapView.findViewById(R.id.button); - startButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - activity.startActivity(new Intent(activity, SwapWorkflowActivity.class)); - } - }); + new NearbyViewBinder(activity, frame); } /** diff --git a/app/src/full/java/org/fdroid/fdroid/views/main/NearbyViewBinder.java b/app/src/full/java/org/fdroid/fdroid/views/main/NearbyViewBinder.java new file mode 100644 index 000000000..8840b915c --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/views/main/NearbyViewBinder.java @@ -0,0 +1,124 @@ +package org.fdroid.fdroid.views.main; + +import android.Manifest; +import android.app.Activity; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.os.Build; +import android.os.Environment; +import android.support.annotation.RequiresApi; +import android.support.v4.app.ActivityCompat; +import android.support.v4.content.ContextCompat; +import android.util.Log; +import android.view.View; +import android.widget.Button; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; +import org.fdroid.fdroid.R; +import org.fdroid.fdroid.localrepo.TreeUriScannerIntentService; +import org.fdroid.fdroid.views.swap.SwapWorkflowActivity; + +import java.io.File; + +/** + * A splash screen encouraging people to start the swap process. The swap + * process is quite heavy duty in that it fires up Bluetooth and/or WiFi + * in order to scan for peers. As such, it is quite convenient to have a + * more lightweight view to show in the main navigation that doesn't + * automatically start doing things when the user touches the navigation + * menu in the bottom navigation. + *

+ * 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. + *

+ * 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}. + *

+ * For write permissions, {@code android-19} and {@code android-20} devices are + * basically screwed here. {@link Intent#ACTION_OPEN_DOCUMENT_TREE} was added + * in {@code android-21}, and there does not seem to be any other way to get + * write access to the the removable storage. + * + * @see TreeUriScannerIntentService + */ +class NearbyViewBinder { + public static final String TAG = "NearbyViewBinder"; + + static File externalStorage = null; + + NearbyViewBinder(final Activity activity, FrameLayout parent) { + View swapView = activity.getLayoutInflater().inflate(R.layout.main_tab_swap, parent, true); + + TextView subtext = swapView.findViewById(R.id.text2); + subtext.setText(activity.getString(R.string.nearby_splash__both_parties_need_fdroid, + activity.getString(R.string.app_name))); + + ImageView nearbySplash = swapView.findViewById(R.id.image); + + Button startButton = swapView.findViewById(R.id.button); + startButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + activity.startActivity(new Intent(activity, SwapWorkflowActivity.class)); + } + }); + + if (Build.VERSION.SDK_INT >= 21) { + Log.i(TAG, "Environment.isExternalStorageRemovable(activity.getExternalFilesDir(\"\")) " + + Environment.isExternalStorageRemovable(activity.getExternalFilesDir(""))); + File[] dirs = activity.getExternalFilesDirs(""); + if (dirs != null) { + for (File f : dirs) { + if (f != null && Environment.isExternalStorageRemovable(f)) { + // remove Android/data/org.fdroid.fdroid/files to get root + externalStorage = f.getParentFile().getParentFile().getParentFile().getParentFile(); + break; + } + } + } + } else if (Environment.isExternalStorageRemovable() && + (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED_READ_ONLY) + || Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED))) { + Log.i(TAG, "<21 isExternalStorageRemovable MEDIA_MOUNTED"); + externalStorage = Environment.getExternalStorageDirectory(); + } + + if (externalStorage != null) { + nearbySplash.setVisibility(View.GONE); + View readExternalStorage = swapView.findViewById(R.id.readExternalStorage); + readExternalStorage.setVisibility(View.VISIBLE); + Button requestReadExternalStorage = swapView.findViewById(R.id.requestReadExternalStorage); + requestReadExternalStorage.setOnClickListener(new View.OnClickListener() { + @RequiresApi(api = 21) + @Override + public void onClick(View v) { + File storage = externalStorage.getParentFile(); + File[] files = storage.listFiles(); + String msg = ""; + if (files != null) for (File f : files) { + msg += "|" + f.getName(); + } + Toast.makeText(activity, msg, Toast.LENGTH_LONG).show(); + final String writeExternalStorage = Manifest.permission.WRITE_EXTERNAL_STORAGE; + if (Build.VERSION.SDK_INT >= 23 + && !externalStorage.canRead() + && PackageManager.PERMISSION_GRANTED + != ContextCompat.checkSelfPermission(activity, writeExternalStorage)) { + ActivityCompat.requestPermissions(activity, new String[]{writeExternalStorage}, + MainActivity.REQUEST_STORAGE_PERMISSIONS); + } else { + // TODO do something + } + } + }); + + } + } +} diff --git a/app/src/full/res/layout/main_tab_swap.xml b/app/src/full/res/layout/main_tab_swap.xml index 020fb9140..a187febb2 100644 --- a/app/src/full/res/layout/main_tab_swap.xml +++ b/app/src/full/res/layout/main_tab_swap.xml @@ -54,6 +54,37 @@ android:layout_marginStart="48dp" android:layout_marginLeft="48dp" /> + + +