support swapping with removable storage on android-21+
This uses the new Storage Access Framework, which was required for accessing files on the SD Card starting in android-19. But the API was really limited until android-21, and not really complete until android-23 or even android-26. So the levels of usability will vary a lot based on how new the version of Android is.
This commit is contained in:
		
							parent
							
								
									ac1a5e0ad8
								
							
						
					
					
						commit
						1571e28f68
					
				@ -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,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");
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@ -77,6 +77,9 @@
 | 
			
		||||
        <service
 | 
			
		||||
                android:name=".localrepo.CacheSwapAppsService"
 | 
			
		||||
                android:exported="false"/>
 | 
			
		||||
        <service
 | 
			
		||||
                android:name=".localrepo.TreeUriScannerIntentService"
 | 
			
		||||
                android:exported="false"/>
 | 
			
		||||
 | 
			
		||||
        <activity
 | 
			
		||||
                android:name=".views.panic.PanicPreferencesActivity"
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,171 @@
 | 
			
		||||
/*
 | 
			
		||||
 * Copyright (C) 2018 Hans-Christoph Steiner <hans@eds.org>
 | 
			
		||||
 *
 | 
			
		||||
 * This program is free software; you can redistribute it and/or
 | 
			
		||||
 * modify it under the terms of the GNU General Public License
 | 
			
		||||
 * as published by the Free Software Foundation; either version 3
 | 
			
		||||
 * of the License, or (at your option) any later version.
 | 
			
		||||
 *
 | 
			
		||||
 * This program is distributed in the hope that it will be useful,
 | 
			
		||||
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 | 
			
		||||
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 | 
			
		||||
 * GNU General Public License for more details.
 | 
			
		||||
 *
 | 
			
		||||
 * You should have received a copy of the GNU General Public License
 | 
			
		||||
 * along with this program; if not, write to the Free Software
 | 
			
		||||
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
 | 
			
		||||
 * MA 02110-1301, USA.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
package org.fdroid.fdroid.localrepo;
 | 
			
		||||
 | 
			
		||||
import android.annotation.TargetApi;
 | 
			
		||||
import android.app.IntentService;
 | 
			
		||||
import android.content.Context;
 | 
			
		||||
import android.content.Intent;
 | 
			
		||||
import android.net.Uri;
 | 
			
		||||
import android.os.Process;
 | 
			
		||||
import android.support.v4.provider.DocumentFile;
 | 
			
		||||
import android.util.Log;
 | 
			
		||||
import org.apache.commons.io.FileUtils;
 | 
			
		||||
import org.apache.commons.io.IOUtils;
 | 
			
		||||
import org.fdroid.fdroid.IndexV1Updater;
 | 
			
		||||
import org.fdroid.fdroid.IndexUpdater;
 | 
			
		||||
import org.fdroid.fdroid.Utils;
 | 
			
		||||
import org.fdroid.fdroid.data.Repo;
 | 
			
		||||
import org.fdroid.fdroid.data.RepoProvider;
 | 
			
		||||
import org.fdroid.fdroid.views.main.MainActivity;
 | 
			
		||||
 | 
			
		||||
import java.io.File;
 | 
			
		||||
import java.io.IOException;
 | 
			
		||||
import java.io.InputStream;
 | 
			
		||||
import java.security.cert.Certificate;
 | 
			
		||||
import java.util.jar.JarEntry;
 | 
			
		||||
import java.util.jar.JarFile;
 | 
			
		||||
import java.util.jar.JarInputStream;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * An {@link IntentService} subclass for handling asynchronous scanning of a
 | 
			
		||||
 * removable storage device like an SD Card or USB OTG thumb drive using the
 | 
			
		||||
 * Storage Access Framework.  Permission must first be granted by the user
 | 
			
		||||
 * {@link android.content.Intent#ACTION_OPEN_DOCUMENT_TREE} or
 | 
			
		||||
 * {@link android.os.storage.StorageVolume#createAccessIntent(String)}request,
 | 
			
		||||
 * then F-Droid will have permanent access to that{@link Uri}.
 | 
			
		||||
 * <p>
 | 
			
		||||
 * Even though the Storage Access Framework was introduced in
 | 
			
		||||
 * {@link android.os.Build.VERSION_CODES#KITKAT android-19}, this approach is only
 | 
			
		||||
 * workable if {@link android.content.Intent#ACTION_OPEN_DOCUMENT_TREE} is available.
 | 
			
		||||
 * It was added in {@link android.os.Build.VERSION_CODES#LOLLIPOP android-21}.
 | 
			
		||||
 *
 | 
			
		||||
 * @see <a href="https://commonsware.com/blog/2017/11/15/storage-situation-removable-storage.html"> The Storage Situation: Removable Storage </a>
 | 
			
		||||
 * @see <a href="https://developer.android.com/training/articles/scoped-directory-access.html">Using Scoped Directory Access</a>
 | 
			
		||||
 * @see <a href="https://developer.android.com/guide/topics/providers/document-provider.html">Open Files using Storage Access Framework</a>
 | 
			
		||||
 */
 | 
			
		||||
