Android: Add facilities to handle more content URIs operations

Use DocumentFile and DocumentsContract to support more operations
on content URIs, such as:
* listing files and subdirectories with usable content uris
* mkdir, rmdir
* creating non-existing files under a tree uri
* remove

And since dealing with content URIs require some level of user
interation, manual tests were added to cover what's been implemented.

Note: parts of the code were from from BogDan Vatra <bogdan@kdab.com>.

Pick-to: 6.4 6.2
Task-number: QTBUG-98974
Task-number: QTBUG-104776
Change-Id: I3d64958ef26d0155210905b65daae2efa3db31c1
Reviewed-by: Ville Voutilainen <ville.voutilainen@qt.io>
This commit is contained in:
Assam Boudjelthia 2022-11-23 14:30:50 +02:00
parent b949f65f60
commit e5d591a0d0
6 changed files with 878 additions and 285 deletions

View File

@ -11,7 +11,6 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Objects;
import java.util.concurrent.Semaphore;
import java.util.HashMap;
import android.app.Activity;
import android.app.Service;
@ -83,8 +82,6 @@ public class QtNative
private static Boolean m_tabletEventSupported = null;
private static boolean m_usePrimaryClip = false;
public static QtThread m_qtThread = new QtThread();
private static HashMap<String, Uri> m_cachedUris = new HashMap<String, Uri>();
private static ArrayList<String> m_knownDirs = new ArrayList<String>();
private static final int KEYBOARD_HEIGHT_THRESHOLD = 100;
private static final String INVALID_OR_NULL_URI_ERROR_MESSAGE = "Received invalid/null Uri";
@ -217,209 +214,6 @@ public class QtNative
}
}
public static ParcelFileDescriptor openParcelFdForContentUrl(Context context, String contentUrl,
String openMode)
{
Uri uri = m_cachedUris.get(contentUrl);
if (uri == null)
uri = getUriWithValidPermission(context, contentUrl, openMode);
if (uri == null) {
Log.e(QtTAG, getCurrentMethodNameLog() + INVALID_OR_NULL_URI_ERROR_MESSAGE);
return null;
}
try {
final ContentResolver resolver = context.getContentResolver();
return resolver.openFileDescriptor(uri, openMode);
} catch (FileNotFoundException | IllegalArgumentException | SecurityException e) {
Log.e(QtTAG, getCurrentMethodNameLog() + e.toString());
}
return null;
}
public static FileDescriptor openFdObjectForContentUrl(Context context, String contentUrl,
String openMode)
{
final ParcelFileDescriptor pfd = openParcelFdForContentUrl(context, contentUrl, openMode);
if (pfd != null)
return pfd.getFileDescriptor();
return null;
}
public static int openFdForContentUrl(Context context, String contentUrl, String openMode)
{
Uri uri = m_cachedUris.get(contentUrl);
if (uri == null)
uri = getUriWithValidPermission(context, contentUrl, openMode);
int fileDescriptor = -1;
if (uri == null) {
Log.e(QtTAG, getCurrentMethodNameLog() + INVALID_OR_NULL_URI_ERROR_MESSAGE);
return fileDescriptor;
}
try {
final ContentResolver resolver = context.getContentResolver();
fileDescriptor = resolver.openFileDescriptor(uri, openMode).detachFd();
} catch (IllegalArgumentException | SecurityException | FileNotFoundException e) {
Log.e(QtTAG, getCurrentMethodNameLog() + e.toString());
}
return fileDescriptor;
}
public static long getSize(Context context, String contentUrl)
{
long size = -1;
Uri uri = m_cachedUris.get(contentUrl);
if (uri == null)
uri = getUriWithValidPermission(context, contentUrl, "r");
if (uri == null) {
Log.e(QtTAG, getCurrentMethodNameLog() + INVALID_OR_NULL_URI_ERROR_MESSAGE);
return size;
} else if (!m_cachedUris.containsKey(contentUrl)) {
m_cachedUris.put(contentUrl, uri);
}
try {
ContentResolver resolver = context.getContentResolver();
Cursor cur = resolver.query(uri, new String[] {
DocumentsContract.Document.COLUMN_SIZE },
null, null, null);
if (cur != null) {
if (cur.moveToFirst())
size = cur.getLong(0);
cur.close();
}
return size;
} catch (IllegalArgumentException | SecurityException | UnsupportedOperationException e) {
Log.e(QtTAG, getCurrentMethodNameLog() + e.toString());
}
return size;
}
public static boolean checkFileExists(Context context, String contentUrl)
{
boolean exists = false;
Uri uri = m_cachedUris.get(contentUrl);
if (uri == null)
uri = getUriWithValidPermission(context, contentUrl, "r");
if (uri == null) {
Log.e(QtTAG, getCurrentMethodNameLog() + INVALID_OR_NULL_URI_ERROR_MESSAGE);
return exists;
} else {
if (!m_cachedUris.containsKey(contentUrl))
m_cachedUris.put(contentUrl, uri);
}
try {
ContentResolver resolver = context.getContentResolver();
Cursor cur = resolver.query(uri, null, null, null, null);
if (cur != null) {
exists = true;
cur.close();
}
return exists;
} catch (IllegalArgumentException | SecurityException | UnsupportedOperationException e) {
Log.e(QtTAG, getCurrentMethodNameLog() + e.toString());
}
return exists;
}
public static boolean checkIfWritable(Context context, String contentUrl)
{
return getUriWithValidPermission(context, contentUrl, "w") != null;
}
public static boolean checkIfDir(Context context, String contentUrl)
{
boolean isDir = false;
Uri uri = m_cachedUris.get(contentUrl);
if (m_knownDirs.contains(contentUrl))
return true;
if (uri == null)
uri = getUriWithValidPermission(context, contentUrl, "r");
if (uri == null) {
Log.e(QtTAG, getCurrentMethodNameLog() + INVALID_OR_NULL_URI_ERROR_MESSAGE);
return isDir;
} else {
if (!m_cachedUris.containsKey(contentUrl))
m_cachedUris.put(contentUrl, uri);
}
try {
final List<String> paths = uri.getPathSegments();
// getTreeDocumentId will throw an exception if it is not a directory so check manually
if (!paths.get(0).equals("tree"))
return false;
ContentResolver resolver = context.getContentResolver();
Uri docUri = DocumentsContract.buildDocumentUriUsingTree(uri,
DocumentsContract.getTreeDocumentId(uri));
if (!docUri.toString().startsWith(uri.toString()))
return false;
Cursor cur = resolver.query(docUri, new String[] {
DocumentsContract.Document.COLUMN_MIME_TYPE },
null, null, null);
if (cur != null) {
if (cur.moveToFirst()) {
final String dirStr = new String(DocumentsContract.Document.MIME_TYPE_DIR);
isDir = cur.getString(0).equals(dirStr);
if (isDir)
m_knownDirs.add(contentUrl);
}
cur.close();
}
return isDir;
} catch (IllegalArgumentException | SecurityException | UnsupportedOperationException e) {
Log.e(QtTAG, getCurrentMethodNameLog() + e.toString());
}
return false;
}
public static String[] listContentsFromTreeUri(Context context, String contentUrl)
{
Uri treeUri = Uri.parse(contentUrl);
final ArrayList<String> results = new ArrayList<>();
if (treeUri == null) {
Log.e(QtTAG, getCurrentMethodNameLog() + INVALID_OR_NULL_URI_ERROR_MESSAGE);
return results.toArray(new String[results.size()]);
}
final ContentResolver resolver = context.getContentResolver();
final Uri docUri = DocumentsContract.buildDocumentUriUsingTree(treeUri,
DocumentsContract.getTreeDocumentId(treeUri));
final Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(docUri,
DocumentsContract.getDocumentId(docUri));
Cursor c;
final String dirStr = DocumentsContract.Document.MIME_TYPE_DIR;
try {
c = resolver.query(childrenUri, new String[] {
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE },
null, null, null);
while (c.moveToNext()) {
final String fileString = c.getString(1);
if (!m_cachedUris.containsKey(contentUrl + "/" + fileString)) {
m_cachedUris.put(contentUrl + "/" + fileString,
DocumentsContract.buildDocumentUriUsingTree(treeUri,
c.getString(0)));
}
results.add(fileString);
if (c.getString(2).equals(dirStr))
m_knownDirs.add(contentUrl + "/" + fileString);
}
c.close();
} catch (Exception e) {
Log.w(QtTAG, "Failed query: " + e);
return results.toArray(new String[results.size()]);
}
return results.toArray(new String[results.size()]);
}
// this method loads full path libs
public static void loadQtLibraries(final ArrayList<String> libraries)
{

View File

@ -1,5 +1,5 @@
// Copyright (C) 2019 Volker Krause <vkrause@kde.org>
// Copyright (C) 2021 The Qt Company Ltd.
// Copyright (C) 2019 Volker Krause <vkrause@kde.org>
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
#include "androidcontentfileengine.h"
@ -7,16 +7,29 @@
#include <QtCore/qcoreapplication.h>
#include <QtCore/qjnienvironment.h>
#include <QtCore/qjniobject.h>
#include <QDebug>
#include <QtCore/qurl.h>
#include <QtCore/qdatetime.h>
#include <QtCore/qmimedatabase.h>
using namespace QNativeInterface;
using namespace Qt::StringLiterals;
AndroidContentFileEngine::AndroidContentFileEngine(const QString &f)
: m_file(f)
static QJniObject &contentResolverInstance()
{
setFileName(f);
static QJniObject contentResolver;
if (!contentResolver.isValid()) {
contentResolver = QJniObject(QNativeInterface::QAndroidApplication::context())
.callObjectMethod("getContentResolver", "()Landroid/content/ContentResolver;");
}
return contentResolver;
}
AndroidContentFileEngine::AndroidContentFileEngine(const QString &filename)
: m_initialFile(filename),
m_documentFile(DocumentFile::parseFromAnyUri(filename))
{
setFileName(filename);
}
bool AndroidContentFileEngine::open(QIODevice::OpenMode openMode,
@ -29,6 +42,27 @@ bool AndroidContentFileEngine::open(QIODevice::OpenMode openMode,
}
if (openMode & QFileDevice::WriteOnly) {
openModeStr += u'w';
if (!m_documentFile->exists()) {
if (QUrl(m_initialFile).path().startsWith("/tree/"_L1)) {
const int lastSeparatorIndex = m_initialFile.lastIndexOf('/');
const QString fileName = m_initialFile.mid(lastSeparatorIndex + 1);
QString mimeType;
const auto mimeTypes = QMimeDatabase().mimeTypesForFileName(fileName);
if (!mimeTypes.empty())
mimeType = mimeTypes.first().name();
else
mimeType = "application/octet-stream";
if (m_documentFile->parent()) {
auto createdFile = m_documentFile->parent()->createFile(mimeType, fileName);
if (createdFile)
m_documentFile = createdFile;
}
} else {
qWarning() << "open(): non-existent content URI with a document type provided";
}
}
}
if (openMode & QFileDevice::Truncate) {
openModeStr += u't';
@ -36,12 +70,10 @@ bool AndroidContentFileEngine::open(QIODevice::OpenMode openMode,
openModeStr += u'a';
}
m_pfd = QJniObject::callStaticObjectMethod("org/qtproject/qt/android/QtNative",
"openParcelFdForContentUrl",
"(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;)Landroid/os/ParcelFileDescriptor;",
QAndroidApplication::context(),
QJniObject::fromString(fileName(DefaultName)).object(),
QJniObject::fromString(openModeStr).object());
m_pfd = contentResolverInstance().callObjectMethod("openFileDescriptor",
"(Landroid/net/Uri;Ljava/lang/String;)Landroid/os/ParcelFileDescriptor;",
m_documentFile->uri().object(),
QJniObject::fromString(openModeStr).object());
if (!m_pfd.isValid())
return false;
@ -49,8 +81,7 @@ bool AndroidContentFileEngine::open(QIODevice::OpenMode openMode,
const auto fd = m_pfd.callMethod<jint>("getFd", "()I");
if (fd < 0) {
m_pfd.callMethod<void>("close", "()V");
m_pfd = QJniObject();
closeNativeFileDescriptor();
return false;
}
@ -58,48 +89,122 @@ bool AndroidContentFileEngine::open(QIODevice::OpenMode openMode,
}
bool AndroidContentFileEngine::close()
{
closeNativeFileDescriptor();
return QFSFileEngine::close();
}
void AndroidContentFileEngine::closeNativeFileDescriptor()
{
if (m_pfd.isValid()) {
m_pfd.callMethod<void>("close", "()V");
m_pfd = QJniObject();
}
return QFSFileEngine::close();
}
qint64 AndroidContentFileEngine::size() const
{
const jlong size = QJniObject::callStaticMethod<jlong>(
"org/qtproject/qt/android/QtNative", "getSize",
"(Landroid/content/Context;Ljava/lang/String;)J", QAndroidApplication::context(),
QJniObject::fromString(fileName(DefaultName)).object());
return (qint64)size;
return m_documentFile->length();
}
bool AndroidContentFileEngine::remove()
{
return m_documentFile->remove();
}
bool AndroidContentFileEngine::mkdir(const QString &dirName, bool createParentDirectories,
std::optional<QFileDevice::Permissions> permissions) const
{
Q_UNUSED(permissions)
QString tmp = dirName;
tmp.remove(m_initialFile);
QStringList dirParts = tmp.split(u'/');
dirParts.removeAll("");
if (dirParts.isEmpty())
return false;
auto createdDir = m_documentFile;
bool allDirsCreated = true;
for (const auto &dir : dirParts) {
// Find if the sub-dir already exists and then don't re-create it
bool subDirExists = false;
for (const DocumentFilePtr &subDir : m_documentFile->listFiles()) {
if (dir == subDir->name() && subDir->isDirectory()) {
createdDir = subDir;
subDirExists = true;
}
}
if (!subDirExists) {
createdDir = createdDir->createDirectory(dir);
if (!createdDir) {
allDirsCreated = false;
break;
}
}
if (!createParentDirectories)
break;
}
return allDirsCreated;
}
bool AndroidContentFileEngine::rmdir(const QString &dirName, bool recurseParentDirectories) const
{
if (recurseParentDirectories)
qWarning() << "rmpath(): Unsupported for Content URIs";
const QString dirFileName = QUrl(dirName).fileName();
bool deleted = false;
for (const DocumentFilePtr &dir : m_documentFile->listFiles()) {
if (dirFileName == dir->name() && dir->isDirectory()) {
deleted = dir->remove();
break;
}
}
return deleted;
}
QByteArray AndroidContentFileEngine::id() const
{
return m_documentFile->id().toUtf8();
}
QDateTime AndroidContentFileEngine::fileTime(FileTime time) const
{
switch (time) {
case FileTime::ModificationTime:
return m_documentFile->lastModified();
break;
default:
break;
}
return QDateTime();
}
AndroidContentFileEngine::FileFlags AndroidContentFileEngine::fileFlags(FileFlags type) const
{
FileFlags commonFlags(ReadOwnerPerm|ReadUserPerm|ReadGroupPerm|ReadOtherPerm|ExistsFlag);
FileFlags flags;
const bool isDir = QJniObject::callStaticMethod<jboolean>(
"org/qtproject/qt/android/QtNative", "checkIfDir",
"(Landroid/content/Context;Ljava/lang/String;)Z", QAndroidApplication::context(),
QJniObject::fromString(fileName(DefaultName)).object());
// If it is a directory then we know it exists so there is no reason to explicitly check
const bool exists = isDir ? true : QJniObject::callStaticMethod<jboolean>(
"org/qtproject/qt/android/QtNative", "checkFileExists",
"(Landroid/content/Context;Ljava/lang/String;)Z", QAndroidApplication::context(),
QJniObject::fromString(fileName(DefaultName)).object());
if (!exists && !isDir)
if (!m_documentFile->exists())
return flags;
if (isDir) {
flags = DirectoryType | commonFlags;
flags = ExistsFlag;
if (!m_documentFile->canRead())
return flags;
flags |= ReadOwnerPerm|ReadUserPerm|ReadGroupPerm|ReadOtherPerm;
if (m_documentFile->isDirectory()) {
flags |= DirectoryType;
} else {
flags = FileType | commonFlags;
const bool writable = QJniObject::callStaticMethod<jboolean>(
"org/qtproject/qt/android/QtNative", "checkIfWritable",
"(Landroid/content/Context;Ljava/lang/String;)Z", QAndroidApplication::context(),
QJniObject::fromString(fileName(DefaultName)).object());
if (writable)
flags |= FileType;
if (m_documentFile->canWrite())
flags |= WriteOwnerPerm|WriteUserPerm|WriteGroupPerm|WriteOtherPerm;
}
return type & flags;
@ -114,18 +219,18 @@ QString AndroidContentFileEngine::fileName(FileName f) const
case DefaultName:
case AbsoluteName:
case CanonicalName:
return m_file;
return m_documentFile->uri().toString();
case BaseName:
{
const qsizetype pos = m_file.lastIndexOf(u'/');
return m_file.mid(pos);
}
return m_documentFile->name();
default:
return QString();
break;
}
return QString();
}
QAbstractFileEngine::Iterator *AndroidContentFileEngine::beginEntryList(QDir::Filters filters, const QStringList &filterNames)
QAbstractFileEngine::Iterator *AndroidContentFileEngine::beginEntryList(QDir::Filters filters,
const QStringList &filterNames)
{
return new AndroidContentFileEngineIterator(filters, filterNames);
}
@ -166,42 +271,468 @@ QString AndroidContentFileEngineIterator::next()
bool AndroidContentFileEngineIterator::hasNext() const
{
if (m_index == -1) {
if (path().isEmpty())
if (m_index == -1 && m_files.isEmpty()) {
const auto currentPath = path();
if (currentPath.isEmpty())
return false;
const bool isDir = QJniObject::callStaticMethod<jboolean>(
"org/qtproject/qt/android/QtNative", "checkIfDir",
"(Landroid/content/Context;Ljava/lang/String;)Z",
QAndroidApplication::context(),
QJniObject::fromString(path()).object());
if (isDir) {
QJniObject objArray = QJniObject::callStaticObjectMethod("org/qtproject/qt/android/QtNative",
"listContentsFromTreeUri",
"(Landroid/content/Context;Ljava/lang/String;)[Ljava/lang/String;",
QAndroidApplication::context(),
QJniObject::fromString(path()).object());
if (objArray.isValid()) {
QJniEnvironment env;
const jsize length = env->GetArrayLength(objArray.object<jarray>());
for (int i = 0; i != length; ++i) {
m_entries << QJniObject(env->GetObjectArrayElement(
objArray.object<jobjectArray>(), i)).toString();
}
}
}
m_index = 0;
const auto iterDoc = DocumentFile::parseFromAnyUri(currentPath);
if (iterDoc->isDirectory())
for (const auto &doc : iterDoc->listFiles())
m_files.append(doc);
}
return m_index < m_entries.size();
return m_index < (m_files.size() - 1);
}
QString AndroidContentFileEngineIterator::currentFileName() const
{
if (m_index <= 0 || m_index > m_entries.size())
if (m_index < 0 || m_index > m_files.size())
return QString();
return m_entries.at(m_index - 1);
// Returns a full path since contstructing a content path from the file name
// and a tree URI only will not point to a valid file URI.
return m_files.at(m_index)->uri().toString();
}
QString AndroidContentFileEngineIterator::currentFilePath() const
{
return currentFileName();
}
// Start of Cursor
class Cursor
{
public:
explicit Cursor(const QJniObject &object)
: m_object{object} { }
~Cursor()
{
if (m_object.isValid())
m_object.callMethod<void>("close");
}
enum Type {
FIELD_TYPE_NULL = 0x00000000,
FIELD_TYPE_INTEGER = 0x00000001,
FIELD_TYPE_FLOAT = 0x00000002,
FIELD_TYPE_STRING = 0x00000003,
FIELD_TYPE_BLOB = 0x00000004
};
QVariant data(int columnIndex) const
{
int type = m_object.callMethod<jint>("getType", "(I)I", columnIndex);
switch (type) {
case FIELD_TYPE_NULL:
return {};
case FIELD_TYPE_INTEGER:
return QVariant::fromValue(m_object.callMethod<jlong>("getLong", "(I)J", columnIndex));
case FIELD_TYPE_FLOAT:
return QVariant::fromValue(m_object.callMethod<jdouble>("getDouble", "(I)D",
columnIndex));
case FIELD_TYPE_STRING:
return QVariant::fromValue(m_object.callObjectMethod("getString",
"(I)Ljava/lang/String;",
columnIndex).toString());
case FIELD_TYPE_BLOB: {
auto blob = m_object.callObjectMethod("getBlob", "(I)[B", columnIndex);
QJniEnvironment env;
const auto blobArray = blob.object<jbyteArray>();
const int size = env->GetArrayLength(blobArray);
const auto byteArray = env->GetByteArrayElements(blobArray, nullptr);
QByteArray data{reinterpret_cast<const char *>(byteArray), size};
env->ReleaseByteArrayElements(blobArray, byteArray, 0);
return QVariant::fromValue(data);
}
}
return {};
}
static std::unique_ptr<Cursor> queryUri(const QJniObject &uri,
const QStringList &projection = {},
const QString &selection = {},
const QStringList &selectionArgs = {},
const QString &sortOrder = {})
{
auto cursor = contentResolverInstance().callObjectMethod("query",
"(Landroid/net/Uri;[Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;Ljava/lang/String;)Landroid/database/Cursor;",
uri.object(),
projection.isEmpty() ? nullptr : fromStringList(projection).object(),
selection.isEmpty() ? nullptr : QJniObject::fromString(selection).object(),
selectionArgs.isEmpty() ? nullptr : fromStringList(selectionArgs).object(),
sortOrder.isEmpty() ? nullptr : QJniObject::fromString(sortOrder).object());
if (!cursor.isValid())
return {};
return std::make_unique<Cursor>(cursor);
}
static QVariant queryColumn(const QJniObject &uri, const QString &column)
{
const auto query = queryUri(uri, {column});
if (!query)
return {};
if (query->rowCount() != 1 || query->columnCount() != 1)
return {};
query->moveToFirst();
return query->data(0);
}
bool isNull(int columnIndex) const
{
return m_object.callMethod<jboolean>("isNull", "(I)Z", columnIndex);
}
int columnCount() const { return m_object.callMethod<jint>("getColumnCount"); }
int rowCount() const { return m_object.callMethod<jint>("getCount"); }
int row() const { return m_object.callMethod<jint>("getPosition"); }
bool isFirst() const { return m_object.callMethod<jboolean>("isFirst"); }
bool isLast() const { return m_object.callMethod<jboolean>("isLast"); }
bool moveToFirst() { return m_object.callMethod<jboolean>("moveToFirst"); }
bool moveToLast() { return m_object.callMethod<jboolean>("moveToLast"); }
bool moveToNext() { return m_object.callMethod<jboolean>("moveToNext"); }
private:
static QJniObject fromStringList(const QStringList &list)
{
QJniEnvironment env;
auto array = env->NewObjectArray(list.size(), env->FindClass("java/lang/String"), nullptr);
for (int i = 0; i < list.size(); ++i)
env->SetObjectArrayElement(array, i, QJniObject::fromString(list[i]).object());
return QJniObject::fromLocalRef(array);
}
QJniObject m_object;
};
// End of Cursor
// Start of DocumentsContract
/*!
*
* DocumentsContract Api.
* Check https://developer.android.com/reference/android/provider/DocumentsContract
* for more information.
*
* \note This does not implement all facilities of the native API.
*
*/
namespace DocumentsContract
{
namespace Document {
const QLatin1String COLUMN_DISPLAY_NAME("_display_name");
const QLatin1String COLUMN_DOCUMENT_ID("document_id");
const QLatin1String COLUMN_FLAGS("flags");
const QLatin1String COLUMN_LAST_MODIFIED("last_modified");
const QLatin1String COLUMN_MIME_TYPE("mime_type");
const QLatin1String COLUMN_SIZE("_size");
constexpr int FLAG_DIR_SUPPORTS_CREATE = 0x00000008;
constexpr int FLAG_SUPPORTS_DELETE = 0x00000004;
constexpr int FLAG_SUPPORTS_WRITE = 0x00000002;
constexpr int FLAG_VIRTUAL_DOCUMENT = 0x00000200;
const QLatin1String MIME_TYPE_DIR("vnd.android.document/directory");
} // namespace Document
QString documentId(const QJniObject &uri)
{
return QJniObject::callStaticObjectMethod("android/provider/DocumentsContract",
"getDocumentId",
"(Landroid/net/Uri;)Ljava/lang/String;",
uri.object()).toString();
}
QString treeDocumentId(const QJniObject &uri)
{
return QJniObject::callStaticObjectMethod("android/provider/DocumentsContract",
"getTreeDocumentId",
"(Landroid/net/Uri;)Ljava/lang/String;",
uri.object()).toString();
}
QJniObject buildChildDocumentsUriUsingTree(const QJniObject &uri, const QString &parentDocumentId)
{
return QJniObject::callStaticObjectMethod("android/provider/DocumentsContract",
"buildChildDocumentsUriUsingTree",
"(Landroid/net/Uri;Ljava/lang/String;)Landroid/net/Uri;",
uri.object(),
QJniObject::fromString(parentDocumentId).object());
}
QJniObject buildDocumentUriUsingTree(const QJniObject &treeUri, const QString &documentId)
{
return QJniObject::callStaticObjectMethod("android/provider/DocumentsContract",
"buildDocumentUriUsingTree",
"(Landroid/net/Uri;Ljava/lang/String;)Landroid/net/Uri;",
treeUri.object(),
QJniObject::fromString(documentId).object());
}
bool isDocumentUri(const QJniObject &uri)
{
return QJniObject::callStaticMethod<jboolean>("android/provider/DocumentsContract",
"isDocumentUri",
"(Landroid/content/Context;Landroid/net/Uri;)Z",
QNativeInterface::QAndroidApplication::context(),
uri.object());
}
bool isTreeUri(const QJniObject &uri)
{
return QJniObject::callStaticMethod<jboolean>("android/provider/DocumentsContract",
"isTreeUri",
"(Landroid/net/Uri;)Z",
uri.object());
}
QJniObject createDocument(const QJniObject &parentDocumentUri, const QString &mimeType,
const QString &displayName)
{
return QJniObject::callStaticObjectMethod("android/provider/DocumentsContract",
"createDocument",
"(Landroid/content/ContentResolver;Landroid/net/Uri;Ljava/lang/String;Ljava/lang/String;)Landroid/net/Uri;",
contentResolverInstance().object(),
parentDocumentUri.object(),
QJniObject::fromString(mimeType).object(),
QJniObject::fromString(displayName).object());
}
bool deleteDocument(const QJniObject &documentUri)
{
const int flags = Cursor::queryColumn(documentUri, Document::COLUMN_FLAGS).toInt();
if (!(flags & Document::FLAG_SUPPORTS_DELETE))
return {};
return QJniObject::callStaticMethod<jboolean>("android/provider/DocumentsContract",
"deleteDocument",
"(Landroid/content/ContentResolver;Landroid/net/Uri;)Z",
contentResolverInstance().object(),
documentUri.object());
}
} // End DocumentsContract namespace
// Start of DocumentFile
using namespace DocumentsContract;
namespace {
class MakeableDocumentFile : public DocumentFile
{
public:
MakeableDocumentFile(const QJniObject &uri, const DocumentFilePtr &parent = {})
: DocumentFile(uri, parent)
{}
};
}
DocumentFile::DocumentFile(const QJniObject &uri,
const DocumentFilePtr &parent)
: m_uri{uri}
, m_parent{parent}
{}
QJniObject parseUri(const QString &uri)
{
return QJniObject::callStaticObjectMethod("android/net/Uri",
"parse",
"(Ljava/lang/String;)Landroid/net/Uri;",
QJniObject::fromString(uri).object());
}
DocumentFilePtr DocumentFile::parseFromAnyUri(const QString &fileName)
{
const QJniObject uri = parseUri(fileName);
if (DocumentsContract::isDocumentUri(uri))
return fromSingleUri(uri);
const QString documentType = "/document/"_L1;
const QString treeType = "/tree/"_L1;
const int treeIndex = fileName.indexOf(treeType);
const int documentIndex = fileName.indexOf(documentType);
const int index = fileName.lastIndexOf("/");
if (index <= treeIndex + treeType.size() || index <= documentIndex + documentType.size())
return fromTreeUri(uri);
const QString parentUrl = fileName.left(index);
DocumentFilePtr parentDocFile = fromTreeUri(parseUri(parentUrl));
const QString baseName = fileName.mid(index);
const QString fileUrl = parentUrl + QUrl::toPercentEncoding(baseName);
DocumentFilePtr docFile = std::make_shared<MakeableDocumentFile>(parseUri(fileUrl));
if (parentDocFile && parentDocFile->isDirectory())
docFile->m_parent = parentDocFile;
return docFile;
}
DocumentFilePtr DocumentFile::fromSingleUri(const QJniObject &uri)
{
return std::make_shared<MakeableDocumentFile>(uri);
}
DocumentFilePtr DocumentFile::fromTreeUri(const QJniObject &treeUri)
{
QString docId;
if (isDocumentUri(treeUri))
docId = documentId(treeUri);
else
docId = treeDocumentId(treeUri);
return std::make_shared<MakeableDocumentFile>(buildDocumentUriUsingTree(treeUri, docId));
}
DocumentFilePtr DocumentFile::createFile(const QString &mimeType, const QString &displayName)
{
if (isDirectory()) {
return std::make_shared<MakeableDocumentFile>(
createDocument(m_uri, mimeType, displayName),
shared_from_this());
}
return {};
}
DocumentFilePtr DocumentFile::createDirectory(const QString &displayName)
{
if (isDirectory()) {
return std::make_shared<MakeableDocumentFile>(
createDocument(m_uri, Document::MIME_TYPE_DIR, displayName),
shared_from_this());
}
return {};
}
const QJniObject &DocumentFile::uri() const
{
return m_uri;
}
const DocumentFilePtr &DocumentFile::parent() const
{
return m_parent;
}
QString DocumentFile::name() const
{
return Cursor::queryColumn(m_uri, Document::COLUMN_DISPLAY_NAME).toString();
}
QString DocumentFile::id() const
{
return DocumentsContract::documentId(uri());
}
QString DocumentFile::mimeType() const
{
return Cursor::queryColumn(m_uri, Document::COLUMN_MIME_TYPE).toString();
}
bool DocumentFile::isDirectory() const
{
return mimeType() == Document::MIME_TYPE_DIR;
}
bool DocumentFile::isFile() const
{
const QString type = mimeType();
return type != Document::MIME_TYPE_DIR && !type.isEmpty();
}
bool DocumentFile::isVirtual() const
{
return isDocumentUri(m_uri) && (Cursor::queryColumn(m_uri,
Document::COLUMN_FLAGS).toInt() & Document::FLAG_VIRTUAL_DOCUMENT);
}
QDateTime DocumentFile::lastModified() const
{
const auto timeVariant = Cursor::queryColumn(m_uri, Document::COLUMN_LAST_MODIFIED);
if (timeVariant.isValid())
return QDateTime::fromMSecsSinceEpoch(timeVariant.toLongLong());
return {};
}
int64_t DocumentFile::length() const
{
return Cursor::queryColumn(m_uri, Document::COLUMN_SIZE).toLongLong();
}
namespace {
constexpr int FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;
constexpr int FLAG_GRANT_WRITE_URI_PERMISSION = 0x00000002;
}
bool DocumentFile::canRead() const
{
const auto context = QJniObject(QNativeInterface::QAndroidApplication::context());
const bool selfUriPermission = context.callMethod<jint>("checkCallingOrSelfUriPermission",
"(Landroid/net/Uri;I)I",
m_uri.object(),
FLAG_GRANT_READ_URI_PERMISSION);
if (selfUriPermission != 0)
return false;
return !mimeType().isEmpty();
}
bool DocumentFile::canWrite() const
{
const auto context = QJniObject(QNativeInterface::QAndroidApplication::context());
const bool selfUriPermission = context.callMethod<jint>("checkCallingOrSelfUriPermission",
"(Landroid/net/Uri;I)I",
m_uri.object(),
FLAG_GRANT_WRITE_URI_PERMISSION);
if (selfUriPermission != 0)
return false;
const QString type = mimeType();
if (type.isEmpty())
return false;
const int flags = Cursor::queryColumn(m_uri, Document::COLUMN_FLAGS).toInt();
if (flags & Document::FLAG_SUPPORTS_DELETE)
return true;
const bool supportsWrite = (flags & Document::FLAG_SUPPORTS_WRITE);
const bool isDir = (type == Document::MIME_TYPE_DIR);
const bool dirSupportsCreate = (isDir && (flags & Document::FLAG_DIR_SUPPORTS_CREATE));
return dirSupportsCreate || supportsWrite;
}
bool DocumentFile::remove()
{
return deleteDocument(m_uri);
}
bool DocumentFile::exists() const
{
return !name().isEmpty();
}
std::vector<DocumentFilePtr> DocumentFile::listFiles()
{
std::vector<DocumentFilePtr> res;
const auto childrenUri = buildChildDocumentsUriUsingTree(m_uri, documentId(m_uri));
const auto query = Cursor::queryUri(childrenUri, {Document::COLUMN_DOCUMENT_ID});
if (!query)
return res;
while (query->moveToNext()) {
const auto uri = buildDocumentUriUsingTree(m_uri, query->data(0).toString());
res.push_back(std::make_shared<MakeableDocumentFile>(uri, shared_from_this()));
}
return res;
}
// End of DocumentFile

View File

@ -5,7 +5,11 @@
#define ANDROIDCONTENTFILEENGINE_H
#include <private/qfsfileengine_p.h>
#include <QtCore/qjniobject.h>
#include <QtCore/qlist.h>
using DocumentFilePtr = std::shared_ptr<class DocumentFile>;
class AndroidContentFileEngine : public QFSFileEngine
{
@ -14,14 +18,24 @@ public:
bool open(QIODevice::OpenMode openMode, std::optional<QFile::Permissions> permissions) override;
bool close() override;
qint64 size() const override;
bool remove() override;
bool mkdir(const QString &dirName, bool createParentDirectories,
std::optional<QFile::Permissions> permissions = std::nullopt) const override;
bool rmdir(const QString &dirName, bool recurseParentDirectories) const override;
QByteArray id() const override;
bool caseSensitive() const override { return true; }
QDateTime fileTime(FileTime time) const override;
FileFlags fileFlags(FileFlags type = FileInfoAll) const override;
QString fileName(FileName file = DefaultName) const override;
QAbstractFileEngine::Iterator *beginEntryList(QDir::Filters filters, const QStringList &filterNames) override;
QAbstractFileEngine::Iterator *endEntryList() override;
private:
QString m_file;
QJniObject m_pfd;
private:
void closeNativeFileDescriptor();
QString m_initialFile;
QJniObject m_pfd;
DocumentFilePtr m_documentFile;
};
class AndroidContentFileEngineHandler : public QAbstractFileEngineHandler
@ -42,8 +56,48 @@ public:
QString currentFileName() const override;
QString currentFilePath() const override;
private:
mutable QStringList m_entries;
mutable int m_index = -1;
mutable QList<DocumentFilePtr> m_files;
mutable qsizetype m_index = -1;
};
/*!
*
* DocumentFile Api.
* Check https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile
* for more information.
*
*/
class DocumentFile : public std::enable_shared_from_this<DocumentFile>
{
public:
static DocumentFilePtr parseFromAnyUri(const QString &filename);
static DocumentFilePtr fromSingleUri(const QJniObject &uri);
static DocumentFilePtr fromTreeUri(const QJniObject &treeUri);
DocumentFilePtr createFile(const QString &mimeType, const QString &displayName);
DocumentFilePtr createDirectory(const QString &displayName);
const QJniObject &uri() const;
const DocumentFilePtr &parent() const;
QString name() const;
QString id() const;
QString mimeType() const;
bool isDirectory() const;
bool isFile() const;
bool isVirtual() const;
QDateTime lastModified() const;
int64_t length() const;
bool canRead() const;
bool canWrite() const;
bool remove();
bool exists() const;
std::vector<DocumentFilePtr> listFiles();
protected:
DocumentFile(const QJniObject &uri, const std::shared_ptr<DocumentFile> &parent);
protected:
QJniObject m_uri;
DocumentFilePtr m_parent;
};
#endif // ANDROIDCONTENTFILEENGINE_H

View File

@ -89,3 +89,7 @@ endif()
if(QT_FEATURE_vulkan)
add_subdirectory(qvulkaninstance)
endif()
if(ANDROID)
add_subdirectory(android_content_uri)
endif()

View File

@ -0,0 +1,7 @@
qt_internal_add_test(tst_content_uris
SOURCES
tst_content_uris.cpp
LIBRARIES
Qt::CorePrivate
Qt::Widgets
)

View File

@ -0,0 +1,203 @@
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0
#include <QTest>
#include <QDirIterator>
#include <QFileDialog>
#include <QMessageBox>
using namespace Qt::StringLiterals;
class tst_ContentUris: public QObject
{
Q_OBJECT
private slots:
void dirFacilities();
void readWriteFile();
void readWriteNonExistingFile_data();
void readWriteNonExistingFile();
void createFileFromDirUrl_data();
void createFileFromDirUrl();
void fileOperations();
};
static QStringList listFiles(const QDir &dir, QDirIterator::IteratorFlag flag = {})
{
QDirIterator it(dir, flag);
QStringList dirs;
while (it.hasNext())
dirs << it.next();
return dirs;
}
void showInstructionsDialog(const QString &message)
{
QMessageBox::information(nullptr, "Instructions", message);
}
void tst_ContentUris::dirFacilities()
{
showInstructionsDialog("Choose a folder with no content/files/subdirs");
auto url = QFileDialog::getExistingDirectory();
QVERIFY(url.startsWith("content"_L1));
QDir dir(url);
QVERIFY(dir.exists());
QVERIFY(!dir.dirName().isEmpty());
QVERIFY(listFiles(dir).isEmpty());
QVERIFY(dir.mkdir("Sub"));
const auto dirList = listFiles(dir);
QVERIFY(dirList.size() == 1);
const QDir subDir = dirList.first();
QVERIFY(subDir.dirName() == "Sub"_L1);
QEXPECT_FAIL("", "absolutePath() is returning wrong path, cutting from 'primary' onward", Continue);
qWarning() << "subDir.absolutePath()" << subDir.absolutePath() << dirList.first();
QVERIFY(subDir.absolutePath() == dirList.first());
QVERIFY(subDir.path() == dirList.first());
QVERIFY(listFiles(dir, QDirIterator::Subdirectories).size() == 1);
QVERIFY(dir.mkdir("Sub")); // Create an existing dir
QVERIFY(dir.rmdir("Sub"));
QVERIFY(dir.mkpath("Sub/Sub2/Sub3"));
QVERIFY(listFiles(dir).size() == 1);
QVERIFY(listFiles(dir, QDirIterator::Subdirectories).size() == 3);
QVERIFY(dir.mkpath("Sub/Sub2/Sub3")); // Create an existing dir hierarchy
QVERIFY(dir.rmdir("Sub"));
}
void tst_ContentUris::readWriteFile()
{
const QByteArray content = "Written to file";
const QString fileName = "new_file.txt";
{
showInstructionsDialog("Choose a name for new file to create");
auto url = QFileDialog::getSaveFileName(nullptr, tr("Save File"), fileName);
QFile file(url);
QVERIFY(file.exists());
QVERIFY(file.size() == 0);
QVERIFY(file.fileName() == url);
QVERIFY(QFileInfo(url).baseName() == fileName);
QVERIFY(file.open(QFile::WriteOnly));
QVERIFY(file.isOpen());
QVERIFY(file.isWritable());
QVERIFY(file.fileTime(QFileDevice::FileModificationTime) != QDateTime());
QVERIFY(file.write(content) > 0);
QVERIFY(file.size() == content.size());
file.close();
// NOTE: The native file cursor is not returning an updated time or it takes long
// for it to get updated, for now just check that we actually received a valid QDateTime
QVERIFY(file.fileTime(QFileDevice::FileModificationTime) != QDateTime());
}
{
showInstructionsDialog("Choose the file that was created");
auto url = QFileDialog::getOpenFileName(nullptr, tr("Open File"), fileName);
QFile file(url);
QVERIFY(file.exists());
QVERIFY(file.open(QFile::ReadOnly));
QVERIFY(file.isOpen());
QVERIFY(file.isReadable());
QVERIFY(file.readAll() == content);
QVERIFY(file.remove());
}
}
void tst_ContentUris::readWriteNonExistingFile_data()
{
QTest::addColumn<QString>("path");
const QString fileName = "non-existing-file.txt";
const QString uriSchemeAuthority = "content://com.android.externalstorage.documents";
const QString id = "primary%3APictures";
const QString encSlash = QUrl::toPercentEncoding("/"_L1);
const QString docSlash = uriSchemeAuthority + "/document/"_L1 + id + "/"_L1 + fileName;
const QString docEncodedSlash = uriSchemeAuthority + "/document/"_L1 + id + encSlash + fileName;
QTest::newRow("document_with_slash") << docSlash;
QTest::newRow("document_with_encoded_slash") << docEncodedSlash;
}
void tst_ContentUris::readWriteNonExistingFile()
{
QFETCH(QString, path);
QFile file(path);
QVERIFY(!file.exists());
QVERIFY(file.size() == 0);
QVERIFY(!file.open(QFile::WriteOnly));
QVERIFY(!file.isOpen());
QVERIFY(!file.isWritable());
}
void tst_ContentUris::createFileFromDirUrl_data()
{
QTest::addColumn<QString>("path");
showInstructionsDialog("Choose a folder with no content/files/subdirs");
const QString treeUrl = QFileDialog::getExistingDirectory();
const QString fileName = "text.txt";
const QString treeSlash = treeUrl + "/"_L1 + fileName;
QTest::newRow("tree_with_slash") << treeSlash;
// TODO: This is not handled at the moment
// const QString encSlash = QUrl::toPercentEncoding("/"_L1);
// const QString treeEncodedSlash = treeUrl + encSlash + fileName;
// QTest::newRow("tree_with_encoded_slash") << treeEncodedSlash;
}
void tst_ContentUris::createFileFromDirUrl()
{
QFETCH(QString, path);
const QByteArray content = "Written to file";
QFile file(path);
QVERIFY(!file.exists());
QVERIFY(file.size() == 0);
QVERIFY(file.open(QFile::WriteOnly));
QVERIFY(file.isOpen());
QVERIFY(file.isWritable());
QVERIFY(file.exists());
QVERIFY(file.write(content));
QVERIFY(file.size() == content.size());
file.close();
QVERIFY(file.open(QFile::ReadOnly));
QVERIFY(file.isOpen());
QVERIFY(file.isReadable());
QVERIFY(file.readAll() == content);
QVERIFY(file.remove());
}
void tst_ContentUris::fileOperations()
{
showInstructionsDialog("Choose a name for new file to create");
const QString fileName = "new_file.txt";
auto url = QFileDialog::getSaveFileName(nullptr, tr("Save File"), fileName);
QFile file(url);
QVERIFY(file.exists());
QVERIFY(file.remove());
QVERIFY(!file.exists());
}
QTEST_MAIN(tst_ContentUris)
#include "tst_content_uris.moc"