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:
Hans-Christoph Steiner 2018-12-20 23:25:01 +00:00
commit 8c5263c5c5
30 changed files with 1118 additions and 127 deletions

View File

@ -1,5 +1,6 @@
package org.fdroid.fdroid; package org.fdroid.fdroid;
import android.Manifest;
import android.app.Instrumentation; import android.app.Instrumentation;
import android.os.Build; import android.os.Build;
import android.support.test.InstrumentationRegistry; 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.espresso.ViewInteraction;
import android.support.test.filters.LargeTest; import android.support.test.filters.LargeTest;
import android.support.test.rule.ActivityTestRule; import android.support.test.rule.ActivityTestRule;
import android.support.test.rule.GrantPermissionRule;
import android.support.test.runner.AndroidJUnit4; import android.support.test.runner.AndroidJUnit4;
import android.support.test.uiautomator.UiDevice; import android.support.test.uiautomator.UiDevice;
import android.support.test.uiautomator.UiObject; import android.support.test.uiautomator.UiObject;
@ -120,6 +122,14 @@ public class MainActivityEspressoTest {
public ActivityTestRule<MainActivity> activityTestRule = public ActivityTestRule<MainActivity> activityTestRule =
new ActivityTestRule<>(MainActivity.class); 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 @Test
public void bottomNavFlavorCheck() { public void bottomNavFlavorCheck() {
onView(withText(R.string.updates)).check(matches(isDisplayed())); onView(withText(R.string.updates)).check(matches(isDisplayed()));

View File

@ -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) {
}
}

View File

@ -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");
}
}

View File

@ -38,11 +38,14 @@
<uses-permission android:name="android.permission.BLUETOOTH"/> <uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <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_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_SETTINGS"/> <uses-permission android:name="android.permission.WRITE_SETTINGS"/>
<uses-permission android:name="android.permission.NFC"/> <uses-permission android:name="android.permission.NFC"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/> <uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission-sdk-23 android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<application> <application>
<activity <activity
@ -77,6 +80,12 @@
<service <service
android:name=".localrepo.CacheSwapAppsService" android:name=".localrepo.CacheSwapAppsService"
android:exported="false"/> android:exported="false"/>
<service
android:name=".localrepo.TreeUriScannerIntentService"
android:exported="false"/>
<service
android:name=".localrepo.SDCardScannerService"
android:exported="false"/>
<activity <activity
android:name=".views.panic.PanicPreferencesActivity" android:name=".views.panic.PanicPreferencesActivity"

View File

@ -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);
}
}
}

View File

@ -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
}
}

View File