@TargetApi(21)
 | 
			
		||||
public class TreeUriScannerIntentService extends IntentService {
 | 
			
		||||
    public static final String TAG = "TreeUriScannerIntentSer";
 | 
			
		||||
 | 
			
		||||
    private static final String ACTION_SCAN_TREE_URI = "org.fdroid.fdroid.localrepo.action.SCAN_TREE_URI";
 | 
			
		||||
 | 
			
		||||
    public TreeUriScannerIntentService() {
 | 
			
		||||
        super("TreeUriScannerIntentService");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static void scan(Context context, Uri data) {
 | 
			
		||||
        Intent intent = new Intent(context, TreeUriScannerIntentService.class);
 | 
			
		||||
        intent.setAction(ACTION_SCAN_TREE_URI);
 | 
			
		||||
        intent.setData(data);
 | 
			
		||||
        context.startService(intent);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    protected void onHandleIntent(Intent intent) {
 | 
			
		||||
        if (intent == null || !ACTION_SCAN_TREE_URI.equals(intent.getAction())) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        Uri treeUri = intent.getData();
 | 
			
		||||
        if (treeUri == null) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        Process.setThreadPriority(Process.THREAD_PRIORITY_LOWEST);
 | 
			
		||||
        DocumentFile treeFile = DocumentFile.fromTreeUri(this, treeUri);
 | 
			
		||||
        searchDirectory(treeFile);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private void searchDirectory(DocumentFile documentFileDir) {
 | 
			
		||||
        DocumentFile[] documentFiles = documentFileDir.listFiles();
 | 
			
		||||
        if (documentFiles == null) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        for (DocumentFile documentFile : documentFiles) {
 | 
			
		||||
            if (documentFile.isDirectory()) {
 | 
			
		||||
                searchDirectory(documentFile);
 | 
			
		||||
            } else {
 | 
			
		||||
                if (IndexV1Updater.SIGNED_FILE_NAME.equals(documentFile.getName())) {
 | 
			
		||||
                    registerRepo(documentFile);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * For all files called {@link IndexV1Updater#SIGNED_FILE_NAME} found, check
 | 
			
		||||
     * the JAR signature and read the fingerprint of the signing certificate.
 | 
			
		||||
     * The fingerprint is then used to find whether this local repo is a mirror
 | 
			
		||||
     * of an existing repo, or a totally new repo.  In order to verify the
 | 
			
		||||
     * signatures in the JAR, the whole file needs to be read in first.
 | 
			
		||||
     *
 | 
			
		||||
     * @see JarInputStream#JarInputStream(InputStream, boolean)
 | 
			
		||||
     */
 | 
			
		||||
    private void registerRepo(DocumentFile index) {
 | 
			
		||||
        InputStream inputStream = null;
 | 
			
		||||
        try {
 | 
			
		||||
            Log.i(TAG, "FOUND: " + index.getUri());
 | 
			
		||||
            inputStream = getContentResolver().openInputStream(index.getUri());
 | 
			
		||||
            Log.i(TAG, "repo URL: " + index.getParentFile().getUri());
 | 
			
		||||
            registerRepo(this, inputStream, index.getParentFile().getUri());
 | 
			
		||||
        } catch (IOException | IndexUpdater.SigningException e) {
 | 
			
		||||
            e.printStackTrace();
 | 
			
		||||
        } finally {
 | 
			
		||||
            Utils.closeQuietly(inputStream);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static void registerRepo(Context context, InputStream inputStream, Uri repoUri)
 | 
			
		||||
            throws IOException, IndexUpdater.SigningException {
 | 
			
		||||
        if (inputStream == null) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        File destFile = File.createTempFile("dl-", IndexV1Updater.SIGNED_FILE_NAME, context.getCacheDir());
 | 
			
		||||
        FileUtils.copyInputStreamToFile(inputStream, destFile);
 | 
			
		||||
        JarFile jarFile = new JarFile(destFile, true);
 | 
			
		||||
        JarEntry indexEntry = (JarEntry) jarFile.getEntry(IndexV1Updater.DATA_FILE_NAME);
 | 
			
		||||
        IOUtils.readLines(jarFile.getInputStream(indexEntry));
 | 
			
		||||
        Certificate certificate = IndexUpdater.getSigningCertFromJar(indexEntry);
 | 
			
		||||
        Log.i(TAG, "Got certificate: " + certificate);
 | 
			
		||||
        String fingerprint = Utils.calcFingerprint(certificate);
 | 
			
		||||
        Log.i(TAG, "Got fingerprint: " + fingerprint);
 | 
			
		||||
        destFile.delete();
 | 
			
		||||
 | 
			
		||||
        Log.i(TAG, "Found a valid, signed index-v1.json");
 | 
			
		||||
        for (Repo repo : RepoProvider.Helper.all(context)) {
 | 
			
		||||
            if (fingerprint.equals(repo.fingerprint)) {
 | 
			
		||||
                Log.i(TAG, repo.address + " has the SAME fingerprint: " + fingerprint);
 | 
			
		||||
            } else {
 | 
			
		||||
                Log.i(TAG, repo.address + " different fingerprint");
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        Intent intent = new Intent(context, MainActivity.class);
 | 
			
		||||
        intent.setAction(Intent.ACTION_VIEW);
 | 
			
		||||
        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
 | 
			
		||||
        intent.setData(repoUri.buildUpon()
 | 
			
		||||
                .appendQueryParameter("fingerprint", fingerprint)
 | 
			
		||||
                .build());
 | 
			
		||||
        context.startActivity(intent);
 | 
			
		||||
        // TODO parse repo URL/mirrors/fingerprint using Jackson
 | 
			
		||||
        //      https://stackoverflow.com/questions/24835431/use-jackson-to-stream-parse-an-array-of-json-objects#
 | 
			
		||||
        // TODO rework IndexUpdater.getSigningCertFromJar to work for here
 | 
			
		||||
        // TODO check whether fingerprint is already in the database
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@ -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,124 @@
 | 
			
		||||
package org.fdroid.fdroid.views.main;
 | 
			
		||||
 | 
			
		||||
import android.Manifest;
 | 
			
		||||
import android.app.Activity;
 | 
			
		||||
import android.content.Intent;
 | 
			
		||||
import android.content.pm.PackageManager;
 | 
			
		||||
import android.os.Build;
 | 
			
		||||
import android.os.Environment;
 | 
			
		||||
import android.support.annotation.RequiresApi;
 | 
			
		||||
import android.support.v4.app.ActivityCompat;
 | 
			
		||||
import android.support.v4.content.ContextCompat;
 | 
			
		||||
import android.util.Log;
 | 
			
		||||
import android.view.View;
 | 
			
		||||
import android.widget.Button;
 | 
			
		||||
import android.widget.FrameLayout;
 | 
			
		||||
import android.widget.ImageView;
 | 
			
		||||
import android.widget.TextView;
 | 
			
		||||
import android.widget.Toast;
 | 
			
		||||
import org.fdroid.fdroid.R;
 | 
			
		||||
import org.fdroid.fdroid.localrepo.TreeUriScannerIntentService;
 | 
			
		||||
import org.fdroid.fdroid.views.swap.SwapWorkflowActivity;
 | 
			
		||||
 | 
			
		||||
import java.io.File;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * A splash screen encouraging people to start the swap process. The swap
 | 
			
		||||
 * process is quite heavy duty in that it fires up Bluetooth and/or WiFi
 | 
			
		||||
 * in  order to scan for peers. As such, it is quite convenient to have a
 | 
			
		||||
 * more lightweight view to show in the main navigation that doesn't
 | 
			
		||||
 * automatically start doing things when the user touches the navigation
 | 
			
		||||
 * menu in the bottom navigation.
 | 
			
		||||
 * <p>
 | 
			
		||||
 * Lots of pieces of the nearby/swap functionality require that the user grant
 | 
			
		||||
 * F-Droid permissions at runtime on {@code android-23} and higher. On devices
 | 
			
		||||
 * that have a removable SD Card that is currently mounted, this will request
 | 
			
		||||
 * permission to read it, so that F-Droid can look for repos on the SD Card.
 | 
			
		||||
 * <p>
 | 
			
		||||
 * Once {@link Manifest.permission#READ_EXTERNAL_STORAGE} or
 | 
			
		||||
 * {@link Manifest.permission#WRITE_EXTERNAL_STORAGE} is granted for F-Droid,
 | 
			
		||||
 * then it can read any file on an SD Card and no more prompts are needed. For
 | 
			
		||||
 * USB OTG drives, the only way to get read permissions is to prompt the user
 | 
			
		||||
 * via {@link Intent#ACTION_OPEN_DOCUMENT_TREE}.
 | 
			
		||||
 * <p>
 | 
			
		||||
 * For write permissions, {@code android-19} and {@code android-20} devices are
 | 
			
		||||
 * basically screwed here.  {@link Intent#ACTION_OPEN_DOCUMENT_TREE} was added
 | 
			
		||||
 * in {@code android-21}, and there does not seem to be any other way to get
 | 
			
		||||
 * write access to the the removable storage.
 | 
			
		||||
 *
 | 
			
		||||
 * @see TreeUriScannerIntentService
 | 
			
		||||
 */
 | 
			
		||||
class NearbyViewBinder {
 | 
			
		||||
    public static final String TAG = "NearbyViewBinder";
 | 
			
		||||
 | 
			
		||||
    static File externalStorage = null;
 | 
			
		||||
 | 
			
		||||
    NearbyViewBinder(final Activity activity, FrameLayout parent) {
 | 
			
		||||
        View swapView = activity.getLayoutInflater().inflate(R.layout.main_tab_swap, parent, true);
 | 
			
		||||
 | 
			
		||||
        TextView subtext = swapView.findViewById(R.id.text2);
 | 
			
		||||
        subtext.setText(activity.getString(R.string.nearby_splash__both_parties_need_fdroid,
 | 
			
		||||
                activity.getString(R.string.app_name)));
 | 
			
		||||
 | 
			
		||||
        ImageView nearbySplash = swapView.findViewById(R.id.image);
 | 
			
		||||
 | 
			
		||||
        Button startButton = swapView.findViewById(R.id.button);
 | 
			
		||||
        startButton.setOnClickListener(new View.OnClickListener() {
 | 
			
		||||
            @Override
 | 
			
		||||
            public void onClick(View v) {
 | 
			
		||||
                activity.startActivity(new Intent(activity, SwapWorkflowActivity.class));
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        if (Build.VERSION.SDK_INT >= 21) {
 | 
			
		||||
            Log.i(TAG, "Environment.isExternalStorageRemovable(activity.getExternalFilesDir(\"\")) " +
 | 
			
		||||
                    Environment.isExternalStorageRemovable(activity.getExternalFilesDir("")));
 | 
			
		||||
            File[] dirs = activity.getExternalFilesDirs("");
 | 
			
		||||
            if (dirs != null) {
 | 
			
		||||
                for (File f : dirs) {
 | 
			
		||||
                    if (f != null && Environment.isExternalStorageRemovable(f)) {
 | 
			
		||||
                        // remove Android/data/org.fdroid.fdroid/files to get root
 | 
			
		||||
                        externalStorage = f.getParentFile().getParentFile().getParentFile().getParentFile();
 | 
			
		||||
                        break;
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        } else if (Environment.isExternalStorageRemovable() &&
 | 
			
		||||
                (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED_READ_ONLY)
 | 
			
		||||
                        || Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED))) {
 | 
			
		||||
            Log.i(TAG, "<21 isExternalStorageRemovable MEDIA_MOUNTED");
 | 
			
		||||
            externalStorage = Environment.getExternalStorageDirectory();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (externalStorage != null) {
 | 
			
		||||
            nearbySplash.setVisibility(View.GONE);
 | 
			
		||||
            View readExternalStorage = swapView.findViewById(R.id.readExternalStorage);
 | 
			
		||||
            readExternalStorage.setVisibility(View.VISIBLE);
 | 
			
		||||
            Button requestReadExternalStorage = swapView.findViewById(R.id.requestReadExternalStorage);
 | 
			
		||||
            requestReadExternalStorage.setOnClickListener(new View.OnClickListener() {
 | 
			
		||||
                @RequiresApi(api = 21)
 | 
			
		||||
                @Override
 | 
			
		||||
                public void onClick(View v) {
 | 
			
		||||
                    File storage = externalStorage.getParentFile();
 | 
			
		||||
                    File[] files = storage.listFiles();
 | 
			
		||||
                    String msg = "";
 | 
			
		||||
                    if (files != null) for (File f : files) {
 | 
			
		||||
                        msg += "|" + f.getName();
 | 
			
		||||
                    }
 | 
			
		||||
                    Toast.makeText(activity, msg, Toast.LENGTH_LONG).show();
 | 
			
		||||
                    final String writeExternalStorage = Manifest.permission.WRITE_EXTERNAL_STORAGE;
 | 
			
		||||
                    if (Build.VERSION.SDK_INT >= 23
 | 
			
		||||
                            && !externalStorage.canRead()
 | 
			
		||||
                            && PackageManager.PERMISSION_GRANTED
 | 
			
		||||
                            != ContextCompat.checkSelfPermission(activity, writeExternalStorage)) {
 | 
			
		||||
                        ActivityCompat.requestPermissions(activity, new String[]{writeExternalStorage},
 | 
			
		||||
                                MainActivity.REQUEST_STORAGE_PERMISSIONS);
 | 
			
		||||
                    } else {
 | 
			
		||||
                        // TODO do something
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@ -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"
 | 
			
		||||
 | 
			
		||||
@ -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() {
 | 
			
		||||
 | 
			
		||||
@ -89,7 +89,7 @@ import java.util.jar.JarFile;
 | 
			
		||||
public class IndexV1Updater extends IndexUpdater {
 | 
			
		||||
    public static final String TAG = "IndexV1Updater";
 | 
			
		||||
 | 
			
		||||
    private static final String SIGNED_FILE_NAME = "index-v1.jar";
 | 
			
		||||
    public static final String SIGNED_FILE_NAME = "index-v1.jar";
 | 
			
		||||
    public static final String DATA_FILE_NAME = "index-v1.json";
 | 
			
		||||
 | 
			
		||||
    public IndexV1Updater(@NonNull Context context, @NonNull Repo repo) {
 | 
			
		||||
@ -97,8 +97,15 @@ public class IndexV1Updater extends IndexUpdater {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    /**
 | 
			
		||||
     * Storage Access Framework URLs have a crazy encoded path within the URL path.
 | 
			
		||||
     */
 | 
			
		||||
    protected String getIndexUrl(@NonNull Repo repo) {
 | 
			
		||||
        return Uri.parse(repo.address).buildUpon().appendPath(SIGNED_FILE_NAME).build().toString();
 | 
			
		||||
        if (repo.address.startsWith("content://")) {
 | 
			
		||||
            return repo.address + "%2F" + SIGNED_FILE_NAME;
 | 
			
		||||
        } else {
 | 
			
		||||
            return Uri.parse(repo.address).buildUpon().appendPath(SIGNED_FILE_NAME).build().toString();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
 | 
			
		||||
@ -82,7 +82,7 @@ public class NewRepoConfig {
 | 
			
		||||
        host = host.toLowerCase(Locale.ENGLISH);
 | 
			
		||||
 | 
			
		||||
        if (uri.getPath() == null
 | 
			
		||||
                || !Arrays.asList("https", "http", "fdroidrepos", "fdroidrepo").contains(scheme)) {
 | 
			
		||||
                || !Arrays.asList("https", "http", "fdroidrepos", "fdroidrepo", "content").contains(scheme)) {
 | 
			
		||||
            isValidRepo = false;
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@ -31,6 +31,8 @@ public class DownloaderFactory {
 | 
			
		||||
        String scheme = uri.getScheme();
 | 
			
		||||
        if ("bluetooth".equals(scheme)) {
 | 
			
		||||
            downloader = new BluetoothDownloader(uri, destFile);
 | 
			
		||||
        } else if ("content".equals(scheme)) {
 | 
			
		||||
            downloader = new TreeUriDownloader(uri, destFile);
 | 
			
		||||
        } else {
 | 
			
		||||
            final String[] projection = {Schema.RepoTable.Cols.USERNAME, Schema.RepoTable.Cols.PASSWORD};
 | 
			
		||||
            Repo repo = RepoProvider.Helper.findByUrl(context, uri, projection);
 | 
			
		||||
 | 
			
		||||
@ -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);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										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;
 | 
			
		||||
@ -555,6 +556,11 @@ public class ManageReposActivity extends AppCompatActivity
 | 
			
		||||
                        return originalAddress;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    if (originalAddress.startsWith(ContentResolver.SCHEME_CONTENT)) {
 | 
			
		||||
                        // TODO check whether there is read access
 | 
			
		||||
                        return originalAddress;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    final String[] pathsToCheck = {"", "fdroid/repo", "repo"};
 | 
			
		||||
                    for (final String path : pathsToCheck) {
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -41,7 +41,6 @@ import android.widget.Toast;
 | 
			
		||||
import com.ashokvarma.bottomnavigation.BottomNavigationBar;
 | 
			
		||||
import com.ashokvarma.bottomnavigation.BottomNavigationItem;
 | 
			
		||||
import com.ashokvarma.bottomnavigation.TextBadgeItem;
 | 
			
		||||
import org.fdroid.fdroid.views.AppDetailsActivity;
 | 
			
		||||
import org.fdroid.fdroid.AppUpdateStatusManager;
 | 
			
		||||
import org.fdroid.fdroid.AppUpdateStatusManager.AppUpdateStatus;
 | 
			
		||||
import org.fdroid.fdroid.BuildConfig;
 | 
			
		||||
@ -52,6 +51,7 @@ import org.fdroid.fdroid.R;
 | 
			
		||||
import org.fdroid.fdroid.UpdateService;
 | 
			
		||||
import org.fdroid.fdroid.Utils;
 | 
			
		||||
import org.fdroid.fdroid.data.NewRepoConfig;
 | 
			
		||||
import org.fdroid.fdroid.views.AppDetailsActivity;
 | 
			
		||||
import org.fdroid.fdroid.views.ManageReposActivity;
 | 
			
		||||
import org.fdroid.fdroid.views.apps.AppListActivity;
 | 
			
		||||
import org.fdroid.fdroid.views.swap.SwapWorkflowActivity;
 | 
			
		||||
@ -77,6 +77,8 @@ public class MainActivity extends AppCompatActivity implements BottomNavigationB
 | 
			
		||||
    public static final String EXTRA_VIEW_UPDATES = "org.fdroid.fdroid.views.main.MainActivity.VIEW_UPDATES";
 | 
			
		||||
    public static final String EXTRA_VIEW_SETTINGS = "org.fdroid.fdroid.views.main.MainActivity.VIEW_SETTINGS";
 | 
			
		||||
 | 
			
		||||
    static final int REQUEST_STORAGE_PERMISSIONS = 0xB004;
 | 
			
		||||
 | 
			
		||||
    private static final String ADD_REPO_INTENT_HANDLED = "addRepoIntentHandled";
 | 
			
		||||
 | 
			
		||||
    private static final String ACTION_ADD_REPO = "org.fdroid.fdroid.MainActivity.ACTION_ADD_REPO";
 | 
			
		||||
 | 
			
		||||
@ -406,6 +406,8 @@ This often occurs with apps installed via Google Play or other sources, if they
 | 
			
		||||
    <string name="nearby_splash__download_apps_from_people_nearby">No Internet? Get apps from people near you!</string>
 | 
			
		||||
    <string name="nearby_splash__find_people_button">Find people nearby</string>
 | 
			
		||||
    <string name="nearby_splash__both_parties_need_fdroid">Both parties need %1$s to use nearby.</string>
 | 
			
		||||
    <string name="nearby_splash__read_external_storage">SD Cards can be used to swap!</string>
 | 
			
		||||
    <string name="nearby_splash__request_permission">Try it</string>
 | 
			
		||||
 | 
			
		||||
    <string name="swap_nfc_title">Touch to swap</string>
 | 
			
		||||
    <string name="swap_nfc_description">If your friend has F-Droid and NFC turned on touch your devices together.
 | 
			
		||||
@ -452,6 +454,10 @@ This often occurs with apps installed via Google Play or other sources, if they
 | 
			
		||||
    <string name="swap_connection_misc_error">Error occurred while connecting to device, can\'t swap with it!</string>
 | 
			
		||||
    <string name="swap_not_enabled">Swapping not enabled</string>
 | 
			
		||||
    <string name="swap_not_enabled_description">Before swapping, your device must be made visible.</string>
 | 
			
		||||
    <string name="swap_toast_using_path">Using %1$s</string>
 | 
			
		||||
    <string name="swap_toast_not_removable_storage">That choice did not match any removeable storage devices, try
 | 
			
		||||
        again!</string>
 | 
			
		||||
    <string name="swap_toast_find_removeable_storage">Choose your removeable SD Card or USB</string>
 | 
			
		||||
    <string name="swap_toast_invalid_url">Invalid URL for swapping: %1$s</string>
 | 
			
		||||
    <string name="swap_toast_hotspot_enabled">Wi-Fi Hotspot enabled</string>
 | 
			
		||||
    <string name="swap_toast_could_not_enable_hotspot">Could not enable Wi-Fi Hotspot!</string>
 | 
			
		||||
 | 
			
		||||
@ -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