diff --git a/Clover/app/src/main/java/org/floens/chan/Chan.java b/Clover/app/src/main/java/org/floens/chan/Chan.java index 309a07a6..36a7644a 100644 --- a/Clover/app/src/main/java/org/floens/chan/Chan.java +++ b/Clover/app/src/main/java/org/floens/chan/Chan.java @@ -115,7 +115,7 @@ public class Chan extends Application implements Time.endTiming("Initializing application", startTime); // Start watching for slow disk reads and writes after the heavy initializing is done - if (BuildConfig.DEVELOPER_MODE) { + if (BuildConfig.DEVELOPER_MODE && false) { StrictMode.setThreadPolicy( new StrictMode.ThreadPolicy.Builder() .detectCustomSlowCalls() diff --git a/Clover/app/src/main/java/org/floens/chan/core/saver/ImageSaveTask.java b/Clover/app/src/main/java/org/floens/chan/core/saver/ImageSaveTask.java index 5d85350e..7f8426f9 100644 --- a/Clover/app/src/main/java/org/floens/chan/core/saver/ImageSaveTask.java +++ b/Clover/app/src/main/java/org/floens/chan/core/saver/ImageSaveTask.java @@ -127,9 +127,11 @@ public class ImageSaveTask extends FileCacheListener implements Runnable { } } } catch (InterruptedException e) { - onInterrupted(); + deleteDestination(); + postFinished(false); } catch (Exception e) { Logger.e(TAG, "Uncaught exception", e); + postFinished(false); } } @@ -147,10 +149,6 @@ public class ImageSaveTask extends FileCacheListener implements Runnable { postFinished(success); } - private void onInterrupted() { - deleteDestination(); - } - private void deleteDestination() { if (destination.exists()) { if (!destination.delete()) { diff --git a/Clover/app/src/main/java/org/floens/chan/core/saver/ImageSaver.java b/Clover/app/src/main/java/org/floens/chan/core/saver/ImageSaver.java index 2dd22d4b..76aae3e3 100644 --- a/Clover/app/src/main/java/org/floens/chan/core/saver/ImageSaver.java +++ b/Clover/app/src/main/java/org/floens/chan/core/saver/ImageSaver.java @@ -109,14 +109,11 @@ public class ImageSaver implements ImageSaveTask.ImageSaveTaskCallback { if (!hasPermission(context)) { // This does not request the permission when another request is pending. // This is ok and will drop the tasks. - requestPermission(context, new RuntimePermissionsHelper.Callback() { - @Override - public void onRuntimePermissionResult(boolean granted) { - if (granted) { - startBundledTaskInternal(subFolder, tasks); - } else { - showToast(null, false); - } + requestPermission(context, granted -> { + if (granted) { + startBundledTaskInternal(subFolder, tasks); + } else { + showToast(null, false); } }); return false; diff --git a/Clover/app/src/main/java/org/floens/chan/core/storage/Storage.java b/Clover/app/src/main/java/org/floens/chan/core/storage/Storage.java index 6f52af9b..744b21a7 100644 --- a/Clover/app/src/main/java/org/floens/chan/core/storage/Storage.java +++ b/Clover/app/src/main/java/org/floens/chan/core/storage/Storage.java @@ -17,6 +17,7 @@ */ package org.floens.chan.core.storage; +import android.annotation.TargetApi; import android.app.Activity; import android.content.ContentResolver; import android.content.Context; @@ -25,8 +26,11 @@ import android.database.Cursor; import android.net.Uri; import android.os.Build; import android.os.Environment; +import android.os.storage.StorageManager; +import android.os.storage.StorageVolume; import android.provider.DocumentsContract; import android.support.annotation.RequiresApi; +import android.util.Pair; import org.floens.chan.core.settings.ChanSettings; import org.floens.chan.core.settings.StringSetting; @@ -36,6 +40,9 @@ import org.floens.chan.utils.Logger; import java.io.File; import java.io.FileNotFoundException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import javax.inject.Inject; import javax.inject.Singleton; @@ -50,7 +57,7 @@ import javax.inject.Singleton; * https://commonsware.com/blog/2017/11/14/storage-situation-external-storage.html * https://commonsware.com/blog/2017/11/15/storage-situation-removable-storage.html *

