diff --git a/src/app/platform/android/src/org/pegasus_frontend/android/MainActivity.java b/src/app/platform/android/src/org/pegasus_frontend/android/MainActivity.java index 97278886f..486e6d10b 100644 --- a/src/app/platform/android/src/org/pegasus_frontend/android/MainActivity.java +++ b/src/app/platform/android/src/org/pegasus_frontend/android/MainActivity.java @@ -22,6 +22,7 @@ import android.app.Activity; import android.content.Intent; import android.content.IntentFilter; +import android.content.UriPermission; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; @@ -174,6 +175,23 @@ public static String[] sdcardPaths() { } + public static String[] grantedPaths() { + List paths = new ArrayList(); + for (UriPermission uriperm : m_self.getContentResolver().getPersistedUriPermissions()) { + final Uri uri = uriperm.getUri(); + paths.add(uri.getPath()); + } + return paths.toArray(new String[paths.size()]); + } + + + public static void rememberGrantedPath(Uri uri) { + m_self + .getContentResolver() + .takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION); + } + + public static boolean getAllStorageAccess() { if (Build.VERSION.SDK_INT < 30) return true; diff --git a/src/backend/model/internal/settings/Settings.cpp b/src/backend/model/internal/settings/Settings.cpp index 63a827c7b..caf2ed8c4 100644 --- a/src/backend/model/internal/settings/Settings.cpp +++ b/src/backend/model/internal/settings/Settings.cpp @@ -22,12 +22,21 @@ #include "Paths.h" #include "utils/PathTools.h" +#ifdef Q_OS_ANDROID +#include "platform/AndroidHelpers.h" +#endif + #include #include #include #include #include +#ifdef Q_OS_ANDROID +#include +#include +#endif + namespace { @@ -191,6 +200,24 @@ void Settings::removeGameDirs(const QVariantList& idx_var_list) emit gameDirsChanged(); } +QStringList Settings::androidGrantedDirs() const +{ +#ifdef Q_OS_ANDROID + return android::granted_paths(); +#else + return {}; +#endif +} + +void Settings::requestAndroidDir() +{ +#ifdef Q_OS_ANDROID + android::request_saf_permission([this](){ + emit androidDirsChanged(); + }); +#endif +} + void Settings::reloadProviders() { Log::info(LOGMSG("Reloading...")); diff --git a/src/backend/model/internal/settings/Settings.h b/src/backend/model/internal/settings/Settings.h index 91fe61c70..9b1121f1c 100644 --- a/src/backend/model/internal/settings/Settings.h +++ b/src/backend/model/internal/settings/Settings.h @@ -43,6 +43,7 @@ class Settings : public QObject { READ verifyFiles WRITE setVerifyFiles NOTIFY verifyFilesChanged) Q_PROPERTY(QStringList gameDirs READ gameDirs NOTIFY gameDirsChanged) + Q_PROPERTY(QStringList androidGrantedDirs READ androidGrantedDirs NOTIFY androidDirsChanged) QML_CONST_PROPERTY(model::KeyEditor, keyEditor) QML_CONST_PROPERTY(model::Locales, locales) @@ -66,6 +67,9 @@ class Settings : public QObject { Q_INVOKABLE void addGameDir(const QString&); Q_INVOKABLE void removeGameDirs(const QVariantList&); + QStringList androidGrantedDirs() const; + Q_INVOKABLE void requestAndroidDir(); + Q_INVOKABLE void reloadProviders(); signals: @@ -73,6 +77,7 @@ class Settings : public QObject { void mouseSupportChanged(); void verifyFilesChanged(); void gameDirsChanged(); + void androidDirsChanged(); void providerReloadingRequested(); }; diff --git a/src/backend/platform/AndroidHelpers.cpp b/src/backend/platform/AndroidHelpers.cpp index dd93de6af..85e2826e4 100644 --- a/src/backend/platform/AndroidHelpers.cpp +++ b/src/backend/platform/AndroidHelpers.cpp @@ -21,11 +21,35 @@ #include // Required for PermissionResultMap #include #include +#include #include #include #include +namespace { +QStringList query_string_array(const char* const method) +{ + static constexpr auto JNI_SIGNATURE = "()[Ljava/lang/String;"; + + QAndroidJniEnvironment jni_env; + const auto jni_path_arr_raw = QAndroidJniObject::callStaticObjectMethod(android::jni_classname(), method, JNI_SIGNATURE); + const auto jni_path_arr = jni_path_arr_raw.object(); + const jsize path_count = jni_env->GetArrayLength(jni_path_arr); + + QStringList out; + out.reserve(path_count); + + for (jsize i = 0; i < path_count; i++) { + const auto jni_path_raw = QAndroidJniObject(jni_env->GetObjectArrayElement(jni_path_arr, i)); + out.append(jni_path_raw.toString()); // TODO: Qt 6 emplace_back + } + + return out; +} +} // namespace + + namespace android { const char* jni_classname() { @@ -38,28 +62,41 @@ QString primary_storage_path() return QStandardPaths::standardLocations(QStandardPaths::GenericDataLocation).constFirst(); } -std::vector storage_paths() +QStringList storage_paths() { - static constexpr auto JNI_METHOD = "sdcardPaths"; - static constexpr auto JNI_SIGNATURE = "()[Ljava/lang/String;"; + QStringList paths = query_string_array("sdcardPaths"); + if (paths.empty()) + paths.append(primary_storage_path()); // TODO: Qt 6 emplace_back + return paths; +} - QAndroidJniEnvironment jni_env; - const auto jni_path_arr_raw = QAndroidJniObject::callStaticObjectMethod(jni_classname(), JNI_METHOD, JNI_SIGNATURE); - const auto jni_path_arr = jni_path_arr_raw.object(); - const jsize path_count = jni_env->GetArrayLength(jni_path_arr); +QStringList granted_paths() +{ + return query_string_array("grantedPaths"); +} - std::vector out; - out.reserve(static_cast(path_count)); +void request_saf_permission(const std::function& cb_success) +{ + constexpr int REQ_OPEN_DOCUMENT_TREE = 0x1; - for (jsize i = 0; i < path_count; i++) { - const auto jni_path_raw = QAndroidJniObject(jni_env->GetObjectArrayElement(jni_path_arr, i)); - out.emplace_back(jni_path_raw.toString()); - } + const auto activity_cb = [&cb_success](int requestCode, int resultCode, const QAndroidJniObject& data) { + const jint RESULT_OK = QAndroidJniObject::getStaticField("android/app/Activity", "RESULT_OK"); + if (requestCode != REQ_OPEN_DOCUMENT_TREE || resultCode != RESULT_OK || !data.isValid()) + return; - if (out.empty()) - out.emplace_back(primary_storage_path()); + const QAndroidJniObject uri = data.callObjectMethod("getData", "()Landroid/net/Uri;"); + if (!uri.isValid()) + return; - return out; + static constexpr auto REMEMBER_FN = "rememberGrantedPath"; + static constexpr auto REMEMBER_SIGN = "(Landroid/net/Uri;)V"; + QAndroidJniObject::callStaticMethod(jni_classname(), REMEMBER_FN, REMEMBER_SIGN, uri.object()); + + cb_success(); + }; + + QAndroidIntent intent(QStringLiteral("android.intent.action.OPEN_DOCUMENT_TREE")); + QtAndroid::startActivity(intent.handle(), REQ_OPEN_DOCUMENT_TREE, activity_cb); } bool has_external_storage_access() diff --git a/src/backend/platform/AndroidHelpers.h b/src/backend/platform/AndroidHelpers.h index 0b37c6cc5..46572b506 100644 --- a/src/backend/platform/AndroidHelpers.h +++ b/src/backend/platform/AndroidHelpers.h @@ -18,15 +18,19 @@ #pragma once #include -#include namespace android { const char* jni_classname(); + QString primary_storage_path(); -std::vector storage_paths(); +QStringList storage_paths(); bool has_external_storage_access(); + +QStringList granted_paths(); +void request_saf_permission(const std::function&); + QString run_am_call(const QStringList&); QString to_content_uri(const QString&); QString to_document_uri(const QString&); diff --git a/src/backend/utils/FolderListModel.cpp b/src/backend/utils/FolderListModel.cpp index c8e89d64a..6f38e73cb 100644 --- a/src/backend/utils/FolderListModel.cpp +++ b/src/backend/utils/FolderListModel.cpp @@ -33,7 +33,7 @@ void erase_if(QFileInfoList& list, const std::function& list.erase(start_it, list.end()); } -std::vector drives() +QStringList drives() { #if defined(Q_OS_ANDROID) return android::storage_paths(); @@ -43,11 +43,11 @@ std::vector drives() return { QStringLiteral("/") }; #else - std::vector out; + QStringList out; const QFileInfoList drive_files = QDir::drives(); for (const QFileInfo& file : drive_files) - out.emplace_back(::pretty_dir(file)); + out.append(::pretty_dir(file)); // TODO: Qt 6 emplace_back return out; #endif @@ -63,9 +63,9 @@ QDir startup_dir() #endif } -bool is_drive_root(const QString& path, const std::vector& drives) +bool is_drive_root(const QString& path, const QStringList& drives) { - return VEC_CONTAINS(drives, path); + return drives.contains(path); } } // namespace diff --git a/src/backend/utils/FolderListModel.h b/src/backend/utils/FolderListModel.h index 98d9269e1..f49b1dc32 100644 --- a/src/backend/utils/FolderListModel.h +++ b/src/backend/utils/FolderListModel.h @@ -74,6 +74,6 @@ class FolderListModel : public QAbstractListModel { QStringList m_filenames; QStringList m_extensions; - const std::vector m_drives_cache; + const QStringList m_drives_cache; const QHash m_role_names; }; diff --git a/src/frontend/frontend.qrc b/src/frontend/frontend.qrc index a8085d20e..529900637 100644 --- a/src/frontend/frontend.qrc +++ b/src/frontend/frontend.qrc @@ -78,5 +78,7 @@ menu/settings/gamepad/GamepadFooter.qml menu/settings/gamepad/GamepadHeader.qml menu/settings/gamedireditor/GameDirEditorEntry.qml + menu/settings/AndroidSafEditor.qml + menu/settings/common/SimpleShade.qml diff --git a/src/frontend/menu/settings/AndroidSafEditor.qml b/src/frontend/menu/settings/AndroidSafEditor.qml new file mode 100644 index 000000000..17b79ad8b --- /dev/null +++ b/src/frontend/menu/settings/AndroidSafEditor.qml @@ -0,0 +1,146 @@ +// Pegasus Frontend +// Copyright (C) 2017-2018 Mátyás Mustoha +// +// 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, see . + + +import "common" +import "gamedireditor" +import QtQuick 2.6 +import QtQuick.Layouts 1.3 + + +FocusScope { + id: root + + signal close + + anchors.fill: parent + + enabled: focus + visible: opacity > 0.001 + opacity: focus ? 1.0 : 0.0 + Behavior on opacity { PropertyAnimation { duration: 150 } } + + Keys.onPressed: { + if (api.keys.isCancel(event) && !event.isAutoRepeat) { + event.accepted = true; + root.close(); + } + } + + + SimpleShade { + onClicked: root.close() + } + + + Rectangle { + id: main + + readonly property int borderSize: vpx(10) + + height: parent.height * 0.8 + width: Math.min(height * 1.5, parent.width) + anchors.centerIn: parent + + color: "#444" + radius: vpx(8) + + MouseArea { + anchors.fill: parent + } + + Text { + id: info + + text: qsTr("Some Android apps may not launch games unless you manually allow access " + + "to the game's directory, in both Pegasus and the launched app. Pegasus " + + "have permission for the following locations:") + api.tr + color: "#eee" + font.family: globalFonts.sans + font.pixelSize: vpx(18) + lineHeight: 1.15 + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + padding: font.pixelSize * lineHeight + + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + wrapMode: Text.WordWrap + } + + RowLayout { + anchors.top: info.bottom + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: main.borderSize + anchors.topMargin: 0 + + spacing: main.borderSize + + Rectangle { + Layout.fillHeight: true + Layout.fillWidth: true + color: "#333" + + ListView { + id: list + + anchors.fill: parent + clip: true + + model: Internal.settings.androidGrantedDirs + delegate: GameDirEditorEntry {} + + focus: true + highlightRangeMode: ListView.ApplyRange + preferredHighlightBegin: height * 0.5 - vpx(18) * 1.25 + preferredHighlightEnd: height * 0.5 + vpx(18) * 1.25 + highlightMoveDuration: 0 + + KeyNavigation.right: buttonAdd + + MouseArea { + anchors.fill: parent + onClicked: { + const new_idx = list.indexAt(mouse.x, list.contentY + mouse.y); + if (new_idx >= 0) { + list.currentIndex = new_idx; + } + } + } + } + } + + Column { + id: buttonArea + + Layout.fillHeight: true + spacing: main.borderSize + + GameDirEditorButton { + id: buttonAdd + + icon: "+" + color: "#4c5" + onPressed: Internal.settings.requestAndroidDir() + } + } + } + } +} diff --git a/src/frontend/menu/settings/GameDirEditor.qml b/src/frontend/menu/settings/GameDirEditor.qml index 1709ee3a8..c7725f86f 100644 --- a/src/frontend/menu/settings/GameDirEditor.qml +++ b/src/frontend/menu/settings/GameDirEditor.qml @@ -65,18 +65,8 @@ FocusScope { } - Rectangle { - id: shade - - anchors.fill: parent - color: "#000" - opacity: 0.3 - - MouseArea { - anchors.fill: parent - acceptedButtons: Qt.LeftButton | Qt.RightButton - onClicked: root.closeMaybe() - } + SimpleShade { + onClicked: root.closeMaybe() } diff --git a/src/frontend/menu/settings/SettingsEntry.qml b/src/frontend/menu/settings/SettingsEntry.qml index 8feb5e614..47128d74a 100644 --- a/src/frontend/menu/settings/SettingsEntry.qml +++ b/src/frontend/menu/settings/SettingsEntry.qml @@ -36,4 +36,6 @@ QtObject { property string selectValue property var buttonAction + + property bool enabled: true } diff --git a/src/frontend/menu/settings/SettingsMain.qml b/src/frontend/menu/settings/SettingsMain.qml index eb7240174..0d6fdd89a 100644 --- a/src/frontend/menu/settings/SettingsMain.qml +++ b/src/frontend/menu/settings/SettingsMain.qml @@ -29,6 +29,7 @@ FocusScope { signal openKeySettings signal openGamepadSettings signal openGameDirSettings + signal openAndroidSafSettings signal openProviderSettings width: parent.width @@ -114,6 +115,13 @@ FocusScope { buttonAction: root.openGameDirSettings section: "gaming" }, + SettingsEntry { + label: QT_TR_NOOP("Accessible Android directories...") + type: SettingsEntry.Type.Button + buttonAction: root.openAndroidSafSettings + section: "gaming" + enabled: Qt.platform.os === "android" + }, SettingsEntry { label: QT_TR_NOOP("Only show existing games") desc: QT_TR_NOOP("Check the game files and only show games that actually exist. You can disable this to improve loading times.") @@ -149,6 +157,7 @@ FocusScope { SimpleButton { label: qsTr(model.label) + api.tr onActivate: model.buttonAction() + enabled: model.enabled } } diff --git a/src/frontend/menu/settings/SettingsScreen.qml b/src/frontend/menu/settings/SettingsScreen.qml index b28b697f1..4525ff39f 100644 --- a/src/frontend/menu/settings/SettingsScreen.qml +++ b/src/frontend/menu/settings/SettingsScreen.qml @@ -50,6 +50,7 @@ FocusScope { onOpenKeySettings: root.openScreen("KeyEditor.qml") onOpenGamepadSettings: root.openScreen("GamepadEditor.qml") onOpenGameDirSettings: root.openModal("GameDirEditor.qml") + onOpenAndroidSafSettings: root.openModal("AndroidSafEditor.qml") onOpenProviderSettings: root.openModal("ProviderEditor.qml") } diff --git a/src/frontend/menu/settings/common/SimpleButton.qml b/src/frontend/menu/settings/common/SimpleButton.qml index a13645611..e4cf492e3 100644 --- a/src/frontend/menu/settings/common/SimpleButton.qml +++ b/src/frontend/menu/settings/common/SimpleButton.qml @@ -31,6 +31,7 @@ FocusScope { width: parent.width height: fontSize * 2.5 + opacity: enabled ? 1.0 : 0.25 Keys.onPressed: { if (api.keys.isAccept(event) && !event.isAutoRepeat) { diff --git a/src/frontend/menu/settings/common/SimpleShade.qml b/src/frontend/menu/settings/common/SimpleShade.qml new file mode 100644 index 000000000..c074299b2 --- /dev/null +++ b/src/frontend/menu/settings/common/SimpleShade.qml @@ -0,0 +1,35 @@ +// Pegasus Frontend +// Copyright (C) 2017-2022 Mátyás Mustoha +// +// 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, see . + + +import QtQuick 2.0 + + +Rectangle { + id: root + + signal clicked + + anchors.fill: parent + color: "#000" + opacity: 0.3 + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: root.clicked() + } +} diff --git a/src/frontend/menu/settings/gamedireditor/GameDirEditorEntry.qml b/src/frontend/menu/settings/gamedireditor/GameDirEditorEntry.qml index 5f5b80c84..e720b758d 100644 --- a/src/frontend/menu/settings/gamedireditor/GameDirEditorEntry.qml +++ b/src/frontend/menu/settings/gamedireditor/GameDirEditorEntry.qml @@ -23,7 +23,7 @@ Rectangle { readonly property bool highlighted: ListView.view.focus && (ListView.isCurrentItem || mouseArea.containsMouse) - property bool selected + property bool selected: false signal pressed