@ -1,17 +1,12 @@
package org.fdroid.fdroid.views.main; package org.fdroid.fdroid.views.main;
import android.content.Intent;
import android.support.annotation.Nullable; import android.support.annotation.Nullable;
import android.support.v4.app.Fragment; import android.support.v4.app.Fragment;
import android.support.v7.app.AppCompatActivity; import android.support.v7.app.AppCompatActivity;
import android.support.v7.widget.RecyclerView; import android.support.v7.widget.RecyclerView;
import android.view.View;
import android.widget.Button;
import android.widget.FrameLayout; import android.widget.FrameLayout;
import android.widget.TextView;
import org.fdroid.fdroid.R; import org.fdroid.fdroid.R;
import org.fdroid.fdroid.views.PreferencesFragment; import org.fdroid.fdroid.views.PreferencesFragment;
import org.fdroid.fdroid.views.swap.SwapWorkflowActivity;
import org.fdroid.fdroid.views.updates.UpdatesViewBinder; import org.fdroid.fdroid.views.updates.UpdatesViewBinder;
/** /**
@ -65,29 +60,8 @@ class MainViewController extends RecyclerView.ViewHolder {
new CategoriesViewBinder(activity, frame); 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() { public void bindSwapView() {
View swapView = activity.getLayoutInflater().inflate(R.layout.main_tab_swap, frame, true); new NearbyViewBinder(activity, frame);
// 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));
}
});
} }
/** /**

View File

@ -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);
}
}
});
}
}
}

View File

@ -54,6 +54,37 @@
android:layout_marginStart="48dp" android:layout_marginStart="48dp"
android:layout_marginLeft="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 <ImageView
android:id="@+id/image" android:id="@+id/image"
android:layout_width="0dp" android:layout_width="0dp"

View File

@ -271,6 +271,9 @@
android:name=".data.InstalledAppProviderService" android:name=".data.InstalledAppProviderService"
android:permission="android.permission.BIND_JOB_SERVICE" android:permission="android.permission.BIND_JOB_SERVICE"
android:exported="false"/> android:exported="false"/>
<service
android:name=".AddRepoIntentService"
android:exported="false"/>
<!-- Warning: Please add all new services to HidingManager --> <!-- Warning: Please add all new services to HidingManager -->

View 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();
}
}

View File

@ -69,6 +69,7 @@ import org.fdroid.fdroid.data.Repo;
import org.fdroid.fdroid.data.RepoProvider; import org.fdroid.fdroid.data.RepoProvider;
import org.fdroid.fdroid.installer.ApkFileProvider; import org.fdroid.fdroid.installer.ApkFileProvider;
import org.fdroid.fdroid.installer.InstallHistoryService; import org.fdroid.fdroid.installer.InstallHistoryService;
import org.fdroid.fdroid.localrepo.SDCardScannerService;
import org.fdroid.fdroid.net.ConnectivityMonitorService; import org.fdroid.fdroid.net.ConnectivityMonitorService;
import org.fdroid.fdroid.net.HttpDownloader; import org.fdroid.fdroid.net.HttpDownloader;
import org.fdroid.fdroid.net.ImageLoaderForUIL; import org.fdroid.fdroid.net.ImageLoaderForUIL;
@ -502,6 +503,8 @@ public class FDroidApp extends Application {
} else { } else {
atStartTime.edit().remove(queryStringKey).apply(); atStartTime.edit().remove(queryStringKey).apply();
} }
SDCardScannerService.scan(this);
} }
/** /**

View File

@ -43,6 +43,7 @@ import org.fdroid.fdroid.installer.InstallManagerService;
import org.fdroid.fdroid.installer.InstallerService; import org.fdroid.fdroid.installer.InstallerService;
import org.fdroid.fdroid.net.Downloader; import org.fdroid.fdroid.net.Downloader;
import org.fdroid.fdroid.net.DownloaderFactory; import org.fdroid.fdroid.net.DownloaderFactory;
import org.fdroid.fdroid.net.TreeUriDownloader;
import org.xml.sax.InputSource; import org.xml.sax.InputSource;
import org.xml.sax.SAXException; import org.xml.sax.SAXException;
import org.xml.sax.XMLReader; import org.xml.sax.XMLReader;
@ -114,7 +115,11 @@ public class IndexUpdater {
} }
protected String getIndexUrl(@NonNull Repo repo) { 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() { public boolean hasChanged() {

View File

@ -62,8 +62,10 @@ import java.net.SocketTimeoutException;
import java.net.UnknownHostException; import java.net.UnknownHostException;
import java.security.cert.X509Certificate; import java.security.cert.X509Certificate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.Date; import java.util.Date;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.jar.JarEntry; import java.util.jar.JarEntry;
@ -87,7 +89,7 @@ import java.util.jar.JarFile;
public class IndexV1Updater extends IndexUpdater { public class IndexV1Updater extends IndexUpdater {
public static final String TAG = "IndexV1Updater"; 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 static final String DATA_FILE_NAME = "index-v1.json";
public IndexV1Updater(@NonNull Context context, @NonNull Repo repo) { public IndexV1Updater(@NonNull Context context, @NonNull Repo repo) {
@ -95,9 +97,16 @@ public class IndexV1Updater extends IndexUpdater {
} }
@Override @Override
/**
* Storage Access Framework URLs have a crazy encoded path within the URL path.
*/
protected String getIndexUrl(@NonNull Repo repo) { protected String getIndexUrl(@NonNull Repo repo) {
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(); return Uri.parse(repo.address).buildUpon().appendPath(SIGNED_FILE_NAME).build().toString();
} }
}
/** /**
* @return whether this successfully found an index of this version * @return whether this successfully found an index of this version
@ -284,7 +293,14 @@ public class IndexV1Updater extends IndexUpdater {
repo.name = getStringRepoValue(repoMap, "name"); repo.name = getStringRepoValue(repoMap, "name");
repo.icon = getStringRepoValue(repoMap, "icon"); repo.icon = getStringRepoValue(repoMap, "icon");
repo.description = getStringRepoValue(repoMap, "description"); 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 // below are optional, can be default value
repo.maxage = getIntRepoValue(repoMap, "maxage"); repo.maxage = getIntRepoValue(repoMap, "maxage");
repo.version = getIntRepoValue(repoMap, "version"); repo.version = getIntRepoValue(repoMap, "version");
@ -372,13 +388,12 @@ public class IndexV1Updater extends IndexUpdater {
} }
@SuppressWarnings("unchecked") @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); Object value = repoMap.get(key);
if (value != null && value instanceof ArrayList) { if (value != null && value instanceof ArrayList) {
ArrayList<String> list = (ArrayList<String>) value; return (List<String>) value;
return list.toArray(new String[list.size()]);
} }
return null; return Collections.emptyList();
} }
private HashMap<String, Object> parseRepo(ObjectMapper mapper, JsonParser parser) throws IOException { private HashMap<String, Object> parseRepo(ObjectMapper mapper, JsonParser parser) throws IOException {

View File

@ -92,6 +92,7 @@ public final class Preferences implements SharedPreferences.OnSharedPreferenceCh
public static final String PREF_PRIVILEGED_INSTALLER = "privilegedInstaller"; public static final String PREF_PRIVILEGED_INSTALLER = "privilegedInstaller";
public static final String PREF_LOCAL_REPO_NAME = "localRepoName"; public static final String PREF_LOCAL_REPO_NAME = "localRepoName";
public static final String PREF_LOCAL_REPO_HTTPS = "localRepoHttps"; 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_LANGUAGE = "language";
public static final String PREF_USE_TOR = "useTor"; public static final String PREF_USE_TOR = "useTor";
public static final String PREF_ENABLE_PROXY = "enableProxy"; 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()); return preferences.getString(PREF_LOCAL_REPO_NAME, getDefaultLocalRepoName());
} }
public boolean isScanRemovableStorageEnabled() {
return preferences.getBoolean(PREF_SCAN_REMOVABLE_STORAGE, true);
}
public boolean isUpdateNotificationEnabled() { public boolean isUpdateNotificationEnabled() {
return preferences.getBoolean(PREF_UPDATE_NOTIFICATION_ENABLED, true); return preferences.getBoolean(PREF_UPDATE_NOTIFICATION_ENABLED, true);
} }

View File

@ -26,6 +26,7 @@ import android.app.job.JobInfo;
import android.app.job.JobScheduler; import android.app.job.JobScheduler;
import android.content.BroadcastReceiver; import android.content.BroadcastReceiver;
import android.content.ComponentName; import android.content.ComponentName;
import android.content.ContentResolver;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.IntentFilter; 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 @Override
protected void onHandleWork(@NonNull Intent intent) { protected void onHandleWork(@NonNull Intent intent) {
Process.setThreadPriority(Process.THREAD_PRIORITY_LOWEST); Process.setThreadPriority(Process.THREAD_PRIORITY_LOWEST);
@ -417,16 +425,38 @@ public class UpdateService extends JobIntentService {
try { try {
final Preferences fdroidPrefs = Preferences.get(); 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... // See if it's time to actually do anything yet...
int netState = ConnectivityMonitorService.getNetworkState(this); int netState = ConnectivityMonitorService.getNetworkState(this);
if (address != null && address.startsWith(BluetoothDownloader.SCHEME)) { if (isLocalRepoAddress(address)) {
Utils.debugLog(TAG, "skipping internet check, this is bluetooth"); Utils.debugLog(TAG, "skipping internet check, this is local: " + address);
} else if (netState == ConnectivityMonitorService.FLAG_NET_UNAVAILABLE) { } else if (netState == ConnectivityMonitorService.FLAG_NET_UNAVAILABLE) {
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"); Utils.debugLog(TAG, "No internet, cannot update");
if (manualUpdate) { if (manualUpdate) {
sendNoInternetToast(); sendNoInternetToast();
} }
return; return;
}
} else if ((manualUpdate || forcedUpdate) && fdroidPrefs.isOnDemandDownloadAllowed()) { } else if ((manualUpdate || forcedUpdate) && fdroidPrefs.isOnDemandDownloadAllowed()) {
Utils.debugLog(TAG, "manually requested or forced update"); Utils.debugLog(TAG, "manually requested or forced update");
if (forcedUpdate) { if (forcedUpdate) {
@ -442,10 +472,6 @@ public class UpdateService extends JobIntentService {
LocalBroadcastManager.getInstance(this).registerReceiver(updateStatusReceiver, LocalBroadcastManager.getInstance(this).registerReceiver(updateStatusReceiver,
new IntentFilter(LOCAL_ACTION_STATUS)); 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 unchangedRepos = 0;
int updatedRepos = 0; int updatedRepos = 0;
int errorRepos = 0; int errorRepos = 0;
@ -482,7 +508,8 @@ public class UpdateService extends JobIntentService {
} catch (IndexUpdater.UpdateException e) { } catch (IndexUpdater.UpdateException e) {
errorRepos++; errorRepos++;
repoErrors.add(e.getMessage()); 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 // now that downloading the index is done, start downloading updates

View File

@ -1394,6 +1394,12 @@ public class DBHelper extends SQLiteOpenHelper {
return exists; 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, private void insertRepo(SQLiteDatabase db, String name, String address,
String description, String version, String enabled, String description, String version, String enabled,
String priority, String pushRequests, String pubKey) { 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.LAST_ETAG, (String) null);
values.put(RepoTable.Cols.TIMESTAMP, 0); values.put(RepoTable.Cols.TIMESTAMP, 0);
String[] initializeMirrors = {address};
values.put(Schema.RepoTable.Cols.MIRRORS, Utils.serializeCommaSeparatedString(initializeMirrors));
switch (pushRequests) { switch (pushRequests) {
case "ignore": case "ignore":
values.put(RepoTable.Cols.PUSH_REQUESTS, Repo.PUSH_REQUEST_IGNORE); values.put(RepoTable.Cols.PUSH_REQUESTS, Repo.PUSH_REQUEST_IGNORE);

View File

@ -4,6 +4,7 @@ import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.net.Uri; import android.net.Uri;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.Log;
import org.fdroid.fdroid.R; import org.fdroid.fdroid.R;
import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.localrepo.peers.WifiPeer; import org.fdroid.fdroid.localrepo.peers.WifiPeer;
@ -53,8 +54,9 @@ public class NewRepoConfig {
String scheme = uri.getScheme(); String scheme = uri.getScheme();
host = uri.getHost(); host = uri.getHost();
port = uri.getPort(); 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); errorMessage = String.format(context.getString(R.string.malformed_repo_uri), uri);
Log.i(TAG, errorMessage);
isValidRepo = false; isValidRepo = false;
return; return;
} }
@ -82,7 +84,7 @@ public class NewRepoConfig {
host = host.toLowerCase(Locale.ENGLISH); host = host.toLowerCase(Locale.ENGLISH);
if (uri.getPath() == null if (uri.getPath() == null
|| !Arrays.asList("https", "http", "fdroidrepos", "fdroidrepo").contains(scheme)) { || !Arrays.asList("https", "http", "fdroidrepos", "fdroidrepo", "content", "file").contains(scheme)) {
isValidRepo = false; isValidRepo = false;
return; return;
} }

View File

@ -37,6 +37,7 @@ import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.Date; import java.util.Date;
import java.util.HashSet;
import java.util.List; 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() { public boolean hasMirrors() {
return (mirrors != null && mirrors.length > 1) return (mirrors != null && mirrors.length > 1)
|| (userMirrors != null && userMirrors.length > 0); || (userMirrors != null && userMirrors.length > 0);
} }
/**
* @return {@link List} of valid URLs to reach this repo, including the canonical URL
*/
public List<String> getMirrorList() { public List<String> getMirrorList() {
final ArrayList<String> allMirrors = new ArrayList<String>(); final HashSet<String> allMirrors = new HashSet<>();
if (userMirrors != null) { if (userMirrors != null) {
allMirrors.addAll(Arrays.asList(userMirrors)); allMirrors.addAll(Arrays.asList(userMirrors));
} }
if (mirrors != null) { if (mirrors != null) {
allMirrors.addAll(Arrays.asList(mirrors)); 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() { public int getMirrorCount() {
int count = 0; int count = 0;
for (String m : getMirrorList()) { for (String m : getMirrorList()) {
if (!m.equals(address)) {
if (FDroidApp.isUsingTor()) { if (FDroidApp.isUsingTor()) {
count++; count++;
} else { } else if (!m.contains(".onion")) {
if (!m.contains(".onion")) {
count++; count++;
} }
} }
}
}
return 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) { public String getMirror(String lastWorkingMirror) {
if (TextUtils.isEmpty(lastWorkingMirror)) { if (TextUtils.isEmpty(lastWorkingMirror)) {
lastWorkingMirror = address; lastWorkingMirror = address;
@ -382,7 +399,7 @@ public class Repo extends ValueObject {
if (shuffledMirrors.size() > 1) { if (shuffledMirrors.size() > 1) {
for (String m : shuffledMirrors) { for (String m : shuffledMirrors) {
// Return a non default, and not last used mirror // Return a non default, and not last used mirror
if (!m.equals(address) && !m.equals(lastWorkingMirror)) { if (!m.equals(lastWorkingMirror)) {
if (FDroidApp.isUsingTor()) { if (FDroidApp.isUsingTor()) {
return m; return m;
} else { } else {

View File

@ -31,6 +31,10 @@ public class DownloaderFactory {
String scheme = uri.getScheme(); String scheme = uri.getScheme();
if ("bluetooth".equals(scheme)) { if ("bluetooth".equals(scheme)) {
downloader = new BluetoothDownloader(uri, destFile); 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 { } else {
final String[] projection = {Schema.RepoTable.Cols.USERNAME, Schema.RepoTable.Cols.PASSWORD}; final String[] projection = {Schema.RepoTable.Cols.USERNAME, Schema.RepoTable.Cols.PASSWORD};
Repo repo = RepoProvider.Helper.findByUrl(context, uri, projection); Repo repo = RepoProvider.Helper.findByUrl(context, uri, projection);

View File

@ -1,6 +1,7 @@
package org.fdroid.fdroid.net; package org.fdroid.fdroid.net;
import android.content.Context; import android.content.Context;
import android.os.Build;
import com.nostra13.universalimageloader.core.download.BaseImageDownloader; import com.nostra13.universalimageloader.core.download.BaseImageDownloader;
import java.io.IOException; import java.io.IOException;
@ -28,6 +29,10 @@ public class ImageLoaderForUIL implements com.nostra13.universalimageloader.core
case HTTP: case HTTP:
case HTTPS: case HTTPS:
return DownloaderFactory.create(context, imageUri).getInputStream(); 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); return new BaseImageDownloader(context).getStream(imageUri, extra);
} }

View File

@ -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);
}
}

View 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() {
}
}

View File

@ -22,6 +22,7 @@ package org.fdroid.fdroid.views;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.content.ClipData; import android.content.ClipData;
import android.content.ClipboardManager; import android.content.ClipboardManager;
import android.content.ContentResolver;
import android.content.ContentValues; import android.content.ContentValues;
import android.content.Context; import android.content.Context;
import android.content.DialogInterface; import android.content.DialogInterface;
@ -32,9 +33,12 @@ import android.net.Uri;
import android.net.wifi.WifiInfo; import android.net.wifi.WifiInfo;
import android.net.wifi.WifiManager; import android.net.wifi.WifiManager;
import android.os.AsyncTask; import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.support.annotation.NonNull; import android.support.annotation.NonNull;
import android.support.v4.app.LoaderManager; 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.CursorLoader;
import android.support.v4.content.Loader; import android.support.v4.content.Loader;
import android.support.v7.app.AlertDialog; import android.support.v7.app.AlertDialog;
@ -53,6 +57,7 @@ import android.widget.EditText;
import android.widget.ListView; import android.widget.ListView;
import android.widget.TextView; import android.widget.TextView;
import android.widget.Toast; import android.widget.Toast;
import org.fdroid.fdroid.AddRepoIntentService;
import org.fdroid.fdroid.FDroidApp; import org.fdroid.fdroid.FDroidApp;
import org.fdroid.fdroid.IndexUpdater; import org.fdroid.fdroid.IndexUpdater;
import org.fdroid.fdroid.R; import org.fdroid.fdroid.R;
@ -68,7 +73,6 @@ import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
import java.net.MalformedURLException; import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import java.net.URL; import java.net.URL;
import java.util.Arrays; import java.util.Arrays;
@ -79,10 +83,12 @@ public class ManageReposActivity extends AppCompatActivity
implements LoaderManager.LoaderCallbacks<Cursor>, RepoAdapter.EnabledListener { implements LoaderManager.LoaderCallbacks<Cursor>, RepoAdapter.EnabledListener {
private static final String TAG = "ManageReposActivity"; 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 static final String DEFAULT_NEW_REPO_TEXT = "https://";
private enum AddRepoState { 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, EXISTS_DISABLED, EXISTS_ENABLED, EXISTS_UPGRADABLE_TO_SIGNED, INVALID_URL,
IS_SWAP 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 * True if activity started with an intent such as from QR code. False if
* opened from, e.g. the main menu. * opened from, e.g. the main menu.
*/ */
private boolean isImportingRepo; private boolean finishAfterAddingRepo;
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
@ -156,6 +162,16 @@ public class ManageReposActivity extends AppCompatActivity
case R.id.action_add_repo: case R.id.action_add_repo:
showAddRepo(); showAddRepo();
return true; 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); return super.onOptionsItemSelected(item);
} }
@ -272,7 +288,7 @@ public class ManageReposActivity extends AppCompatActivity
@Override @Override
public void onClick(DialogInterface dialog, int which) { public void onClick(DialogInterface dialog, int which) {
dialog.dismiss(); dialog.dismiss();
if (isImportingRepo) { if (finishAfterAddingRepo) {
ManageReposActivity.this.finish(); ManageReposActivity.this.finish();
} }
} }
@ -311,7 +327,7 @@ public class ManageReposActivity extends AppCompatActivity
String url = uriEditText.getText().toString(); String url = uriEditText.getText().toString();
try { try {
url = normalizeUrl(url); url = AddRepoIntentService.normalizeUrl(url);
} catch (URISyntaxException e) { } catch (URISyntaxException e) {
invalidUrl(); invalidUrl();
return; return;
@ -415,7 +431,7 @@ public class ManageReposActivity extends AppCompatActivity
private void validateRepoDetails(@NonNull String uri, @NonNull String fingerprint) { private void validateRepoDetails(@NonNull String uri, @NonNull String fingerprint) {
try { try {
uri = normalizeUrl(uri); uri = AddRepoIntentService.normalizeUrl(uri);
} catch (URISyntaxException e) { } catch (URISyntaxException e) {
// Don't bother dealing with this exception yet, as this is called every time // 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 // 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)) { } else if (repo.fingerprint != null && !repo.fingerprint.equalsIgnoreCase(fingerprint)) {
repoFingerprintDoesntMatch(repo); repoFingerprintDoesntMatch(repo);
} else { } else {
if (!TextUtils.equals(repo.address, uri) if (repo.getMirrorList().contains(uri) && !TextUtils.equals(repo.address, uri) && repo.inuse) {
&& !repo.getMirrorList().contains(uri)) { repoExistsAlreadyMirror(repo);
} else if (!TextUtils.equals(repo.address, uri) && repo.inuse) {
repoExistsAddMirror(repo); repoExistsAddMirror(repo);
} else if (repo.inuse) { } else if (repo.inuse) {
repoExistsAndEnabled(repo); repoExistsAndEnabled(repo);
@ -487,6 +504,10 @@ public class ManageReposActivity extends AppCompatActivity
R.string.repo_add_mirror, true); 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) { private void upgradingToSigned(Repo repo) {
updateUi(repo, AddRepoState.EXISTS_UPGRADABLE_TO_SIGNED, R.string.repo_exists_add_fingerprint, updateUi(repo, AddRepoState.EXISTS_UPGRADABLE_TO_SIGNED, R.string.repo_exists_add_fingerprint,
false, R.string.add_key, true); false, R.string.add_key, true);
@ -518,6 +539,13 @@ public class ManageReposActivity extends AppCompatActivity
addButton.setText(addTextRes); addButton.setText(addTextRes);
addButton.setEnabled(addEnabled); 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; 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"}; final String[] pathsToCheck = {"", "fdroid/repo", "repo"};
for (final String path : pathsToCheck) { for (final String path : pathsToCheck) {
@ -683,53 +717,6 @@ public class ManageReposActivity extends AppCompatActivity
checker.execute(originalAddress); 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. * Create a repository without a username or password.
*/ */
@ -740,7 +727,7 @@ public class ManageReposActivity extends AppCompatActivity
private void createNewRepo(String address, String fingerprint, private void createNewRepo(String address, String fingerprint,
final String username, final String password) { final String username, final String password) {
try { try {
address = normalizeUrl(address); address = AddRepoIntentService.normalizeUrl(address);
} catch (URISyntaxException e) { } catch (URISyntaxException e) {
// Leave address as it was. // Leave address as it was.
} }
@ -816,7 +803,7 @@ public class ManageReposActivity extends AppCompatActivity
if (addRepoDialog.isShowing()) { if (addRepoDialog.isShowing()) {
addRepoDialog.dismiss(); addRepoDialog.dismiss();
} }
if (isImportingRepo) { if (finishAfterAddingRepo) {
setResult(RESULT_OK); setResult(RESULT_OK);
finish(); finish();
} }
@ -827,7 +814,7 @@ public class ManageReposActivity extends AppCompatActivity
/* an URL from a click, NFC, QRCode scan, etc */ /* an URL from a click, NFC, QRCode scan, etc */
NewRepoConfig newRepoConfig = new NewRepoConfig(this, intent); NewRepoConfig newRepoConfig = new NewRepoConfig(this, intent);
if (newRepoConfig.isValidRepo()) { if (newRepoConfig.isValidRepo()) {
isImportingRepo = true; finishAfterAddingRepo = intent.getBooleanExtra(EXTRA_FINISH_AFTER_ADDING_REPO, true);
showAddRepo(newRepoConfig.getRepoUriString(), newRepoConfig.getFingerprint(), showAddRepo(newRepoConfig.getRepoUriString(), newRepoConfig.getFingerprint(),
newRepoConfig.getUsername(), newRepoConfig.getPassword()); newRepoConfig.getUsername(), newRepoConfig.getPassword());
checkIfNewRepoOnSameWifi(newRepoConfig); checkIfNewRepoOnSameWifi(newRepoConfig);

View File

@ -30,6 +30,7 @@ import android.content.IntentFilter;
import android.net.Uri; import android.net.Uri;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable; import android.support.annotation.Nullable;
import android.support.v4.content.LocalBroadcastManager; import android.support.v4.content.LocalBroadcastManager;
import android.support.v7.app.AppCompatActivity; import android.support.v7.app.AppCompatActivity;
@ -41,7 +42,6 @@ import android.widget.Toast;
import com.ashokvarma.bottomnavigation.BottomNavigationBar; import com.ashokvarma.bottomnavigation.BottomNavigationBar;
import com.ashokvarma.bottomnavigation.BottomNavigationItem; import com.ashokvarma.bottomnavigation.BottomNavigationItem;
import com.ashokvarma.bottomnavigation.TextBadgeItem; import com.ashokvarma.bottomnavigation.TextBadgeItem;
import org.fdroid.fdroid.views.AppDetailsActivity;
import org.fdroid.fdroid.AppUpdateStatusManager; import org.fdroid.fdroid.AppUpdateStatusManager;
import org.fdroid.fdroid.AppUpdateStatusManager.AppUpdateStatus; import org.fdroid.fdroid.AppUpdateStatusManager.AppUpdateStatus;
import org.fdroid.fdroid.BuildConfig; import org.fdroid.fdroid.BuildConfig;
@ -52,6 +52,7 @@ import org.fdroid.fdroid.R;
import org.fdroid.fdroid.UpdateService; import org.fdroid.fdroid.UpdateService;
import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.data.NewRepoConfig; import org.fdroid.fdroid.data.NewRepoConfig;
import org.fdroid.fdroid.views.AppDetailsActivity;
import org.fdroid.fdroid.views.ManageReposActivity; import org.fdroid.fdroid.views.ManageReposActivity;
import org.fdroid.fdroid.views.apps.AppListActivity; import org.fdroid.fdroid.views.apps.AppListActivity;
import org.fdroid.fdroid.views.swap.SwapWorkflowActivity; 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_UPDATES = "org.fdroid.fdroid.views.main.MainActivity.VIEW_UPDATES";
public static final String EXTRA_VIEW_SETTINGS = "org.fdroid.fdroid.views.main.MainActivity.VIEW_SETTINGS"; 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 ADD_REPO_INTENT_HANDLED = "addRepoIntentHandled";
private static final String ACTION_ADD_REPO = "org.fdroid.fdroid.MainActivity.ACTION_ADD_REPO"; 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); 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 @Override
public void onTabSelected(int position) { public void onTabSelected(int position) {
pager.scrollToPosition(position); pager.scrollToPosition(position);
@ -341,7 +353,12 @@ public class MainActivity extends AppCompatActivity implements BottomNavigationB
confirmIntent.setData(intent.getData()); confirmIntent.setData(intent.getData());
startActivityForResult(confirmIntent, REQUEST_SWAP); startActivityForResult(confirmIntent, REQUEST_SWAP);
} else { } 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(); finish();
} else if (parser.getErrorMessage() != null) { } else if (parser.getErrorMessage() != null) {

View File

@ -59,6 +59,10 @@
<string name="local_repo_name">Name of your Local Repo</string> <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_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="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_title">Authentication required</string>
<string name="login_name">Username</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__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__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__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_title">Touch to swap</string>
<string name="swap_nfc_description">If your friend has F-Droid and NFC turned on touch your devices together. <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_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">Swapping not enabled</string>
<string name="swap_not_enabled_description">Before swapping, your device must be made visible.</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_invalid_url">Invalid URL for swapping: %1$s</string>
<string name="swap_toast_hotspot_enabled">Wi-Fi Hotspot enabled</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> <string name="swap_toast_could_not_enable_hotspot">Could not enable Wi-Fi Hotspot!</string>

View File

@ -96,6 +96,11 @@
<EditTextPreference <EditTextPreference
android:key="localRepoName" android:key="localRepoName"
android:title="@string/local_repo_name"/> 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.support.v7.preference.PreferenceCategory android:title="@string/proxy"> <android.support.v7.preference.PreferenceCategory android:title="@string/proxy">

View File

@ -34,13 +34,13 @@ public class FDroidRepoUpdateTest extends MultiIndexUpdaterTest {
protected void updateEarlier() throws IndexUpdater.UpdateException { protected void updateEarlier() throws IndexUpdater.UpdateException {
Utils.debugLog(TAG, "Updating earlier version of F-Droid repo"); 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"); "index.fdroid.2016-10-30.jar");
} }
protected void updateLater() throws IndexUpdater.UpdateException { protected void updateLater() throws IndexUpdater.UpdateException {
Utils.debugLog(TAG, "Updating later version of F-Droid repo"); 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"); "index.fdroid.2016-11-10.jar");
} }

View File

@ -171,11 +171,11 @@ public abstract class MultiIndexUpdaterTest extends FDroidProviderTest {
return RepoProvider.Helper.findByAddress(context, uri); 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)); 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)); return new IndexUpdater(context, createRepo(name, uri, context, signingCert));
} }
@ -184,15 +184,15 @@ public abstract class MultiIndexUpdaterTest extends FDroidProviderTest {
} }
protected void updateConflicting() throws UpdateException { 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 { 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 { 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 { protected void updateRepo(IndexUpdater updater, String indexJarPath) throws UpdateException {

View File

@ -17,7 +17,7 @@
<module name="FileContentsHolder" /> <module name="FileContentsHolder" />
<module name="LineLength"> <module name="LineLength">
<property name="max" value="118"/> <property name="max" value="118"/>
<property name="ignorePattern" value="https?://"/> <property name="ignorePattern" value="[a-z]+://"/>
</module> </module>
<module name="ConstantName"> <module name="ConstantName">