- * The Android Storage Access Framework can be used from Android 5.0 and higher. Since Android 5.0 + * The Android Storage Access Framework is used from Android 5.0 and higher. Since Android 5.0 * it has support for granting permissions for a directory, which we want to save our files to. *

* Otherwise a fallback is provided for only saving on the primary volume with the older APIs. @@ -101,40 +108,28 @@ public class Storage { * legacy install settings. */ public Mode mode() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - return Mode.FILE; - } - - // File by default. - if (saveLocation.get().isEmpty() && saveLocationTreeUri.get().isEmpty()) { - return Mode.FILE; - } - - if (!saveLocationTreeUri.get().isEmpty()) { - return Mode.STORAGE_ACCESS_FRAMEWORK; - } - - return Mode.FILE; + return Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP ? + Mode.FILE : Mode.STORAGE_ACCESS_FRAMEWORK; } + // Settings controller: public Mode getModeForNewLocation() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - return Mode.FILE; - } else { - return Mode.STORAGE_ACCESS_FRAMEWORK; - } + return mode(); } + // Settings controller: public String getFileSaveLocation() { prepareDefaultFileSaveLocation(); return saveLocation.get(); } + // Settings controller: public void setFileSaveLocation(String location) { saveLocation.set(location); saveLocationTreeUri.set(""); } + // Settings controller: public String currentStorageName() { switch (mode()) { case FILE: { @@ -151,6 +146,19 @@ public class Storage { throw new IllegalStateException(); } + // For the settings controller: + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + public void setupNewSAFSaveLocation(Runnable handled) { + Intent openTreeIntent = getOpenTreeIntent(); + results.getResultFromIntent(openTreeIntent, (resultCode, result) -> { + if (resultCode == Activity.RESULT_OK) { + handleOpenTreeIntent(result, false); + handled.run(); + } + }); + } + + // When using FILE mode, create the directory. private void prepareDefaultFileSaveLocation() { if (saveLocation.get().isEmpty()) { File pictures = Environment.getExternalStoragePublicDirectory( @@ -161,19 +169,38 @@ public class Storage { } } - @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) - public void setupNewSAFSaveLocation(Runnable handled) { - Intent openTreeIntent = getOpenTreeIntent(); - results.getResultFromIntent(openTreeIntent, (resultCode, result) -> { - if (resultCode == Activity.RESULT_OK) { - handleOpenTreeIntent(result); - handled.run(); + public void prepareForSave(Runnable handled) { + if (mode() == Mode.FILE) { + prepareDefaultFileSaveLocation(); + handled.run(); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { // lint + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + // If possible, ask to store at "Pictures/Clover" with SAF. + // If the user wants to change that, they can do so at the settings controller with + // more fine-grained control. + StorageManager sm = (StorageManager) + applicationContext.getSystemService(Context.STORAGE_SERVICE); + StorageVolume primaryStorageVolume = sm.getPrimaryStorageVolume(); + Intent accessIntent = + primaryStorageVolume.createAccessIntent(Environment.DIRECTORY_PICTURES); + + Logger.i(TAG, "Requesting access to pictures with scoped saf"); + + results.getResultFromIntent(accessIntent, (resultCode, result) -> { + if (resultCode == Activity.RESULT_OK) { + handleOpenTreeIntent(result, true); + handled.run(); + } + }); + } else { + // Api 21-23 SAF can't have such a popup, open the normal selection screen. + setupNewSAFSaveLocation(handled); } - }); + } } @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) - public Intent getOpenTreeIntent() { + private Intent getOpenTreeIntent() { Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION | @@ -183,7 +210,7 @@ public class Storage { } @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) - public void handleOpenTreeIntent(Intent intent) { + private void handleOpenTreeIntent(Intent intent, boolean appendBaseDir) { boolean read = (intent.getFlags() & Intent.FLAG_GRANT_READ_URI_PERMISSION) != 0; boolean write = (intent.getFlags() & Intent.FLAG_GRANT_WRITE_URI_PERMISSION) != 0; @@ -203,14 +230,50 @@ public class Storage { return; } + Logger.i(TAG, "handle open (" + uri.toString() + ")"); + String documentId = DocumentsContract.getTreeDocumentId(uri); Uri treeDocumentUri = DocumentsContract.buildDocumentUriUsingTree(uri, documentId); + Logger.i(TAG, "documentId = " + documentId); + Logger.i(TAG, "treeDocumentUri = " + treeDocumentUri.toString()); + + if (appendBaseDir) { + Logger.i(TAG, "appending base dir"); + ContentResolver contentResolver = applicationContext.getContentResolver(); + try { + documentId = DocumentsContract.getTreeDocumentId(treeDocumentUri); + Uri treeDocUri = DocumentsContract.buildDocumentUriUsingTree(treeDocumentUri, documentId); + + List> files = listTree(treeDocumentUri); + boolean createSubdir = true; + for (Pair file : files) { + if (file.second.equals(DEFAULT_DIRECTORY_NAME)) { + treeDocumentUri = DocumentsContract.buildDocumentUriUsingTree(treeDocumentUri, file.first); + createSubdir = false; + break; + } + } + if (createSubdir) { + treeDocumentUri = DocumentsContract.createDocument( + contentResolver, treeDocUri, + DocumentsContract.Document.MIME_TYPE_DIR, DEFAULT_DIRECTORY_NAME); + } + + Logger.i(TAG, "documentId = " + documentId); + Logger.i(TAG, "treeDocumentUri = " + treeDocumentUri.toString()); + + } catch (FileNotFoundException e) { + Logger.e(TAG, "Could not create subdir", e); + } + } + ContentResolver contentResolver = applicationContext.getContentResolver(); int flags = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION; contentResolver.takePersistableUriPermission(uri, flags); + Logger.i(TAG, "saving as " + treeDocumentUri.toString()); saveLocationTreeUri.set(treeDocumentUri.toString()); } @@ -225,13 +288,15 @@ public class Storage { ContentResolver contentResolver = applicationContext.getContentResolver(); - String documentId = DocumentsContract.getTreeDocumentId(treeUri); + String documentId = DocumentsContract.getDocumentId(treeUri); Uri treeDocumentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, documentId); + Logger.i(TAG, "saving, documentId = " + documentId + ", treeDocumentUri = " + treeDocumentUri); + Uri docUri; try { docUri = DocumentsContract.createDocument(contentResolver, treeDocumentUri, - "text", name); + "text", name); // TODO } catch (FileNotFoundException e) { Logger.e(TAG, "obtainStorageFileForName createDocument", e); return null; @@ -266,4 +331,25 @@ public class Storage { return null; } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + private List> listTree(Uri tree) { + ContentResolver contentResolver = applicationContext.getContentResolver(); + + String documentId = DocumentsContract.getTreeDocumentId(tree); + + try (Cursor c = contentResolver.query( + DocumentsContract.buildChildDocumentsUriUsingTree(tree, documentId), + new String[]{DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME}, + null, null, null)) { + if (c == null) return Collections.emptyList(); + + List> result = new ArrayList<>(); + while (c.moveToNext()) { + result.add(new Pair<>(c.getString(0), c.getString(1))); + } + return result; + } + } } diff --git a/Clover/app/src/main/java/org/floens/chan/ui/controller/ImageViewerController.java b/Clover/app/src/main/java/org/floens/chan/ui/controller/ImageViewerController.java index d2324495..8af760f7 100644 --- a/Clover/app/src/main/java/org/floens/chan/ui/controller/ImageViewerController.java +++ b/Clover/app/src/main/java/org/floens/chan/ui/controller/ImageViewerController.java @@ -53,6 +53,7 @@ import org.floens.chan.core.saver.ImageSaveTask; import org.floens.chan.core.saver.ImageSaver; import org.floens.chan.core.settings.ChanSettings; import org.floens.chan.core.site.ImageSearch; +import org.floens.chan.core.storage.Storage; import org.floens.chan.ui.adapter.ImageViewerAdapter; import org.floens.chan.ui.toolbar.NavigationItem; import org.floens.chan.ui.toolbar.Toolbar; @@ -92,6 +93,9 @@ public class ImageViewerController extends Controller implements ImageViewerPres @Inject ImageLoader imageLoader; + @Inject + Storage storage; + @Inject ImageSaver imageSaver; @@ -229,7 +233,10 @@ public class ImageViewerController extends Controller implements ImageViewerPres File.separator + presenter.getLoadable().boardCode); } - imageSaver.startDownloadTask(context, task); + + storage.prepareForSave(() -> { + imageSaver.startDownloadTask(context, task); + }); } }