From 2b619cfcfb4407cf572bcf3440bf9b4655456635 Mon Sep 17 00:00:00 2001 From: SSimco <37044560+SSimco@users.noreply.github.com> Date: Wed, 25 Sep 2024 18:40:37 +0300 Subject: [PATCH] Added option to open the cemu folder --- src/android/app/build.gradle | 21 +- src/android/app/src/main/AndroidManifest.xml | 11 + .../java/info/cemu/Cemu/CemuApplication.java | 17 +- .../cemu/Cemu/features/DocumentsProvider.java | 293 ++++++++++++++++++ .../cemu/Cemu/settings/SettingsFragment.java | 18 ++ .../app/src/main/res/values/strings.xml | 2 + 6 files changed, 350 insertions(+), 12 deletions(-) create mode 100644 src/android/app/src/main/java/info/cemu/Cemu/features/DocumentsProvider.java diff --git a/src/android/app/build.gradle b/src/android/app/build.gradle index cbae20af..3b67d0fe 100644 --- a/src/android/app/build.gradle +++ b/src/android/app/build.gradle @@ -80,23 +80,24 @@ android { } } buildFeatures { + buildConfig true dataBinding true viewBinding true } } dependencies { - implementation "androidx.datastore:datastore-rxjava3:1.1.0" - implementation "androidx.datastore:datastore-preferences:1.1.0" - implementation "androidx.datastore:datastore:1.1.0" - implementation("com.squareup.okhttp3:okhttp:4.12.0") - implementation 'androidx.appcompat:appcompat:1.6.1' - implementation 'com.google.android.material:material:1.9.0' + implementation "androidx.datastore:datastore-rxjava3:1.1.1" + implementation "androidx.datastore:datastore-preferences:1.1.1" + implementation "androidx.datastore:datastore:1.1.1" + implementation "com.squareup.okhttp3:okhttp:4.12.0" + implementation 'androidx.appcompat:appcompat:1.7.0' + implementation 'com.google.android.material:material:1.12.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' - implementation 'androidx.navigation:navigation-fragment:2.5.3' - implementation 'androidx.navigation:navigation-ui:2.5.3' + implementation 'androidx.navigation:navigation-fragment:2.8.1' + implementation 'androidx.navigation:navigation-ui:2.8.1' testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' } \ No newline at end of file diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml index 814563c2..82a6da67 100644 --- a/src/android/app/src/main/AndroidManifest.xml +++ b/src/android/app/src/main/AndroidManifest.xml @@ -65,5 +65,16 @@ + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/Cemu/CemuApplication.java b/src/android/app/src/main/java/info/cemu/Cemu/CemuApplication.java index 34eb9426..3b2bc1f5 100644 --- a/src/android/app/src/main/java/info/cemu/Cemu/CemuApplication.java +++ b/src/android/app/src/main/java/info/cemu/Cemu/CemuApplication.java @@ -3,23 +3,36 @@ package info.cemu.Cemu; import android.app.Application; import android.util.DisplayMetrics; +import java.io.File; +import java.util.Objects; + import info.cemu.Cemu.NativeLibrary; import info.cemu.Cemu.utils.FileUtil; public class CemuApplication extends Application { private static CemuApplication application; + public CemuApplication() { + application = this; + } + public static CemuApplication getApplication() { return application; } + public File getInternalFolder() { + var externalFilesDir = getExternalFilesDir(null); + if (externalFilesDir != null) + return externalFilesDir; + return getFilesDir(); + } + @Override public void onCreate() { super.onCreate(); - application = this; DisplayMetrics displayMetrics = getResources().getDisplayMetrics(); NativeLibrary.setDPI(displayMetrics.density); - NativeLibrary.initializeActiveSettings(getExternalFilesDir(null).getAbsoluteFile().toString(), getExternalFilesDir(null).getAbsoluteFile().toString()); + NativeLibrary.initializeActiveSettings(getInternalFolder().toString(), getInternalFolder().toString()); NativeLibrary.initializeEmulation(); } } diff --git a/src/android/app/src/main/java/info/cemu/Cemu/features/DocumentsProvider.java b/src/android/app/src/main/java/info/cemu/Cemu/features/DocumentsProvider.java new file mode 100644 index 00000000..0a4716d7 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/Cemu/features/DocumentsProvider.java @@ -0,0 +1,293 @@ +// Based on: +// Skyline +// SPDX-License-Identifier: MPL-2.0 +// Copyright © 2022 Skyline Team and Contributors (https://github.com/skyline-emu/) +package info.cemu.Cemu.features; + +import android.database.Cursor; +import android.database.MatrixCursor; +import android.os.CancellationSignal; +import android.os.ParcelFileDescriptor; +import android.provider.DocumentsContract; +import android.webkit.MimeTypeMap; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.Objects; + +import info.cemu.Cemu.CemuApplication; +import info.cemu.Cemu.BuildConfig; +import info.cemu.Cemu.R; + +public class DocumentsProvider extends android.provider.DocumentsProvider { + private final File baseDirectory; + private final String applicationName = CemuApplication.getApplication().getApplicationInfo().loadLabel(CemuApplication.getApplication().getPackageManager()).toString(); + + public DocumentsProvider() { + try { + baseDirectory = CemuApplication.getApplication().getInternalFolder().getCanonicalFile(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + + private final String[] DEFAULT_ROOT_PROJECTION = { + 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 final String[] DEFAULT_DOCUMENT_PROJECTION = { + 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 + }; + + public static final String ROOT_ID = "root"; + public static final String AUTHORITY = BuildConfig.APPLICATION_ID + ".provider"; + + @Override + public boolean onCreate() { + return true; + } + + @Override + public Cursor queryRoots(String[] projection) { + var cursor = new MatrixCursor(projection == null ? DEFAULT_ROOT_PROJECTION : projection); + cursor.newRow().add(DocumentsContract.Root.COLUMN_ROOT_ID, ROOT_ID) + .add(DocumentsContract.Root.COLUMN_SUMMARY, null) + .add(DocumentsContract.Root.COLUMN_FLAGS, DocumentsContract.Root.FLAG_SUPPORTS_CREATE | DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD) + .add(DocumentsContract.Root.COLUMN_TITLE, applicationName) + .add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, getDocumentId(baseDirectory)) + .add(DocumentsContract.Root.COLUMN_MIME_TYPES, "*/*") + .add(DocumentsContract.Root.COLUMN_AVAILABLE_BYTES, baseDirectory.getFreeSpace()) + .add(DocumentsContract.Root.COLUMN_ICON, R.mipmap.ic_launcher); + return cursor; + } + + @Override + public Cursor queryDocument(String documentId, String[] projection) throws FileNotFoundException { + var cursor = new MatrixCursor(projection == null ? DEFAULT_DOCUMENT_PROJECTION : projection); + includeFile(cursor, documentId, null); + return cursor; + } + + @Override + public boolean isChildDocument(String parentDocumentId, String documentId) { + if (parentDocumentId == null || documentId == null) return false; + return documentId.startsWith(parentDocumentId); + } + + @Override + public String createDocument(String parentDocumentId, String mimeType, String displayName) throws FileNotFoundException { + var parentFile = getFile(parentDocumentId); + var newFile = resolveWithoutConflict(parentFile, displayName); + + if (DocumentsContract.Document.MIME_TYPE_DIR.equals(mimeType)) { + if (!newFile.mkdir()) + throw new FileNotFoundException("Failed to create directory"); + } else { + try { + if (!newFile.createNewFile()) + throw new FileNotFoundException("Failed to create file"); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + return getDocumentId(newFile); + } + + @Override + public void deleteDocument(String documentId) throws FileNotFoundException { + var file = getFile(documentId); + if (!file.delete()) + throw new FileNotFoundException("Couldn't delete document with ID '$documentId'"); + } + + @Override + public void removeDocument(String documentId, String parentDocumentId) throws FileNotFoundException { + var parent = getFile(parentDocumentId); + var file = getFile(documentId); + + if (parent.equals(file) || file.getParentFile() == null || file.getParentFile().equals(parent)) { + if (!file.delete()) + throw new FileNotFoundException("Couldn't delete document with ID '$documentId'"); + } else { + throw new FileNotFoundException("Couldn't delete document with ID '$documentId'"); + } + } + + @Override + public String renameDocument(String documentId, String displayName) throws FileNotFoundException { + if (displayName == null) + throw new FileNotFoundException("Couldn't rename document '$documentId' as the new name is null"); + + var sourceFile = getFile(documentId); + var sourceParentFile = sourceFile.getParentFile(); + if (sourceParentFile == null) + throw new FileNotFoundException("Couldn't rename document '$documentId' as it has no parent"); + var destFile = resolve(sourceParentFile, displayName); + + try { + if (!sourceFile.renameTo(destFile)) + throw new FileNotFoundException("Couldn't rename document from '${sourceFile.name}' to '${destFile.name}'"); + } catch (Exception exception) { + throw new FileNotFoundException("Couldn't rename document from '${sourceFile.name}' to '${destFile.name}': ${e.message}"); + } + + return getDocumentId(destFile); + } + + @Override + public String copyDocument(String sourceDocumentId, String targetParentDocumentId) throws FileNotFoundException { + var parent = getFile(targetParentDocumentId); + var oldFile = getFile(sourceDocumentId); + var newFile = resolveWithoutConflict(parent, oldFile.getName()); + + try { + if (!(newFile.createNewFile() && newFile.setWritable(true) && newFile.setReadable(true))) + throw new IOException("Couldn't create new file"); + try (var inputStream = new FileInputStream(oldFile); var outputStream = new FileOutputStream(newFile)) { + byte[] b = new byte[1024]; + int len; + while ((len = inputStream.read(b, 0, 1024)) > 0) { + outputStream.write(b, 0, len); + } + } + } catch (IOException exception) { + throw new FileNotFoundException("Couldn't copy document '$sourceDocumentId': ${e.message}"); + } + return getDocumentId(newFile); + } + + @Override + public String moveDocument(String sourceDocumentId, String sourceParentDocumentId, String targetParentDocumentId) throws FileNotFoundException { + try { + var newDocumentId = copyDocument(sourceDocumentId, sourceParentDocumentId, targetParentDocumentId); + removeDocument(sourceDocumentId, sourceParentDocumentId); + return newDocumentId; + } catch (FileNotFoundException e) { + throw new FileNotFoundException("Couldn't move document '$sourceDocumentId'"); + } + } + + @Override + public Cursor queryChildDocuments(String parentDocumentId, String[] projection, String sortOrder) throws FileNotFoundException { + var cursor = new MatrixCursor(projection == null ? DEFAULT_DOCUMENT_PROJECTION : projection); + var parent = getFile(parentDocumentId); + var files = parent.listFiles(); + if (files == null) + return cursor; + for (var file : files) + includeFile(cursor, null, file); + return cursor; + } + + @Override + public ParcelFileDescriptor openDocument(String documentId, String mode, CancellationSignal signal) throws FileNotFoundException { + var file = getFile(documentId); + var accessMode = ParcelFileDescriptor.parseMode(mode); + return ParcelFileDescriptor.open(file, accessMode); + } + + private File resolve(File file, String other) { + return file.toPath().resolve(other).toFile(); + } + + private String copyDocument(String sourceDocumentId, String sourceParentDocumentId, String targetParentDocumentId) throws FileNotFoundException { + if (!isChildDocument(sourceParentDocumentId, sourceDocumentId)) + throw new FileNotFoundException("Couldn't copy document '$sourceDocumentId' as its parent is not '$sourceParentDocumentId'"); + return copyDocument(sourceDocumentId, targetParentDocumentId); + } + + private File resolveWithoutConflict(File originalFile, String name) { + var file = resolve(originalFile, name); + if (!file.exists()) + return file; + + // Makes sure two files don't have the same name by adding a number to the end + var noConflictId = 1; + var periodIndex = name.lastIndexOf('.'); + var extension = ""; + var baseName = name; + if (periodIndex != -1) { + baseName = name.substring(0, periodIndex); + extension = name.substring(periodIndex); + } + while (file.exists()) { + String newFileName = baseName + " (" + noConflictId + ")" + extension; + file = file.toPath().resolve(newFileName).toFile(); + } + return file; + } + + private void includeFile(MatrixCursor cursor, String documentId, File file) throws FileNotFoundException { + var localDocumentId = documentId == null ? getDocumentId(file) : documentId; + var localFile = file == null ? getFile(documentId) : file; + int flags = 0; + if (localFile.isDirectory() && localFile.canWrite()) { + flags = DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE; + } else if (localFile.canWrite()) { + flags = DocumentsContract.Document.FLAG_SUPPORTS_WRITE + | DocumentsContract.Document.FLAG_SUPPORTS_MOVE + | DocumentsContract.Document.FLAG_SUPPORTS_COPY + | DocumentsContract.Document.FLAG_SUPPORTS_RENAME; + } + flags = flags | DocumentsContract.Document.FLAG_SUPPORTS_DELETE + | DocumentsContract.Document.FLAG_SUPPORTS_REMOVE; + var curorRowBuilder = cursor.newRow().add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, localDocumentId) + .add(DocumentsContract.Document.COLUMN_DISPLAY_NAME, localFile.equals(baseDirectory) ? applicationName : localFile.getName()) + .add(DocumentsContract.Document.COLUMN_SIZE, localFile.length()) + .add(DocumentsContract.Document.COLUMN_MIME_TYPE, getTypeForFile(localFile)) + .add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, localFile.lastModified()) + .add(DocumentsContract.Document.COLUMN_FLAGS, flags); + if (localFile.equals(baseDirectory)) + curorRowBuilder.add(DocumentsContract.Root.COLUMN_ICON, R.mipmap.ic_launcher); + } + + private String getTypeForFile(File file) { + if (file.isDirectory()) + return DocumentsContract.Document.MIME_TYPE_DIR; + return getTypeForName(file.getName()); + } + + private String getTypeForName(String name) { + var lastDot = name.lastIndexOf('.'); + if (lastDot >= 0) { + var extension = name.substring(lastDot + 1); + var mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); + if (mime != null) + return mime; + } + return "application/octect-stream"; + } + + private File getFile(String documentId) throws FileNotFoundException { + Objects.requireNonNull(documentId); + if (documentId.startsWith(ROOT_ID)) { + var file = resolve(baseDirectory, documentId.substring(ROOT_ID.length() + 1)); + if (!file.exists()) + throw new FileNotFoundException(file.getAbsolutePath() + " " + documentId + " not found"); + return file; + } else { + throw new FileNotFoundException(documentId + " is not in any known root"); + } + } + + private String getDocumentId(File file) { + return ROOT_ID + "/" + baseDirectory.toPath().relativize(file.toPath()).toString(); + } +} diff --git a/src/android/app/src/main/java/info/cemu/Cemu/settings/SettingsFragment.java b/src/android/app/src/main/java/info/cemu/Cemu/settings/SettingsFragment.java index 57504124..0f14e143 100644 --- a/src/android/app/src/main/java/info/cemu/Cemu/settings/SettingsFragment.java +++ b/src/android/app/src/main/java/info/cemu/Cemu/settings/SettingsFragment.java @@ -1,9 +1,13 @@ package info.cemu.Cemu.settings; +import android.content.ActivityNotFoundException; +import android.content.Intent; import android.os.Bundle; +import android.provider.DocumentsContract; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.Toast; import androidx.annotation.NonNull; import androidx.fragment.app.Fragment; @@ -11,6 +15,7 @@ import androidx.navigation.fragment.NavHostFragment; import info.cemu.Cemu.R; import info.cemu.Cemu.databinding.GenericRecyclerViewLayoutBinding; +import info.cemu.Cemu.features.DocumentsProvider; import info.cemu.Cemu.guibasecomponents.ButtonRecyclerViewItem; import info.cemu.Cemu.guibasecomponents.GenericRecyclerViewAdapter; import info.cemu.Cemu.guibasecomponents.SimpleButtonRecyclerViewItem; @@ -21,6 +26,7 @@ public class SettingsFragment extends Fragment { GenericRecyclerViewLayoutBinding binding = GenericRecyclerViewLayoutBinding.inflate(inflater, container, false); GenericRecyclerViewAdapter genericRecyclerViewAdapter = new GenericRecyclerViewAdapter(); + genericRecyclerViewAdapter.addRecyclerViewItem(new SimpleButtonRecyclerViewItem(getString(R.string.open_cemu_folder), this::openCemuFolder)); genericRecyclerViewAdapter.addRecyclerViewItem(new ButtonRecyclerViewItem(getString(R.string.add_game_path), getString(R.string.games_folder_description), () -> NavHostFragment.findNavController(SettingsFragment.this).navigate(R.id.action_settingsFragment_to_gamePathsFragment))); genericRecyclerViewAdapter.addRecyclerViewItem(new SimpleButtonRecyclerViewItem(getString(R.string.input_settings), () -> NavHostFragment.findNavController(SettingsFragment.this).navigate(R.id.action_settingsFragment_to_inputSettingsFragment))); genericRecyclerViewAdapter.addRecyclerViewItem(new SimpleButtonRecyclerViewItem(getString(R.string.graphics_settings), () -> NavHostFragment.findNavController(SettingsFragment.this).navigate(R.id.action_settingsFragment_to_graphicSettingsFragment))); @@ -31,4 +37,16 @@ public class SettingsFragment extends Fragment { return binding.getRoot(); } + + private void openCemuFolder() { + try { + var intent = new Intent(Intent.ACTION_VIEW); + intent.addCategory(Intent.CATEGORY_DEFAULT); + intent.setData(DocumentsContract.buildRootUri(DocumentsProvider.AUTHORITY, DocumentsProvider.ROOT_ID)); + intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + requireContext().startActivity(intent); + } catch (ActivityNotFoundException activityNotFoundException) { + Toast.makeText(requireContext(), R.string.failed_to_open_cemu_folder, Toast.LENGTH_LONG).show(); + } + } } \ No newline at end of file diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index d0fa0699..9a5fb407 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -176,4 +176,6 @@ Unable to launch game because the base files were not found. Failed creating surface: %1$s Failed to initialize renderer: %1$s + Open Cemu folder + Could not open Cemu folder \ No newline at end of file