diff --git a/app/src/basic/java/org/fdroid/fdroid/nearby/TreeUriScannerIntentService.java b/app/src/basic/java/org/fdroid/fdroid/nearby/TreeUriScannerIntentService.java new file mode 100644 index 000000000..979187247 --- /dev/null +++ b/app/src/basic/java/org/fdroid/fdroid/nearby/TreeUriScannerIntentService.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2018 Senecto Limited + * + * 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.nearby; + +import android.app.Activity; +import android.content.Intent; + +/** + * Dummy version for basic app flavor. + */ +public class TreeUriScannerIntentService { + public static void onActivityResult(Activity activity, Intent intent) { + throw new IllegalStateException("unimplemented"); + } +} diff --git a/app/src/basic/java/org/fdroid/fdroid/views/main/NearbyViewBinder.java b/app/src/basic/java/org/fdroid/fdroid/views/main/NearbyViewBinder.java index f3e21451a..d53d0b937 100644 --- a/app/src/basic/java/org/fdroid/fdroid/views/main/NearbyViewBinder.java +++ b/app/src/basic/java/org/fdroid/fdroid/views/main/NearbyViewBinder.java @@ -1,10 +1,9 @@ package org.fdroid.fdroid.views.main; import android.app.Activity; -import android.content.Intent; class NearbyViewBinder { - static void onActivityResult(Activity activity, Intent data) { + public static void updateUsbOtg(final Activity activity) { throw new IllegalStateException("unimplemented"); } } diff --git a/app/src/full/AndroidManifest.xml b/app/src/full/AndroidManifest.xml index a1d1afc3b..41fb74cfe 100644 --- a/app/src/full/AndroidManifest.xml +++ b/app/src/full/AndroidManifest.xml @@ -28,6 +28,7 @@ + @@ -42,6 +43,8 @@ + @@ -78,7 +81,6 @@ android:exported="false"/> - @@ -89,6 +91,17 @@ android:name=".nearby.SDCardScannerService" android:exported="false"/> + + + + + + + The Storage Situation: Removable Storage + * @see The Storage Situation: Removable Storage + * @see Be Careful with Scoped Directory Access * @see Using Scoped Directory Access * @see Open Files using Storage Access Framework */ @@ -81,6 +90,25 @@ public class TreeUriScannerIntentService extends IntentService { } } + /** + * Now determine if it is External Storage that must be handled by the + * {@link TreeUriScannerIntentService} or whether it is External Storage + * like an SD Card that can be directly accessed via the file system. + */ + public static void onActivityResult(Activity activity, Intent intent) { + Uri uri = intent.getData(); + if (uri != null) { + if (Build.VERSION.SDK_INT >= 19) { + ContentResolver contentResolver = activity.getContentResolver(); + int perms = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION; + contentResolver.takePersistableUriPermission(uri, perms); + } + String msg = String.format(activity.getString(R.string.swap_toast_using_path), uri.toString()); + Toast.makeText(activity, msg, Toast.LENGTH_SHORT).show(); + scan(activity, uri); + } + } + @Override protected void onHandleIntent(Intent intent) { if (intent == null || !ACTION_SCAN_TREE_URI.equals(intent.getAction())) { @@ -95,20 +123,34 @@ public class TreeUriScannerIntentService extends IntentService { searchDirectory(treeFile); } + /** + * Recursively search for {@link IndexV1Updater#SIGNED_FILE_NAME} starting + * from the given directory, looking at files first before recursing into + * directories. This is "depth last" since the index file is much more + * likely to be shallow than deep, and there can be a lot of files to + * search through starting at 4 or more levels deep, like the fdroid + * icons dirs and the per-app "external storage" dirs. + */ private void searchDirectory(DocumentFile documentFileDir) { DocumentFile[] documentFiles = documentFileDir.listFiles(); if (documentFiles == null) { return; } + boolean foundIndex = false; + ArrayList dirs = new ArrayList<>(); for (DocumentFile documentFile : documentFiles) { if (documentFile.isDirectory()) { - searchDirectory(documentFile); - } else { + dirs.add(documentFile); + } else if (!foundIndex) { if (IndexV1Updater.SIGNED_FILE_NAME.equals(documentFile.getName())) { registerRepo(documentFile); + foundIndex = true; } } } + for (DocumentFile dir : dirs) { + searchDirectory(dir); + } } /** @@ -123,9 +165,7 @@ public class TreeUriScannerIntentService extends IntentService { 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(); @@ -145,7 +185,6 @@ public class TreeUriScannerIntentService extends IntentService { 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(); diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/TreeUriUtils.java b/app/src/full/java/org/fdroid/fdroid/nearby/TreeUriUtils.java new file mode 100644 index 000000000..3098a7a34 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/TreeUriUtils.java @@ -0,0 +1,96 @@ +package org.fdroid.fdroid.nearby; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.content.Context; +import android.net.Uri; +import android.os.Build; +import android.os.storage.StorageManager; +import android.provider.DocumentsContract; +import android.support.annotation.Nullable; + +import java.io.File; +import java.lang.reflect.Array; +import java.lang.reflect.Method; + + +/** + * @see Android 5.0 DocumentFile from tree URI + */ +public final class TreeUriUtils { + public static final String TAG = "TreeUriUtils"; + + private static final String PRIMARY_VOLUME_NAME = "primary"; + + @Nullable + public static String getFullPathFromTreeUri(Context context, @Nullable final Uri treeUri) { + if (treeUri == null) return null; + String volumePath = getVolumePath(getVolumeIdFromTreeUri(treeUri), context); + if (volumePath == null) return File.separator; + if (volumePath.endsWith(File.separator)) + volumePath = volumePath.substring(0, volumePath.length() - 1); + + String documentPath = getDocumentPathFromTreeUri(treeUri); + if (documentPath.endsWith(File.separator)) + documentPath = documentPath.substring(0, documentPath.length() - 1); + + if (documentPath.length() > 0) { + if (documentPath.startsWith(File.separator)) + return volumePath + documentPath; + else + return volumePath + File.separator + documentPath; + } else return volumePath; + } + + + @SuppressLint("ObsoleteSdkInt") + private static String getVolumePath(final String volumeId, Context context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return null; + try { + StorageManager mStorageManager = + (StorageManager) context.getSystemService(Context.STORAGE_SERVICE); + Class storageVolumeClazz = Class.forName("android.os.storage.StorageVolume"); + Method getVolumeList = mStorageManager.getClass().getMethod("getVolumeList"); + Method getUuid = storageVolumeClazz.getMethod("getUuid"); + Method getPath = storageVolumeClazz.getMethod("getPath"); + Method isPrimary = storageVolumeClazz.getMethod("isPrimary"); + Object result = getVolumeList.invoke(mStorageManager); + + final int length = Array.getLength(result); + for (int i = 0; i < length; i++) { + Object storageVolumeElement = Array.get(result, i); + String uuid = (String) getUuid.invoke(storageVolumeElement); + Boolean primary = (Boolean) isPrimary.invoke(storageVolumeElement); + + // primary volume? + if (primary && PRIMARY_VOLUME_NAME.equals(volumeId)) + return (String) getPath.invoke(storageVolumeElement); + + // other volumes? + if (uuid != null && uuid.equals(volumeId)) + return (String) getPath.invoke(storageVolumeElement); + } + // not found. + return null; + } catch (Exception ex) { + return null; + } + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + private static String getVolumeIdFromTreeUri(final Uri treeUri) { + final String docId = DocumentsContract.getTreeDocumentId(treeUri); + final String[] split = docId.split(":"); + if (split.length > 0) return split[0]; + else return null; + } + + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + private static String getDocumentPathFromTreeUri(final Uri treeUri) { + final String docId = DocumentsContract.getTreeDocumentId(treeUri); + final String[] split = docId.split(":"); + if ((split.length >= 2) && (split[1] != null)) return split[1]; + else return File.separator; + } +} \ No newline at end of file diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/UsbDeviceAttachedActivity.java b/app/src/full/java/org/fdroid/fdroid/nearby/UsbDeviceAttachedActivity.java new file mode 100644 index 000000000..3834d0aea --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/UsbDeviceAttachedActivity.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2018-2019 Hans-Christoph Steiner + * + * 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.nearby; + +import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.ComponentName; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.UriPermission; +import android.database.ContentObserver; +import android.hardware.usb.UsbManager; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.support.annotation.Nullable; +import android.support.annotation.RequiresApi; +import android.text.TextUtils; +import android.util.Log; +import org.fdroid.fdroid.views.main.MainActivity; +import org.fdroid.fdroid.views.main.NearbyViewBinder; + +import java.util.HashMap; + +/** + * This is just a shim to receive {@link UsbManager#ACTION_USB_ACCESSORY_ATTACHED} + * events then open up the right screen in {@link MainActivity}. + */ +public class UsbDeviceAttachedActivity extends Activity { + public static final String TAG = "UsbDeviceAttachedActivi"; + + private static final HashMap contentObservers = new HashMap<>(); + + @RequiresApi(api = 19) + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (Build.VERSION.SDK_INT < 19) { + finish(); + return; + } + + Intent intent = getIntent(); + if (intent == null || TextUtils.isEmpty(intent.getAction()) + || !UsbManager.ACTION_USB_DEVICE_ATTACHED.equals(intent.getAction())) { + Log.i(TAG, "ignoring irrelevant intent: " + intent); + finish(); + return; + } + Log.i(TAG, "handling intent: " + intent); + + final ContentResolver contentResolver = getContentResolver(); + BroadcastReceiver receiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (!UsbManager.ACTION_USB_DEVICE_DETACHED.equals(intent.getAction())) { + return; + } + NearbyViewBinder.updateUsbOtg(UsbDeviceAttachedActivity.this); + unregisterReceiver(this); + for (ContentObserver contentObserver : contentObservers.values()) { + contentResolver.unregisterContentObserver(contentObserver); + } + } + }; + registerReceiver(receiver, new IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED)); + + for (final UriPermission uriPermission : contentResolver.getPersistedUriPermissions()) { + Uri uri = uriPermission.getUri(); + final ContentObserver contentObserver = new ContentObserver(new Handler()) { + + @Override + public void onChange(boolean selfChange, Uri uri) { + NearbyViewBinder.updateUsbOtg(UsbDeviceAttachedActivity.this); + } + }; + contentResolver.registerContentObserver(uri, true, contentObserver); + } + intent.setComponent(new ComponentName(this, MainActivity.class)); + intent.putExtra(MainActivity.EXTRA_VIEW_NEARBY, true); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + startActivity(intent); + finish(); + } + + @Override + public void finish() { + setResult(RESULT_OK); + super.finish(); + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/views/main/NearbyViewBinder.java b/app/src/full/java/org/fdroid/fdroid/views/main/NearbyViewBinder.java index 88a00c973..f6e20dac9 100644 --- a/app/src/full/java/org/fdroid/fdroid/views/main/NearbyViewBinder.java +++ b/app/src/full/java/org/fdroid/fdroid/views/main/NearbyViewBinder.java @@ -2,13 +2,21 @@ package org.fdroid.fdroid.views.main; import android.Manifest; import android.app.Activity; +import android.content.Context; import android.content.Intent; +import android.content.UriPermission; import android.content.pm.PackageManager; +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbManager; +import android.net.Uri; import android.os.Build; import android.os.Environment; +import android.os.storage.StorageManager; +import android.os.storage.StorageVolume; import android.support.annotation.RequiresApi; import android.support.v4.app.ActivityCompat; import android.support.v4.content.ContextCompat; +import android.text.TextUtils; import android.util.Log; import android.view.View; import android.widget.Button; @@ -17,11 +25,13 @@ import android.widget.ImageView; import android.widget.TextView; import android.widget.Toast; import org.fdroid.fdroid.R; +import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.nearby.SDCardScannerService; import org.fdroid.fdroid.nearby.SwapService; import org.fdroid.fdroid.nearby.TreeUriScannerIntentService; import java.io.File; +import java.util.List; /** * A splash screen encouraging people to start the swap process. The swap @@ -50,13 +60,14 @@ import java.io.File; * @see TreeUriScannerIntentService * @see org.fdroid.fdroid.nearby.SDCardScannerService */ -class NearbyViewBinder { +public class NearbyViewBinder { public static final String TAG = "NearbyViewBinder"; - static File externalStorage = null; + private static File externalStorage = null; + private static View swapView; NearbyViewBinder(final Activity activity, FrameLayout parent) { - View swapView = activity.getLayoutInflater().inflate(R.layout.main_tab_swap, parent, true); + swapView = activity.getLayoutInflater().inflate(R.layout.main_tab_swap, parent, true); TextView subtext = swapView.findViewById(R.id.both_parties_need_fdroid_text); subtext.setText(activity.getString(R.string.nearby_splash__both_parties_need_fdroid, @@ -131,5 +142,62 @@ class NearbyViewBinder { } }); } + + updateUsbOtg(activity); + } + + public static void updateUsbOtg(final Activity activity) { + if (Build.VERSION.SDK_INT < 24) { + return; + } + if (swapView == null) { + Utils.debugLog(TAG, "swapView == null"); + return; + } + TextView storageVolumeText = swapView.findViewById(R.id.storage_volume_text); + Button requestStorageVolume = swapView.findViewById(R.id.request_storage_volume_button); + storageVolumeText.setVisibility(View.GONE); + requestStorageVolume.setVisibility(View.GONE); + + final StorageManager storageManager = (StorageManager) activity.getSystemService(Context.STORAGE_SERVICE); + for (final StorageVolume storageVolume : storageManager.getStorageVolumes()) { + if (storageVolume.isRemovable() && !storageVolume.isPrimary()) { + Log.i(TAG, "StorageVolume: " + storageVolume); + final Intent intent = storageVolume.createAccessIntent(null); + if (intent == null) { + Utils.debugLog(TAG, "Got null Storage Volume access Intent"); + return; + } + storageVolumeText.setVisibility(View.VISIBLE); + + String text = storageVolume.getDescription(activity); + if (!TextUtils.isEmpty(text)) { + requestStorageVolume.setText(text); + UsbDevice usb = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + if (usb != null) { + text = String.format("%s (%s %s)", text, usb.getManufacturerName(), usb.getProductName()); + Toast.makeText(activity, text, Toast.LENGTH_LONG).show(); + } + } + + requestStorageVolume.setVisibility(View.VISIBLE); + requestStorageVolume.setOnClickListener(new View.OnClickListener() { + @Override + @RequiresApi(api = 24) + public void onClick(View v) { + List list = activity.getContentResolver().getPersistedUriPermissions(); + if (list != null) for (UriPermission uriPermission : list) { + Uri uri = uriPermission.getUri(); + if (uri.getPath().equals(String.format("/tree/%s:", storageVolume.getUuid()))) { + intent.setData(uri); + TreeUriScannerIntentService.onActivityResult(activity, intent); + return; + } + } + activity.startActivityForResult(intent, MainActivity.REQUEST_STORAGE_ACCESS); + } + }); + } + } } } diff --git a/app/src/full/res/layout/main_tab_swap.xml b/app/src/full/res/layout/main_tab_swap.xml index 3db7d5eee..dff52eb34 100644 --- a/app/src/full/res/layout/main_tab_swap.xml +++ b/app/src/full/res/layout/main_tab_swap.xml @@ -72,11 +72,12 @@ android:layout_height="wrap_content" android:layout_weight="1" android:text="@string/nearby_splash__read_external_storage" - android:textSize="20sp" + android:textSize="17sp" android:textColor="?attr/lightGrayTextColor" android:textAlignment="center" android:gravity="center" - android:layout_marginEnd="24dp" app:layout_constraintEnd_toEndOf="parent" android:layout_marginRight="24dp" + android:layout_marginEnd="24dp" app:layout_constraintEnd_toEndOf="parent" + android:layout_marginRight="24dp" android:layout_marginStart="24dp" app:layout_constraintStart_toStartOf="parent" android:layout_marginLeft="24dp" android:layout_marginTop="48dp" app:layout_constraintTop_toBottomOf="@+id/both_parties_need_fdroid_text" @@ -96,4 +97,35 @@ app:layout_constraintTop_toBottomOf="@+id/read_external_storage_text" android:visibility="gone" tools:visibility="visible"/> + + +