mirror of https://github.com/TeamNewPipe/NewPipe
400 lines
14 KiB
Java
400 lines
14 KiB
Java
package org.schabi.newpipe.streams.io;
|
|
|
|
import static android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME;
|
|
import static android.provider.DocumentsContract.Root.COLUMN_DOCUMENT_ID;
|
|
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
|
|
|
|
import android.content.ContentResolver;
|
|
import android.content.Context;
|
|
import android.content.Intent;
|
|
import android.database.Cursor;
|
|
import android.net.Uri;
|
|
import android.os.ParcelFileDescriptor;
|
|
import android.provider.DocumentsContract;
|
|
import android.system.Os;
|
|
import android.system.StructStatVfs;
|
|
import android.util.Log;
|
|
|
|
import androidx.annotation.NonNull;
|
|
import androidx.annotation.Nullable;
|
|
import androidx.documentfile.provider.DocumentFile;
|
|
|
|
import org.schabi.newpipe.settings.NewPipeSettings;
|
|
import org.schabi.newpipe.util.FilePickerActivityHelper;
|
|
|
|
import java.io.FileDescriptor;
|
|
import java.io.IOException;
|
|
import java.net.URI;
|
|
import java.nio.file.Files;
|
|
import java.nio.file.Path;
|
|
import java.nio.file.Paths;
|
|
import java.util.ArrayList;
|
|
import java.util.Collections;
|
|
import java.util.List;
|
|
import java.util.stream.Collectors;
|
|
import java.util.stream.Stream;
|
|
|
|
public class StoredDirectoryHelper {
|
|
private static final String TAG = StoredDirectoryHelper.class.getSimpleName();
|
|
public static final int PERMISSION_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
|
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION;
|
|
|
|
private Path ioTree;
|
|
private DocumentFile docTree;
|
|
|
|
// will be `null` for non-SAF files, i.e. files that use `ioTree`
|
|
private Context context;
|
|
|
|
private final String tag;
|
|
|
|
public StoredDirectoryHelper(@NonNull final Context context, @NonNull final Uri path,
|
|
final String tag) throws IOException {
|
|
this.tag = tag;
|
|
|
|
if (ContentResolver.SCHEME_FILE.equalsIgnoreCase(path.getScheme())) {
|
|
ioTree = Paths.get(URI.create(path.toString()));
|
|
return;
|
|
}
|
|
|
|
this.context = context;
|
|
|
|
try {
|
|
this.context.getContentResolver().takePersistableUriPermission(path, PERMISSION_FLAGS);
|
|
} catch (final Exception e) {
|
|
throw new IOException(e);
|
|
}
|
|
|
|
this.docTree = DocumentFile.fromTreeUri(context, path);
|
|
|
|
if (this.docTree == null) {
|
|
throw new IOException("Failed to create the tree from Uri");
|
|
}
|
|
}
|
|
|
|
public StoredFileHelper createFile(final String filename, final String mime) {
|
|
return createFile(filename, mime, false);
|
|
}
|
|
|
|
public StoredFileHelper createUniqueFile(final String name, final String mime) {
|
|
final List<String> matches = new ArrayList<>();
|
|
final String[] filename = splitFilename(name);
|
|
final String lcFileName = filename[0].toLowerCase();
|
|
|
|
if (docTree == null) {
|
|
try (Stream<Path> stream = Files.list(ioTree)) {
|
|
matches.addAll(stream.map(path -> path.getFileName().toString().toLowerCase())
|
|
.filter(fileName -> fileName.startsWith(lcFileName))
|
|
.collect(Collectors.toList()));
|
|
} catch (final IOException e) {
|
|
Log.e(TAG, "Exception while traversing " + ioTree, e);
|
|
}
|
|
} else {
|
|
// warning: SAF file listing is very slow
|
|
final Uri docTreeChildren = DocumentsContract.buildChildDocumentsUriUsingTree(
|
|
docTree.getUri(), DocumentsContract.getDocumentId(docTree.getUri()));
|
|
|
|
final String[] projection = new String[]{COLUMN_DISPLAY_NAME};
|
|
final String selection = "(LOWER(" + COLUMN_DISPLAY_NAME + ") LIKE ?%";
|
|
final ContentResolver cr = context.getContentResolver();
|
|
|
|
try (Cursor cursor = cr.query(docTreeChildren, projection, selection,
|
|
new String[]{lcFileName}, null)) {
|
|
if (cursor != null) {
|
|
while (cursor.moveToNext()) {
|
|
addIfStartWith(matches, lcFileName, cursor.getString(0));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (matches.isEmpty()) {
|
|
return createFile(name, mime, true);
|
|
}
|
|
|
|
// check if the filename is in use
|
|
String lcName = name.toLowerCase();
|
|
for (final String testName : matches) {
|
|
if (testName.equals(lcName)) {
|
|
lcName = null;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// create file if filename not in use
|
|
if (lcName != null) {
|
|
return createFile(name, mime, true);
|
|
}
|
|
|
|
Collections.sort(matches, String::compareTo);
|
|
|
|
for (int i = 1; i < 1000; i++) {
|
|
if (Collections.binarySearch(matches, makeFileName(lcFileName, i, filename[1])) < 0) {
|
|
return createFile(makeFileName(filename[0], i, filename[1]), mime, true);
|
|
}
|
|
}
|
|
|
|
return createFile(String.valueOf(System.currentTimeMillis()).concat(filename[1]), mime,
|
|
false);
|
|
}
|
|
|
|
private StoredFileHelper createFile(final String filename, final String mime,
|
|
final boolean safe) {
|
|
final StoredFileHelper storage;
|
|
|
|
try {
|
|
if (docTree == null) {
|
|
storage = new StoredFileHelper(ioTree, filename, mime);
|
|
} else {
|
|
storage = new StoredFileHelper(context, docTree, filename, mime, safe);
|
|
}
|
|
} catch (final IOException e) {
|
|
return null;
|
|
}
|
|
|
|
storage.tag = tag;
|
|
|
|
return storage;
|
|
}
|
|
|
|
public Uri getUri() {
|
|
return docTree == null ? Uri.fromFile(ioTree.toFile()) : docTree.getUri();
|
|
}
|
|
|
|
public boolean exists() {
|
|
return docTree == null ? Files.exists(ioTree) : docTree.exists();
|
|
}
|
|
|
|
/**
|
|
* Indicates whether it's using the {@code java.io} API.
|
|
*
|
|
* @return {@code true} for Java I/O API, otherwise, {@code false} for Storage Access Framework
|
|
*/
|
|
public boolean isDirect() {
|
|
return docTree == null;
|
|
}
|
|
|
|
/**
|
|
* Get free memory of the storage partition this file belongs to (root of the directory).
|
|
* See <a href="https://stackoverflow.com/q/31171838">StackOverflow</a> and
|
|
* <a href="https://pubs.opengroup.org/onlinepubs/9699919799/functions/fstatvfs.html">
|
|
* {@code statvfs()} and {@code fstatvfs()} docs</a>
|
|
*
|
|
* @return amount of free memory in the volume of current directory (bytes), or {@link
|
|
* Long#MAX_VALUE} if an error occurred
|
|
*/
|
|
public long getFreeStorageSpace() {
|
|
try {
|
|
final StructStatVfs stat;
|
|
|
|
if (ioTree != null) {
|
|
// non-SAF file, use statvfs with the path directly (also, `context` would be null
|
|
// for non-SAF files, so we wouldn't be able to call `getContentResolver` anyway)
|
|
stat = Os.statvfs(ioTree.toString());
|
|
|
|
} else {
|
|
// SAF file, we can't get a path directly, so obtain a file descriptor first
|
|
// and then use fstatvfs with the file descriptor
|
|
try (ParcelFileDescriptor parcelFileDescriptor =
|
|
context.getContentResolver().openFileDescriptor(getUri(), "r")) {
|
|
if (parcelFileDescriptor == null) {
|
|
return Long.MAX_VALUE;
|
|
}
|
|
final FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor();
|
|
stat = Os.fstatvfs(fileDescriptor);
|
|
}
|
|
}
|
|
|
|
// this is the same formula used inside the FsStat class
|
|
return stat.f_bavail * stat.f_frsize;
|
|
} catch (final Throwable e) {
|
|
// ignore any error
|
|
Log.e(TAG, "Could not get free storage space", e);
|
|
return Long.MAX_VALUE;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Only using Java I/O. Creates the directory named by this abstract pathname, including any
|
|
* necessary but nonexistent parent directories.
|
|
* Note that if this operation fails it may have succeeded in creating some of the necessary
|
|
* parent directories.
|
|
*
|
|
* @return <code>true</code> if and only if the directory was created,
|
|
* along with all necessary parent directories or already exists; <code>false</code>
|
|
* otherwise
|
|
*/
|
|
public boolean mkdirs() {
|
|
if (docTree == null) {
|
|
try {
|
|
Files.createDirectories(ioTree);
|
|
} catch (final IOException e) {
|
|
Log.e(TAG, "Error while creating directories at " + ioTree, e);
|
|
}
|
|
return Files.exists(ioTree);
|
|
}
|
|
|
|
if (docTree.exists()) {
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
DocumentFile parent;
|
|
String child = docTree.getName();
|
|
|
|
while (true) {
|
|
parent = docTree.getParentFile();
|
|
if (parent == null || child == null) {
|
|
break;
|
|
}
|
|
if (parent.exists()) {
|
|
return true;
|
|
}
|
|
|
|
parent.createDirectory(child);
|
|
|
|
child = parent.getName(); // for the next iteration
|
|
}
|
|
} catch (final Exception ignored) {
|
|
// no more parent directories or unsupported by the storage provider
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public String getTag() {
|
|
return tag;
|
|
}
|
|
|
|
public Uri findFile(final String filename) {
|
|
if (docTree == null) {
|
|
final Path res = ioTree.resolve(filename);
|
|
return Files.exists(res) ? Uri.fromFile(res.toFile()) : null;
|
|
}
|
|
|
|
final DocumentFile res = findFileSAFHelper(context, docTree, filename);
|
|
return res == null ? null : res.getUri();
|
|
}
|
|
|
|
public boolean canWrite() {
|
|
return docTree == null ? Files.isWritable(ioTree) : docTree.canWrite();
|
|
}
|
|
|
|
/**
|
|
* @return {@code false} if the storage is direct, or the SAF storage is valid; {@code true} if
|
|
* SAF access to this SAF storage is denied (e.g. the user clicked on {@code Android settings ->
|
|
* Apps & notifications -> NewPipe -> Storage & cache -> Clear access});
|
|
*/
|
|
public boolean isInvalidSafStorage() {
|
|
return docTree != null && docTree.getName() == null;
|
|
}
|
|
|
|
@NonNull
|
|
@Override
|
|
public String toString() {
|
|
return (docTree == null ? Uri.fromFile(ioTree.toFile()) : docTree.getUri()).toString();
|
|
}
|
|
|
|
////////////////////
|
|
// Utils
|
|
///////////////////
|
|
|
|
private static void addIfStartWith(final List<String> list, @NonNull final String base,
|
|
final String str) {
|
|
if (isNullOrEmpty(str)) {
|
|
return;
|
|
}
|
|
final String lowerStr = str.toLowerCase();
|
|
if (lowerStr.startsWith(base)) {
|
|
list.add(lowerStr);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Splits the filename into the name and extension.
|
|
*
|
|
* @param filename The filename to split
|
|
* @return A String array with the name at index 0 and extension at index 1
|
|
*/
|
|
private static String[] splitFilename(@NonNull final String filename) {
|
|
final int dotIndex = filename.lastIndexOf('.');
|
|
|
|
if (dotIndex < 0 || (dotIndex == filename.length() - 1)) {
|
|
return new String[]{filename, ""};
|
|
}
|
|
|
|
return new String[]{filename.substring(0, dotIndex), filename.substring(dotIndex)};
|
|
}
|
|
|
|
private static String makeFileName(final String name, final int idx, final String ext) {
|
|
return name + "(" + idx + ")" + ext;
|
|
}
|
|
|
|
/**
|
|
* Fast (but not enough) file/directory finder under the storage access framework.
|
|
*
|
|
* @param context The context
|
|
* @param tree Directory where search
|
|
* @param filename Target filename
|
|
* @return A {@link DocumentFile} contain the reference, otherwise, null
|
|
*/
|
|
static DocumentFile findFileSAFHelper(@Nullable final Context context, final DocumentFile tree,
|
|
final String filename) {
|
|
if (context == null) {
|
|
return tree.findFile(filename); // warning: this is very slow
|
|
}
|
|
|
|
if (!tree.canRead()) {
|
|
return null; // missing read permission
|
|
}
|
|
|
|
final int name = 0;
|
|
final int documentId = 1;
|
|
|
|
// LOWER() SQL function is not supported
|
|
final String selection = COLUMN_DISPLAY_NAME + " = ?";
|
|
//final String selection = COLUMN_DISPLAY_NAME + " LIKE ?%";
|
|
|
|
final Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(tree.getUri(),
|
|
DocumentsContract.getDocumentId(tree.getUri()));
|
|
final String[] projection = {COLUMN_DISPLAY_NAME, COLUMN_DOCUMENT_ID};
|
|
final ContentResolver contentResolver = context.getContentResolver();
|
|
|
|
final String lowerFilename = filename.toLowerCase();
|
|
|
|
try (Cursor cursor = contentResolver.query(childrenUri, projection, selection,
|
|
new String[]{lowerFilename}, null)) {
|
|
if (cursor == null) {
|
|
return null;
|
|
}
|
|
|
|
while (cursor.moveToNext()) {
|
|
if (cursor.isNull(name)
|
|
|| !cursor.getString(name).toLowerCase().startsWith(lowerFilename)) {
|
|
continue;
|
|
}
|
|
|
|
return DocumentFile.fromSingleUri(context,
|
|
DocumentsContract.buildDocumentUriUsingTree(tree.getUri(),
|
|
cursor.getString(documentId)));
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public static Intent getPicker(final Context ctx) {
|
|
if (NewPipeSettings.useStorageAccessFramework(ctx)) {
|
|
return new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
|
.putExtra("android.content.extra.SHOW_ADVANCED", true)
|
|
.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
|
|
| StoredDirectoryHelper.PERMISSION_FLAGS);
|
|
} else {
|
|
return new Intent(ctx, FilePickerActivityHelper.class)
|
|
.putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false)
|
|
.putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true)
|
|
.putExtra(FilePickerActivityHelper.EXTRA_MODE,
|
|
FilePickerActivityHelper.MODE_DIR);
|
|
}
|
|
}
|
|
}
|