From a47d373d0d16bee71412cd3ed1f303205dfca741 Mon Sep 17 00:00:00 2001 From: CasualPokePlayer <50538166+CasualPokePlayer@users.noreply.github.com> Date: Sat, 23 Nov 2024 02:25:49 -0800 Subject: [PATCH] Add UI option to open the user folder (#11) --- GSE.Android/AndroidFile.cs | 9 + GSE/Gui/ImGuiModals.cs | 26 ++ android/app/src/main/AndroidManifest.xml | 17 +- .../java/org/psr/gse/DocumentProvider.java | 442 ++++++++++++++++++ .../main/java/org/psr/gse/GSEActivity.java | 70 +++ 5 files changed, 561 insertions(+), 3 deletions(-) create mode 100644 android/app/src/main/java/org/psr/gse/DocumentProvider.java diff --git a/GSE.Android/AndroidFile.cs b/GSE.Android/AndroidFile.cs index f7cac46..bb9a3e9 100644 --- a/GSE.Android/AndroidFile.cs +++ b/GSE.Android/AndroidFile.cs @@ -20,12 +20,14 @@ public static class AndroidFile private static JClass _gseActivityClassId; private static JMethodID _requestDocumentMethodId; private static JMethodID _openContentMethodId; + private static JMethodID _openFileManagerMethodId; internal static void InitializeJNI(JNIEnvPtr env, JClass gseActivityClassId) { _gseActivityClassId = gseActivityClassId; _requestDocumentMethodId = env.GetStaticMethodID(_gseActivityClassId, "RequestDocument"u8, "()V"u8); _openContentMethodId = env.GetStaticMethodID(_gseActivityClassId, "OpenContent"u8, "(Ljava/lang/String;)I"u8); + _openFileManagerMethodId = env.GetStaticMethodID(_gseActivityClassId, "OpenFileManager"u8, "()V"u8); } private static readonly AutoResetEvent _documentRequestDone = new(false); @@ -80,4 +82,11 @@ public static MemoryStream OpenBufferedStream(string contentUri) ms.Seek(0, SeekOrigin.Begin); return ms; } + + // ReSharper disable once UnusedMember.Global + public static void OpenFileManager() + { + var env = JNIEnvPtr.GetEnv(); + env.CallStaticVoidMethodA(_gseActivityClassId, _openFileManagerMethodId, []); + } } diff --git a/GSE/Gui/ImGuiModals.cs b/GSE/Gui/ImGuiModals.cs index 371108e..6456228 100644 --- a/GSE/Gui/ImGuiModals.cs +++ b/GSE/Gui/ImGuiModals.cs @@ -14,6 +14,9 @@ using static SDL2.SDL; +#if GSE_ANDROID +using GSE.Android; +#endif using GSE.Audio; using GSE.Emu; using GSE.Input; @@ -429,6 +432,29 @@ static string AddBiosPathButton(string system, string biosPathConfig, ImGuiWindo (_config.SavePathLocation, _config.SavePathCustom) = AddPathLocationButton("Save", _config.SavePathLocation, _config.SavePathCustom, _mainWindow); (_config.StatePathLocation, _config.StatePathCustom) = AddPathLocationButton("State", _config.StatePathLocation, _config.StatePathCustom, _mainWindow); #endif + ImGui.Separator(); + ImGui.AlignTextToFramePadding(); + + if (ImGui.Button("Open User Folder")) + { +#if GSE_ANDROID + AndroidFile.OpenFileManager(); +#else + var prefPath = PathResolver.GetPath(PathResolver.PathType.PrefPath, null, null, null); +#if GSE_OSX + _ = SDL_OpenURL(new Uri(prefPath).LocalPath); +#else + try + { + Process.Start(new ProcessStartInfo(prefPath) { UseShellExecute = true }); + } + catch + { + // ignored + } +#endif +#endif + } // TODO: Add menus for opening up the user path (needed on Android, nice to have on other platforms) ImGui.EndPopup(); diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ed6347c..1e89c87 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -43,7 +43,7 @@ android:icon="@mipmap/ic_launcher" android:allowBackup="true" android:theme="@android:style/Theme.NoTitleBar.Fullscreen" - android:hardwareAccelerated="true" > + android:hardwareAccelerated="true"> + android:windowSoftInputMode="stateAlwaysHidden"> @@ -64,5 +63,17 @@ + + + + + + diff --git a/android/app/src/main/java/org/psr/gse/DocumentProvider.java b/android/app/src/main/java/org/psr/gse/DocumentProvider.java new file mode 100644 index 0000000..68eff0e --- /dev/null +++ b/android/app/src/main/java/org/psr/gse/DocumentProvider.java @@ -0,0 +1,442 @@ +// Copyright 2023 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +// Partially based on: +// Skyline +// SPDX-License-Identifier: MPL-2.0 +// Copyright © 2022 Skyline Team and Contributors (https://github.com/skyline-emu/) + +package org.psr.gse; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.content.res.AssetFileDescriptor; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.graphics.Point; +import android.os.CancellationSignal; +import android.os.ParcelFileDescriptor; +import android.provider.DocumentsContract; +import android.provider.DocumentsProvider; +import android.webkit.MimeTypeMap; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.ArrayList; +import java.util.regex.Pattern; + +/** @noinspection ReassignedVariable */ + +public class DocumentProvider extends DocumentsProvider +{ + public static final String ROOT_ID = "root"; + + private static final String[] DEFAULT_ROOT_PROJECTION = new String[] + { + DocumentsContract.Root.COLUMN_ROOT_ID, + DocumentsContract.Root.COLUMN_MIME_TYPES, + DocumentsContract.Root.COLUMN_FLAGS, + DocumentsContract.Root.COLUMN_ICON, + DocumentsContract.Root.COLUMN_TITLE, + DocumentsContract.Root.COLUMN_SUMMARY, + DocumentsContract.Root.COLUMN_DOCUMENT_ID, + DocumentsContract.Root.COLUMN_AVAILABLE_BYTES + }; + + private static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[] + { + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_MIME_TYPE, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_LAST_MODIFIED, + DocumentsContract.Document.COLUMN_FLAGS, + DocumentsContract.Document.COLUMN_SIZE + }; + + private File _rootDirectory = null; + + @Override + @SuppressWarnings("ConstantConditions") + public boolean onCreate() + { + _rootDirectory = getContext().getExternalFilesDir(null); + return true; + } + + @Override + @SuppressWarnings("ConstantConditions") + public Cursor queryRoots(String[] projection) + { + var result = new MatrixCursor(projection != null ? projection : DEFAULT_ROOT_PROJECTION); + + if (_rootDirectory == null) + { + return result; + } + + var row = result.newRow(); + row.add(DocumentsContract.Root.COLUMN_ROOT_ID, ROOT_ID); + row.add(DocumentsContract.Root.COLUMN_TITLE, getContext().getString(R.string.app_name)); + row.add(DocumentsContract.Root.COLUMN_ICON, R.mipmap.ic_launcher); + row.add(DocumentsContract.Root.COLUMN_FLAGS, + DocumentsContract.Root.FLAG_SUPPORTS_CREATE | DocumentsContract.Root.FLAG_SUPPORTS_RECENTS | DocumentsContract.Root.FLAG_SUPPORTS_SEARCH); + row.add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, ROOT_ID); + + return result; + } + + @Override + public Cursor queryDocument(String documentId, String[] projection) throws FileNotFoundException + { + var result = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION); + + if (_rootDirectory == null) + { + return result; + } + + var file = documentIdToPath(documentId); + appendDocument(file, result); + return result; + } + + @Override + @SuppressWarnings("ConstantConditions") + public Cursor queryChildDocuments(String parentDocumentId, String[] projection, String queryArgs) throws FileNotFoundException + { + var result = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION); + + if (_rootDirectory == null) + { + _rootDirectory = getContext().getExternalFilesDir(null); + } + + if (_rootDirectory == null) + { + return result; + } + + var folder = documentIdToPath(parentDocumentId); + var files = folder.listFiles(); + if (files != null) + { + for (var file : files) + { + appendDocument(file, result); + } + } + + var authority = String.format("%s.user", getContext().getPackageName()); + result.setNotificationUri(getContext().getContentResolver(), DocumentsContract.buildChildDocumentsUri(authority, parentDocumentId)); + return result; + } + + @Override + public ParcelFileDescriptor openDocument(String documentId, String mode, CancellationSignal signal) throws FileNotFoundException + { + if (_rootDirectory == null) + { + return null; + } + + var file = documentIdToPath(documentId); + return ParcelFileDescriptor.open(file, ParcelFileDescriptor.parseMode(mode)); + } + + @Override + public AssetFileDescriptor openDocumentThumbnail(String documentId, Point sizeHint, CancellationSignal signal) throws FileNotFoundException + { + var file = documentIdToPath(documentId); + var pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY); + return new AssetFileDescriptor(pfd, 0, AssetFileDescriptor.UNKNOWN_LENGTH); + } + + @Override + @SuppressWarnings("ResultOfMethodCallIgnored") + public String createDocument(String parentDocumentId, String mimeType, String displayName) throws FileNotFoundException + { + if (_rootDirectory == null) + { + return null; + } + + var folder = documentIdToPath(parentDocumentId); + var file = findFileNameForNewFile(new File(folder, displayName)); + if (mimeType.equals(DocumentsContract.Document.MIME_TYPE_DIR)) + { + file.mkdirs(); + } + else + { + try + { + file.createNewFile(); + } + catch (IOException e) + { + throw new RuntimeException(e); + } + } + + refreshDocument(parentDocumentId); + return pathToDocumentId(file); + } + + private void deleteChildrenRecursively(File directory) throws IOException + { + var children = directory.listFiles(); + if (children == null) + { + throw new IOException(String.format("Could not find directory %s", directory.getPath())); + } + + for (var child : children) + { + deleteRecursively(child); + } + } + + private void deleteRecursively(File file) throws IOException + { + if (file.isDirectory()) + { + deleteChildrenRecursively(file); + } + + if (!file.delete()) + { + throw new IOException(String.format("Failed to delete %s", file.getPath())); + } + } + + @Override + @SuppressWarnings("ConstantConditions") + public void deleteDocument(String documentId) throws FileNotFoundException + { + if (_rootDirectory == null) + { + return; + } + + var file = documentIdToPath(documentId); + var fileParent = file.getParentFile(); + try + { + deleteRecursively(file); + } + catch (IOException e) + { + throw new RuntimeException(e); + } + + refreshDocument(pathToDocumentId(fileParent)); + } + + @Override + @SuppressWarnings({"ConstantConditions", "ResultOfMethodCallIgnored"}) + public String renameDocument(String documentId, String displayName) throws FileNotFoundException + { + if (_rootDirectory == null) + { + return null; + } + + var file = documentIdToPath(documentId); + var dest = findFileNameForNewFile(new File(file.getParentFile(), displayName)); + file.renameTo(dest); + refreshDocument(pathToDocumentId(file.getParentFile())); + return pathToDocumentId(dest); + } + + @SuppressWarnings("ConstantConditions") + private void refreshDocument(String parentDocumentId) + { + var authority = String.format("%s.user", getContext().getPackageName()); + var parentUri = DocumentsContract.buildChildDocumentsUri(authority, parentDocumentId); + getContext().getContentResolver().notifyChange(parentUri, null); + } + + @Override + public boolean isChildDocument(String parentDocumentId, String documentId) + { + return documentId.startsWith(parentDocumentId); + } + + @SuppressWarnings("ConstantConditions") + private void appendDocument(File file, MatrixCursor cursor) + { + var flags = 0; + if (file.canWrite()) + { + flags = file.isDirectory() + ? DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE + : DocumentsContract.Document.FLAG_SUPPORTS_WRITE; + flags |= DocumentsContract.Document.FLAG_SUPPORTS_DELETE | DocumentsContract.Document.FLAG_SUPPORTS_RENAME; + // The system will handle copy + move for us + } + + var name = file == _rootDirectory + ? getContext().getString(R.string.app_name) + : file.getName(); + + var mimeType = getTypeForFile(file); + if (file.exists() && mimeType.startsWith("image/")) + { + flags |= DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL; + } + + var row = cursor.newRow(); + row.add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, pathToDocumentId(file)); + row.add(DocumentsContract.Document.COLUMN_MIME_TYPE, getTypeForFile(file)); + row.add(DocumentsContract.Document.COLUMN_DISPLAY_NAME, name); + row.add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, file.lastModified()); + row.add(DocumentsContract.Document.COLUMN_FLAGS, flags); + row.add(DocumentsContract.Document.COLUMN_SIZE, file.length()); + if (file == _rootDirectory) + { + row.add(DocumentsContract.Document.COLUMN_ICON, R.mipmap.ic_launcher); + } + } + + // https://stackoverflow.com/a/40995124 + public static String[] splitPath(String path) + { + var ret = new ArrayList(); + var p = Pattern.compile("/+"); + var m = p.matcher(path); + var s0 = 0; + int s1, e1; + while (m.find()) + { + s1 = m.start(); + e1 = m.end(); + if (s1 - s0 > 0) + { + ret.add(path.substring(s0, s1)); + } + + s0 = e1; + } + + if (s0 < path.length()) + { + ret.add(path.substring(s0)); + } + + return ret.toArray(new String[0]); + } + + public static String relativize(String baseDir, String path) + { + if (!baseDir.endsWith("/")) + { + baseDir = baseDir + "/"; // assume the baseDir is always a directory. + } + + var bases = splitPath(baseDir); + var paths = splitPath(path); + int p = 0, q = 0; + + while (p < bases.length && q < paths.length && bases[p].equals(paths[q])) + { + p++; + q++; + } + + var sb = new StringBuilder(255); + for (var i = bases.length - 1; i >= p; i--) + { + sb.append("../"); + } + + for (var i = q; i < paths.length; i++) + { + sb.append(paths[i]); + if (i != paths.length - 1) + { + sb.append("/"); + } + } + + if (path.endsWith("/")) + { + // ensure the last char of sb is not / + var i = sb.length(); + if (i > 0 && sb.charAt(i - 1) != '/') + { + sb.append("/"); + } + } + + return sb.toString(); + } + + private String pathToDocumentId(File path) + { + var basePath = _rootDirectory.getAbsolutePath(); + var targetPath = path.getAbsolutePath(); + return String.format("%s/%s", ROOT_ID, relativize(basePath, targetPath)); + } + + private File documentIdToPath(String documentId) throws FileNotFoundException + { + var file = new File(_rootDirectory, documentId.substring(ROOT_ID.length())); + if (!file.exists()) + { + throw new FileNotFoundException(String.format("File %s does not exist.", documentId)); + } + + return file; + } + + private String getTypeForFile(File file) + { + if (file.isDirectory()) + { + return DocumentsContract.Document.MIME_TYPE_DIR; + } + else + { + var fileName = file.getName(); + var extDotIndex = fileName.lastIndexOf('.'); + if (extDotIndex == -1) + { + return "application/octet-stream"; + } + + var extension = fileName.substring(extDotIndex + 1); + var mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); + if (mimeType == null) + { + return "application/octet-stream"; + } + + return mimeType; + } + } + + @SuppressLint("DefaultLocale") + private File findFileNameForNewFile(File file) + { + var i = 1; + while (file.exists()) + { + var path = file.getAbsolutePath(); + var extDotIndex = path.lastIndexOf('.'); + if (extDotIndex == -1) + { + file = new File(String.format("%s.%d", path, i)); + } + else + { + var pathWithoutExtension = path.substring(0, extDotIndex); + var extension = path.substring(extDotIndex + 1); + file = new File(String.format("%s.%d.%s", pathWithoutExtension, i, extension)); + } + + i++; + } + + return file; + } +} diff --git a/android/app/src/main/java/org/psr/gse/GSEActivity.java b/android/app/src/main/java/org/psr/gse/GSEActivity.java index af32223..34b0f05 100644 --- a/android/app/src/main/java/org/psr/gse/GSEActivity.java +++ b/android/app/src/main/java/org/psr/gse/GSEActivity.java @@ -217,4 +217,74 @@ public static int GetRandomInt32(int toExclusive) var random = new SecureRandom(); return random.nextInt(toExclusive); } + + // Called by C# side via JNI + public static void OpenFileManager() + { + try + { + mSingleton.runOnUiThread(() -> + { + // apparently this needs to be tried against multiple methods of opening the file manager + // reference https://github.com/dolphin-emu/dolphin/blob/401d6e70f644afd4418a93778148d53f90954d75/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/UserDataActivity.kt#L115-L153 + + try + { + var intent = new Intent(Intent.ACTION_VIEW); + intent.addCategory(Intent.CATEGORY_DEFAULT); + intent.setData(DocumentsContract.buildRootUri(String.format("%s.user", mSingleton.getPackageName()), DocumentProvider.ROOT_ID)); + intent.setFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + mSingleton.startActivity(intent); + return; + } + catch (Exception ex) + { + System.err.println(ex.getMessage()); + } + + try + { + var intent = new Intent("android.provider.action.BROWSE"); + intent.addCategory(Intent.CATEGORY_DEFAULT); + intent.setData(DocumentsContract.buildRootUri(String.format("%s.user", mSingleton.getPackageName()), DocumentProvider.ROOT_ID)); + intent.setFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + mSingleton.startActivity(intent); + return; + } + catch (Exception ex) + { + System.err.println(ex.getMessage()); + } + + try + { + var intent = new Intent(Intent.ACTION_MAIN); + intent.setClassName("com.google.android.documentsui", "com.android.documentsui.files.FilesActivity"); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + mSingleton.startActivity(intent); + return; + } + catch (Exception ex) + { + System.err.println(ex.getMessage()); + } + + try + { + var intent = new Intent(Intent.ACTION_MAIN); + intent.setClassName("com.android.documentsui", "com.android.documentsui.files.FilesActivity"); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + mSingleton.startActivity(intent); + } + catch (Exception ex) + { + System.err.println(ex.getMessage()); + } + }); + } + catch (Exception ex) + { + System.err.println(ex.getMessage()); + } + } }