From 8d563d37b481668ab39a215ac14aa7845d3c093a Mon Sep 17 00:00:00 2001 From: hank121314 Date: Thu, 23 Mar 2023 21:30:52 +0800 Subject: [PATCH] citra_android: Storage Access Framework implementation (#6313) --- CMakeLists.txt | 3 +- externals/CMakeLists.txt | 9 + externals/boost | 2 +- src/android/app/build.gradle | 11 +- .../citra_emu/ExampleInstrumentedTest.java | 6 +- src/android/app/src/main/AndroidManifest.xml | 25 +- .../org/citra/citra_emu/CitraApplication.java | 3 + .../org/citra/citra_emu/NativeLibrary.java | 84 +++- .../activities/CustomFilePickerActivity.java | 38 -- .../activities/EmulationActivity.java | 36 +- .../citra/citra_emu/adapters/GameAdapter.java | 14 +- .../contracts/OpenFileResultContract.java | 24 + .../dialogs/CitraDirectoryDialog.java | 91 ++++ .../dialogs/CopyDirProgressDialog.java | 61 +++ .../ui/SettingsActivityPresenter.java | 10 +- .../features/settings/utils/SettingsFile.java | 47 +- .../fragments/CustomFilePickerFragment.java | 120 ----- .../citra/citra_emu/model/CheapDocument.java | 36 ++ .../citra/citra_emu/model/GameDatabase.java | 48 +- .../citra/citra_emu/ui/main/MainActivity.java | 164 +++---- .../citra_emu/ui/main/MainPresenter.java | 20 +- .../citra_emu/utils/CitraDirectoryHelper.java | 87 ++++ .../utils/DirectoryInitialization.java | 29 +- .../citra/citra_emu/utils/DocumentsTree.java | 271 +++++++++++ .../citra_emu/utils/FileBrowserHelper.java | 85 ++-- .../org/citra/citra_emu/utils/FileUtil.java | 427 +++++++++++++++++- .../citra_emu/utils/ForegroundService.java | 2 +- .../utils/GameIconRequestHandler.java | 4 +- .../citra_emu/utils/PermissionsHandler.java | 55 ++- .../citra/citra_emu/utils/PicassoUtils.java | 2 +- .../citra/citra_emu/utils/StartupHandler.java | 27 +- src/android/app/src/main/jni/config.cpp | 13 +- src/android/app/src/main/jni/id_cache.cpp | 7 +- .../src/main/jni/lodepng_image_interface.cpp | 19 +- src/android/app/src/main/jni/native.cpp | 21 + src/android/app/src/main/jni/native.h | 7 + .../res/layout/dialog_citra_directory.xml | 44 ++ .../main/res/layout/filepicker_toolbar.xml | 32 -- .../app/src/main/res/menu/menu_game_grid.xml | 5 + .../app/src/main/res/values-de/strings.xml | 1 - .../app/src/main/res/values-es/strings.xml | 1 - .../app/src/main/res/values-fi/strings.xml | 1 - .../app/src/main/res/values-fr/strings.xml | 1 - .../app/src/main/res/values-it/strings.xml | 1 - .../app/src/main/res/values-ja/strings.xml | 1 - .../app/src/main/res/values-ko/strings.xml | 1 - .../app/src/main/res/values-nb/strings.xml | 1 - .../res/values-night/styles_filepicker.xml | 5 - .../app/src/main/res/values-pt/strings.xml | 1 - .../app/src/main/res/values-zh/strings.xml | 1 - .../app/src/main/res/values/dimens.xml | 1 + .../app/src/main/res/values/strings.xml | 9 +- .../app/src/main/res/values/styles.xml | 6 - .../src/main/res/values/styles_filepicker.xml | 5 - .../app/src/main/res/values/themes.xml | 20 +- src/common/CMakeLists.txt | 4 +- src/common/android_storage.cpp | 190 ++++++++ src/common/android_storage.h | 84 ++++ src/common/common_paths.h | 4 - src/common/file_util.cpp | 134 +++++- src/common/file_util.h | 12 + src/common/string_util.cpp | 6 + src/common/string_util.h | 2 + src/core/CMakeLists.txt | 2 +- src/core/cheats/cheats.cpp | 8 +- src/core/cheats/gateway_cheat.cpp | 8 +- src/core/hw/aes/key.cpp | 9 +- .../post_processing_opengl.cpp | 9 +- 68 files changed, 1972 insertions(+), 545 deletions(-) delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/activities/CustomFilePickerActivity.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/contracts/OpenFileResultContract.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/dialogs/CitraDirectoryDialog.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/dialogs/CopyDirProgressDialog.java delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/fragments/CustomFilePickerFragment.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/model/CheapDocument.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/CitraDirectoryHelper.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/DocumentsTree.java create mode 100644 src/android/app/src/main/res/layout/dialog_citra_directory.xml delete mode 100644 src/android/app/src/main/res/layout/filepicker_toolbar.xml delete mode 100644 src/android/app/src/main/res/values-night/styles_filepicker.xml delete mode 100644 src/android/app/src/main/res/values/styles_filepicker.xml create mode 100644 src/common/android_storage.cpp create mode 100644 src/common/android_storage.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 6e575323b..3f5f7d4b1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -386,10 +386,11 @@ endforeach() # Boost if (USE_SYSTEM_BOOST) - find_package(Boost 1.70.0 COMPONENTS serialization REQUIRED) + find_package(Boost 1.70.0 COMPONENTS serialization iostreams REQUIRED) else() add_library(Boost::boost ALIAS boost) add_library(Boost::serialization ALIAS boost_serialization) + add_library(Boost::iostreams ALIAS boost_iostreams) endif() # SDL2 diff --git a/externals/CMakeLists.txt b/externals/CMakeLists.txt index 48bd2ac34..35f3fe364 100644 --- a/externals/CMakeLists.txt +++ b/externals/CMakeLists.txt @@ -21,6 +21,15 @@ file(GLOB boost_serialization_SRC "${CMAKE_SOURCE_DIR}/externals/boost/libs/seri add_library(boost_serialization STATIC ${boost_serialization_SRC}) target_link_libraries(boost_serialization PUBLIC boost) +# Boost::iostreams +add_library( + boost_iostreams + STATIC + ${CMAKE_SOURCE_DIR}/externals/boost/libs/iostreams/src/file_descriptor.cpp + ${CMAKE_SOURCE_DIR}/externals/boost/libs/iostreams/src/mapped_file.cpp +) +target_link_libraries(boost_iostreams PUBLIC boost) + # Add additional boost libs here; remember to ALIAS them in the root CMakeLists! # Catch2 diff --git a/externals/boost b/externals/boost index 66937ea62..80a171a17 160000 --- a/externals/boost +++ b/externals/boost @@ -1 +1 @@ -Subproject commit 66937ea62d126a92b5057e3fd9ceac7c44daf4f5 +Subproject commit 80a171a179c1f901e4f8dfc8962417f44865ceec diff --git a/src/android/app/build.gradle b/src/android/app/build.gradle index 7f4c64e55..feb0dfa44 100644 --- a/src/android/app/build.gradle +++ b/src/android/app/build.gradle @@ -32,7 +32,7 @@ android { // TODO If this is ever modified, change application_id in strings.xml applicationId "org.citra.citra_emu" minSdkVersion 28 - targetSdkVersion 29 + targetSdkVersion 31 versionCode autoVersion versionName getVersion() ndk.abiFilters abiFilter @@ -117,22 +117,25 @@ android { } dependencies { + implementation "androidx.activity:activity:1.5.1" + implementation "androidx.fragment:fragment:1.5.5" implementation 'androidx.appcompat:appcompat:1.5.1' implementation 'androidx.exifinterface:exifinterface:1.3.4' implementation 'androidx.cardview:cardview:1.0.0' implementation 'androidx.recyclerview:recyclerview:1.2.1' + implementation "androidx.documentfile:documentfile:1.0.1" implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.lifecycle:lifecycle-viewmodel:2.5.1' implementation 'androidx.fragment:fragment:1.5.3' implementation "androidx.slidingpanelayout:slidingpanelayout:1.2.0" implementation 'com.google.android.material:material:1.6.1' + implementation 'androidx.core:core-splashscreen:1.0.0' // For loading huge screenshots from the disk. implementation 'com.squareup.picasso:picasso:2.71828' // Allows FRP-style asynchronous operations in Android. implementation 'io.reactivex:rxandroid:1.2.1' - implementation 'com.nononsenseapps:filepicker:4.2.1' implementation 'org.ini4j:ini4j:0.5.4' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0' @@ -140,6 +143,10 @@ dependencies { // Please don't upgrade the billing library as the newer version is not GPL-compatible implementation 'com.android.billingclient:billing:2.0.3' + + // To use the androidx.test.core APIs + androidTestImplementation "androidx.test:core:1.5.0" + androidTestImplementation "androidx.test.ext:junit:1.1.5" } def getVersion() { diff --git a/src/android/app/src/androidTest/java/org/citra/citra_emu/ExampleInstrumentedTest.java b/src/android/app/src/androidTest/java/org/citra/citra_emu/ExampleInstrumentedTest.java index 671fb4b30..b7c32a2f1 100644 --- a/src/android/app/src/androidTest/java/org/citra/citra_emu/ExampleInstrumentedTest.java +++ b/src/android/app/src/androidTest/java/org/citra/citra_emu/ExampleInstrumentedTest.java @@ -1,8 +1,8 @@ package org.citra.citra_emu; import android.content.Context; -import android.support.test.InstrumentationRegistry; -import android.support.test.runner.AndroidJUnit4; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; import org.junit.Test; import org.junit.runner.RunWith; @@ -19,7 +19,7 @@ public class ExampleInstrumentedTest { @Test public void useAppContext() { // Context of the app under test. - Context appContext = InstrumentationRegistry.getTargetContext(); + Context appContext = ApplicationProvider.getApplicationContext(); assertEquals("org.citra.citra_emu", appContext.getPackageName()); } diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml index 7d35fe910..ec83e022e 100644 --- a/src/android/app/src/main/AndroidManifest.xml +++ b/src/android/app/src/main/AndroidManifest.xml @@ -26,7 +26,6 @@ - @@ -44,7 +43,8 @@ @@ -69,23 +69,12 @@ - - - - - - - - - - - - diff --git a/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.java b/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.java index 41ac7e27c..638e1ebaa 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.java @@ -12,10 +12,12 @@ import android.os.Build; import org.citra.citra_emu.model.GameDatabase; import org.citra.citra_emu.utils.DirectoryInitialization; +import org.citra.citra_emu.utils.DocumentsTree; import org.citra.citra_emu.utils.PermissionsHandler; public class CitraApplication extends Application { public static GameDatabase databaseHelper; + public static DocumentsTree documentsTree; private static CitraApplication application; private void createNotificationChannel() { @@ -39,6 +41,7 @@ public class CitraApplication extends Application { public void onCreate() { super.onCreate(); application = this; + documentsTree = new DocumentsTree(); if (PermissionsHandler.hasWriteAccess(getApplicationContext())) { DirectoryInitialization.start(getApplicationContext()); diff --git a/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.java b/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.java index 5899d62a6..0b683d575 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.java @@ -28,6 +28,7 @@ import androidx.fragment.app.DialogFragment; import org.citra.citra_emu.activities.EmulationActivity; import org.citra.citra_emu.applets.SoftwareKeyboard; import org.citra.citra_emu.utils.EmulationMenuSettings; +import org.citra.citra_emu.utils.FileUtil; import org.citra.citra_emu.utils.Log; import org.citra.citra_emu.utils.PermissionsHandler; @@ -164,6 +165,10 @@ public final class NativeLibrary { // Create the config.ini file. public static native void CreateConfigFile(); + public static native void CreateLogFile(); + + public static native void LogUserDirectory(String directory); + public static native int DefaultCPUCore(); /** @@ -262,11 +267,11 @@ public final class NativeLibrary { coreErrorAlertLock.notify(); } }).setOnDismissListener(dialog -> { - coreErrorAlertResult = true; - synchronized (coreErrorAlertLock) { - coreErrorAlertLock.notify(); - } - }).create(); + coreErrorAlertResult = true; + synchronized (coreErrorAlertLock) { + coreErrorAlertLock.notify(); + } + }).create(); } } @@ -665,4 +670,73 @@ public final class NativeLibrary { public static final int RELEASED = 0; public static final int PRESSED = 1; } + public static boolean createFile(String directory, String filename) { + if (FileUtil.isNativePath(directory)) { + return CitraApplication.documentsTree.createFile(directory, filename); + } + return FileUtil.createFile(CitraApplication.getAppContext(), directory, filename) != null; + } + + public static boolean createDir(String directory, String directoryName) { + if (FileUtil.isNativePath(directory)) { + return CitraApplication.documentsTree.createDir(directory, directoryName); + } + return FileUtil.createDir(CitraApplication.getAppContext(), directory, directoryName) != null; + } + + public static int openContentUri(String path, String openMode) { + if (FileUtil.isNativePath(path)) { + return CitraApplication.documentsTree.openContentUri(path, openMode); + } + return FileUtil.openContentUri(CitraApplication.getAppContext(), path, openMode); + } + + public static String[] getFilesName(String path) { + if (FileUtil.isNativePath(path)) { + return CitraApplication.documentsTree.getFilesName(path); + } + return FileUtil.getFilesName(CitraApplication.getAppContext(), path); + } + + public static long getSize(String path) { + if (FileUtil.isNativePath(path)) { + return CitraApplication.documentsTree.getFileSize(path); + } + return FileUtil.getFileSize(CitraApplication.getAppContext(), path); + } + + public static boolean fileExists(String path) { + if (FileUtil.isNativePath(path)) { + return CitraApplication.documentsTree.Exists(path); + } + return FileUtil.Exists(CitraApplication.getAppContext(), path); + } + + public static boolean isDirectory(String path) { + if (FileUtil.isNativePath(path)) { + return CitraApplication.documentsTree.isDirectory(path); + } + return FileUtil.isDirectory(CitraApplication.getAppContext(), path); + } + + public static boolean copyFile(String sourcePath, String destinationParentPath, String destinationFilename) { + if (FileUtil.isNativePath(sourcePath) && FileUtil.isNativePath(destinationParentPath)) { + return CitraApplication.documentsTree.copyFile(sourcePath, destinationParentPath, destinationFilename); + } + return FileUtil.copyFile(CitraApplication.getAppContext(), sourcePath, destinationParentPath, destinationFilename); + } + + public static boolean renameFile(String path, String destinationFilename) { + if (FileUtil.isNativePath(path)) { + return CitraApplication.documentsTree.renameFile(path, destinationFilename); + } + return FileUtil.renameFile(CitraApplication.getAppContext(), path, destinationFilename); + } + + public static boolean deleteDocument(String path) { + if (FileUtil.isNativePath(path)) { + return CitraApplication.documentsTree.deleteDocument(path); + } + return FileUtil.deleteDocument(CitraApplication.getAppContext(), path); + } } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/activities/CustomFilePickerActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/activities/CustomFilePickerActivity.java deleted file mode 100644 index 3083286e2..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/activities/CustomFilePickerActivity.java +++ /dev/null @@ -1,38 +0,0 @@ -package org.citra.citra_emu.activities; - -import android.content.Intent; -import android.os.Environment; - -import androidx.annotation.Nullable; - -import com.nononsenseapps.filepicker.AbstractFilePickerFragment; -import com.nononsenseapps.filepicker.FilePickerActivity; - -import org.citra.citra_emu.fragments.CustomFilePickerFragment; - -import java.io.File; - -public class CustomFilePickerActivity extends FilePickerActivity { - public static final String EXTRA_TITLE = "filepicker.intent.TITLE"; - public static final String EXTRA_EXTENSIONS = "filepicker.intent.EXTENSIONS"; - - @Override - protected AbstractFilePickerFragment getFragment( - @Nullable final String startPath, final int mode, final boolean allowMultiple, - final boolean allowCreateDir, final boolean allowExistingFile, - final boolean singleClick) { - CustomFilePickerFragment fragment = new CustomFilePickerFragment(); - // startPath is allowed to be null. In that case, default folder should be SD-card and not "/" - fragment.setArgs( - startPath != null ? startPath : Environment.getExternalStorageDirectory().getPath(), - mode, allowMultiple, allowCreateDir, allowExistingFile, singleClick); - - Intent intent = getIntent(); - int title = intent == null ? 0 : intent.getIntExtra(EXTRA_TITLE, 0); - fragment.setTitle(title); - String allowedExtensions = intent == null ? "*" : intent.getStringExtra(EXTRA_EXTENSIONS); - fragment.setAllowedExtensions(allowedExtensions); - - return fragment; - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java index 114f1e427..8eca11b95 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java @@ -6,6 +6,7 @@ import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.os.Bundle; import android.preference.PreferenceManager; +import android.util.Pair; import android.util.SparseIntArray; import android.view.InputDevice; import android.view.KeyEvent; @@ -20,6 +21,8 @@ import android.widget.CheckBox; import android.widget.TextView; import android.widget.Toast; +import androidx.activity.result.ActivityResultCallback; +import androidx.activity.result.ActivityResultLauncher; import androidx.annotation.IntDef; import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; @@ -30,6 +33,7 @@ import androidx.fragment.app.FragmentActivity; import org.citra.citra_emu.CitraApplication; import org.citra.citra_emu.NativeLibrary; import org.citra.citra_emu.R; +import org.citra.citra_emu.contracts.OpenFileResultContract; import org.citra.citra_emu.features.cheats.ui.CheatsActivity; import org.citra.citra_emu.features.settings.model.view.InputBindingSetting; import org.citra.citra_emu.features.settings.ui.SettingsActivity; @@ -84,6 +88,18 @@ public final class EmulationActivity extends AppCompatActivity { private static final int EMULATION_RUNNING_NOTIFICATION = 0x1000; private static SparseIntArray buttonsActionsMap = new SparseIntArray(); + private final ActivityResultLauncher mOpenFileLauncher = + registerForActivityResult(new OpenFileResultContract(), result -> { + if (result == null) + return; + String[] selectedFiles = FileBrowserHelper.getSelectedFiles( + result, getApplicationContext(), Collections.singletonList("bin")); + if (selectedFiles == null) + return; + + onAmiiboSelected(selectedFiles[0]); + }); + static { buttonsActionsMap.append(R.id.menu_emulation_edit_layout, EmulationActivity.MENU_ACTION_EDIT_CONTROLS_PLACEMENT); @@ -453,9 +469,7 @@ public final class EmulationActivity extends AppCompatActivity { break; case MENU_ACTION_LOAD_AMIIBO: - FileBrowserHelper.openFilePicker(this, REQUEST_SELECT_AMIIBO, - R.string.select_amiibo, - Collections.singletonList("bin"), false); + mOpenFileLauncher.launch(false); break; case MENU_ACTION_REMOVE_AMIIBO: @@ -548,20 +562,8 @@ public final class EmulationActivity extends AppCompatActivity { @Override protected void onActivityResult(int requestCode, int resultCode, Intent result) { super.onActivityResult(requestCode, resultCode, result); - switch (requestCode) { - case StillImageCameraHelper.REQUEST_CAMERA_FILE_PICKER: - StillImageCameraHelper.OnFilePickerResult(resultCode == RESULT_OK ? result : null); - break; - case REQUEST_SELECT_AMIIBO: - // If the user picked a file, as opposed to just backing out. - if (resultCode == MainActivity.RESULT_OK) { - String[] selectedFiles = FileBrowserHelper.getSelectedFiles(result); - if (selectedFiles == null) - return; - - onAmiiboSelected(selectedFiles[0]); - } - break; + if (requestCode == StillImageCameraHelper.REQUEST_CAMERA_FILE_PICKER) { + StillImageCameraHelper.OnFilePickerResult(resultCode == RESULT_OK ? result : null); } } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.java b/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.java index ca66c3b9b..1facecaa3 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.java @@ -15,15 +15,15 @@ import androidx.recyclerview.widget.RecyclerView; import com.google.android.material.color.MaterialColors; +import org.citra.citra_emu.CitraApplication; import org.citra.citra_emu.R; import org.citra.citra_emu.activities.EmulationActivity; import org.citra.citra_emu.model.GameDatabase; +import org.citra.citra_emu.utils.FileUtil; import org.citra.citra_emu.utils.Log; import org.citra.citra_emu.utils.PicassoUtils; import org.citra.citra_emu.viewholders.GameViewHolder; -import java.nio.file.Path; -import java.nio.file.Paths; import java.util.stream.Stream; /** @@ -86,8 +86,14 @@ public final class GameAdapter extends RecyclerView.Adapter impl holder.textGameTitle.setText(mCursor.getString(GameDatabase.GAME_COLUMN_TITLE).replaceAll("[\\t\\n\\r]+", " ")); holder.textCompany.setText(mCursor.getString(GameDatabase.GAME_COLUMN_COMPANY)); - final Path gamePath = Paths.get(mCursor.getString(GameDatabase.GAME_COLUMN_PATH)); - holder.textFileName.setText(gamePath.getFileName().toString()); + String filepath = mCursor.getString(GameDatabase.GAME_COLUMN_PATH); + String filename; + if (FileUtil.isNativePath(filepath)) { + filename = CitraApplication.documentsTree.getFilename(filepath); + } else { + filename = FileUtil.getFilename(CitraApplication.getAppContext(), filepath); + } + holder.textFileName.setText(filename); // TODO These shouldn't be necessary once the move to a DB-based model is complete. holder.gameId = mCursor.getString(GameDatabase.GAME_COLUMN_GAME_ID); diff --git a/src/android/app/src/main/java/org/citra/citra_emu/contracts/OpenFileResultContract.java b/src/android/app/src/main/java/org/citra/citra_emu/contracts/OpenFileResultContract.java new file mode 100644 index 000000000..cc29088ce --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/contracts/OpenFileResultContract.java @@ -0,0 +1,24 @@ +package org.citra.citra_emu.contracts; + +import android.content.Context; +import android.content.Intent; +import android.util.Pair; + +import androidx.activity.result.contract.ActivityResultContract; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class OpenFileResultContract extends ActivityResultContract { + @NonNull + @Override + public Intent createIntent(@NonNull Context context, Boolean allowMultiple) { + return new Intent(Intent.ACTION_OPEN_DOCUMENT) + .setType("application/octet-stream") + .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, allowMultiple); + } + + @Override + public Intent parseResult(int i, @Nullable Intent intent) { + return intent; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/dialogs/CitraDirectoryDialog.java b/src/android/app/src/main/java/org/citra/citra_emu/dialogs/CitraDirectoryDialog.java new file mode 100644 index 000000000..7d70e94b4 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/dialogs/CitraDirectoryDialog.java @@ -0,0 +1,91 @@ +package org.citra.citra_emu.dialogs; + +import android.app.Dialog; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Bundle; +import android.preference.PreferenceManager; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.CheckBox; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentActivity; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import java.util.Objects; +import org.citra.citra_emu.R; +import org.citra.citra_emu.utils.FileUtil; +import org.citra.citra_emu.utils.PermissionsHandler; + +public class CitraDirectoryDialog extends DialogFragment { + public static final String TAG = "citra_directory_dialog_fragment"; + + private static final String MOVE_DATE_ENABLE = "IS_MODE_DATA_ENABLE"; + + TextView pathView; + + TextView spaceView; + + CheckBox checkBox; + + AlertDialog dialog; + + Listener listener; + + public interface Listener { + void onPressPositiveButton(boolean moveData, Uri path); + } + + public static CitraDirectoryDialog newInstance(String path, Listener listener) { + CitraDirectoryDialog frag = new CitraDirectoryDialog(); + frag.listener = listener; + Bundle args = new Bundle(); + args.putString("path", path); + frag.setArguments(args); + return frag; + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + final FragmentActivity activity = requireActivity(); + final Uri path = Uri.parse(Objects.requireNonNull(requireArguments().getString("path"))); + SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(activity); + String freeSpaceText = + getResources().getString(R.string.free_space, FileUtil.getFreeSpace(activity, path)); + + LayoutInflater inflater = getLayoutInflater(); + View view = inflater.inflate(R.layout.dialog_citra_directory, null); + + checkBox = view.findViewById(R.id.checkBox); + pathView = view.findViewById(R.id.path); + spaceView = view.findViewById(R.id.space); + + checkBox.setChecked(mPreferences.getBoolean(MOVE_DATE_ENABLE, true)); + if (!PermissionsHandler.hasWriteAccess(activity)) { + checkBox.setVisibility(View.GONE); + } + checkBox.setOnCheckedChangeListener( + (v, isChecked) + // record move data selection with SharedPreferences + -> mPreferences.edit().putBoolean(MOVE_DATE_ENABLE, checkBox.isChecked()).apply()); + + pathView.setText(path.getPath()); + spaceView.setText(freeSpaceText); + + setCancelable(false); + + dialog = new MaterialAlertDialogBuilder(activity) + .setView(view) + .setIcon(R.mipmap.ic_launcher) + .setTitle(R.string.app_name) + .setPositiveButton( + android.R.string.ok, + (d, v) -> listener.onPressPositiveButton(checkBox.isChecked(), path)) + .setNegativeButton(android.R.string.cancel, null) + .create(); + return dialog; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/dialogs/CopyDirProgressDialog.java b/src/android/app/src/main/java/org/citra/citra_emu/dialogs/CopyDirProgressDialog.java new file mode 100644 index 000000000..f13e626ee --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/dialogs/CopyDirProgressDialog.java @@ -0,0 +1,61 @@ +package org.citra.citra_emu.dialogs; + +import android.app.Dialog; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ProgressBar; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentActivity; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import org.citra.citra_emu.R; + +public class CopyDirProgressDialog extends DialogFragment { + public static final String TAG = "copy_dir_progress_dialog"; + ProgressBar progressBar; + + TextView progressText; + + AlertDialog dialog; + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + final FragmentActivity activity = requireActivity(); + + LayoutInflater inflater = getLayoutInflater(); + View view = inflater.inflate(R.layout.dialog_progress_bar, null); + + progressBar = view.findViewById(R.id.progress_bar); + progressText = view.findViewById(R.id.progress_text); + progressText.setText(""); + + setCancelable(false); + + dialog = new MaterialAlertDialogBuilder(activity) + .setView(view) + .setIcon(R.mipmap.ic_launcher) + .setTitle(R.string.move_data) + .setMessage("") + .create(); + return dialog; + } + + public void onUpdateSearchProgress(String msg) { + requireActivity().runOnUiThread(() -> { + dialog.setMessage(getResources().getString(R.string.searching_direcotry, msg)); + }); + } + + public void onUpdateCopyProgress(String msg, int progress, int max) { + requireActivity().runOnUiThread(() -> { + progressBar.setProgress(progress); + progressBar.setMax(max); + progressText.setText(String.format("%d/%d", progress, max)); + dialog.setMessage(getResources().getString(R.string.copy_file_name, msg)); + }); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityPresenter.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityPresenter.java index e288bf934..b4f7c22d1 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityPresenter.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityPresenter.java @@ -3,9 +3,9 @@ package org.citra.citra_emu.features.settings.ui; import android.content.IntentFilter; import android.os.Bundle; import android.text.TextUtils; - import androidx.appcompat.app.AppCompatActivity; - +import androidx.documentfile.provider.DocumentFile; +import java.io.File; import org.citra.citra_emu.NativeLibrary; import org.citra.citra_emu.features.settings.model.Settings; import org.citra.citra_emu.features.settings.utils.SettingsFile; @@ -15,8 +15,6 @@ import org.citra.citra_emu.utils.DirectoryStateReceiver; import org.citra.citra_emu.utils.Log; import org.citra.citra_emu.utils.ThemeUtil; -import java.io.File; - public final class SettingsActivityPresenter { private static final String KEY_SHOULD_SAVE = "should_save"; @@ -62,8 +60,8 @@ public final class SettingsActivityPresenter { } private void prepareCitraDirectoriesIfNeeded() { - File configFile = new File(DirectoryInitialization.getUserDirectory() + "/config/" + SettingsFile.FILE_NAME_CONFIG + ".ini"); - if (!configFile.exists()) { + DocumentFile configFile = SettingsFile.getSettingsFile(SettingsFile.FILE_NAME_CONFIG); + if (configFile == null || !configFile.exists()) { Log.error("Citra config file could not be found!"); } if (DirectoryInitialization.areCitraDirectoriesReady()) { diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java index c73f45d2e..4246b50f6 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java @@ -1,6 +1,10 @@ package org.citra.citra_emu.features.settings.utils; +import android.content.Context; +import android.net.Uri; + import androidx.annotation.NonNull; +import androidx.documentfile.provider.DocumentFile; import org.citra.citra_emu.CitraApplication; import org.citra.citra_emu.NativeLibrary; @@ -18,10 +22,11 @@ import org.citra.citra_emu.utils.Log; import org.ini4j.Wini; import java.io.BufferedReader; -import java.io.File; import java.io.FileNotFoundException; -import java.io.FileReader; import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; import java.util.HashMap; import java.util.Set; import java.util.TreeMap; @@ -145,13 +150,15 @@ public final class SettingsFile { * @param view The current view. * @return An Observable that emits a HashMap of the file's contents, then completes. */ - static HashMap readFile(final File ini, boolean isCustomGame, SettingsActivityView view) { + static HashMap readFile(final DocumentFile ini, boolean isCustomGame, SettingsActivityView view) { HashMap sections = new Settings.SettingsSectionMap(); BufferedReader reader = null; try { - reader = new BufferedReader(new FileReader(ini)); + Context context = CitraApplication.getAppContext(); + InputStream inputStream = context.getContentResolver().openInputStream(ini.getUri()); + reader = new BufferedReader(new InputStreamReader(inputStream)); SettingSection current = null; for (String line; (line = reader.readLine()) != null; ) { @@ -166,11 +173,11 @@ public final class SettingsFile { } } } catch (FileNotFoundException e) { - Log.error("[SettingsFile] File not found: " + ini.getAbsolutePath() + e.getMessage()); + Log.error("[SettingsFile] File not found: " + ini.getUri() + e.getMessage()); if (view != null) view.onSettingsFileNotFound(); } catch (IOException e) { - Log.error("[SettingsFile] Error reading from: " + ini.getAbsolutePath() + e.getMessage()); + Log.error("[SettingsFile] Error reading from: " + ini.getUri() + e.getMessage()); if (view != null) view.onSettingsFileNotFound(); } finally { @@ -178,7 +185,7 @@ public final class SettingsFile { try { reader.close(); } catch (IOException e) { - Log.error("[SettingsFile] Error closing: " + ini.getAbsolutePath() + e.getMessage()); + Log.error("[SettingsFile] Error closing: " + ini.getUri() + e.getMessage()); } } } @@ -212,17 +219,23 @@ public final class SettingsFile { */ public static void saveFile(final String fileName, TreeMap sections, SettingsActivityView view) { - File ini = getSettingsFile(fileName); + DocumentFile ini = getSettingsFile(fileName); try { - Wini writer = new Wini(ini); + Context context = CitraApplication.getAppContext(); + InputStream inputStream = context.getContentResolver().openInputStream(ini.getUri()); + Wini writer = new Wini(inputStream); Set keySet = sections.keySet(); for (String key : keySet) { SettingSection section = sections.get(key); writeSection(writer, section); } - writer.store(); + inputStream.close(); + OutputStream outputStream = context.getContentResolver().openOutputStream(ini.getUri()); + writer.store(outputStream); + outputStream.flush(); + outputStream.close(); } catch (IOException e) { Log.error("[SettingsFile] File not found: " + fileName + ".ini: " + e.getMessage()); view.showToastMessage(CitraApplication.getAppContext().getString(R.string.error_saving, fileName, e.getMessage()), false); @@ -262,14 +275,16 @@ public final class SettingsFile { return generalSectionName; } - @NonNull - private static File getSettingsFile(String fileName) { - return new File( - DirectoryInitialization.getUserDirectory() + "/config/" + fileName + ".ini"); + public static DocumentFile getSettingsFile(String fileName) { + DocumentFile root = DocumentFile.fromTreeUri(CitraApplication.getAppContext(), Uri.parse(DirectoryInitialization.getUserDirectory())); + DocumentFile configDirectory = root.findFile("config"); + return configDirectory.findFile(fileName + ".ini"); } - private static File getCustomGameSettingsFile(String gameId) { - return new File(DirectoryInitialization.getUserDirectory() + "/GameSettings/" + gameId + ".ini"); + private static DocumentFile getCustomGameSettingsFile(String gameId) { + DocumentFile root = DocumentFile.fromTreeUri(CitraApplication.getAppContext(), Uri.parse(DirectoryInitialization.getUserDirectory())); + DocumentFile configDirectory = root.findFile("GameSettings"); + return configDirectory.findFile(gameId + ".ini"); } private static SettingSection sectionFromLine(String line, boolean isCustomGame) { diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/CustomFilePickerFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/fragments/CustomFilePickerFragment.java deleted file mode 100644 index c18ecd4c3..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/fragments/CustomFilePickerFragment.java +++ /dev/null @@ -1,120 +0,0 @@ -package org.citra.citra_emu.fragments; - -import android.net.Uri; -import android.os.Bundle; -import android.os.Environment; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.appcompat.widget.Toolbar; -import androidx.core.content.FileProvider; - -import com.nononsenseapps.filepicker.FilePickerFragment; - -import org.citra.citra_emu.R; - -import java.io.File; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -public class CustomFilePickerFragment extends FilePickerFragment { - private static String ALL_FILES = "*"; - private int mTitle; - private static List extensions = Collections.singletonList(ALL_FILES); - - @NonNull - @Override - public Uri toUri(@NonNull final File file) { - return FileProvider - .getUriForFile(getContext(), - getContext().getApplicationContext().getPackageName() + ".filesprovider", - file); - } - - @Override - public void onActivityCreated(Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - - if (mode == MODE_DIR) { - TextView ok = getActivity().findViewById(R.id.nnf_button_ok); - ok.setText(R.string.select_dir); - - TextView cancel = getActivity().findViewById(R.id.nnf_button_cancel); - cancel.setVisibility(View.GONE); - } - } - - @Override - protected View inflateRootView(LayoutInflater inflater, ViewGroup container) { - View view = super.inflateRootView(inflater, container); - if (mTitle != 0) { - Toolbar toolbar = view.findViewById(com.nononsenseapps.filepicker.R.id.nnf_picker_toolbar); - ViewGroup parent = (ViewGroup) toolbar.getParent(); - int index = parent.indexOfChild(toolbar); - View newToolbar = inflater.inflate(R.layout.filepicker_toolbar, toolbar, false); - TextView title = newToolbar.findViewById(R.id.filepicker_title); - title.setText(mTitle); - parent.removeView(toolbar); - parent.addView(newToolbar, index); - } - return view; - } - - public void setTitle(int title) { - mTitle = title; - } - - public void setAllowedExtensions(String allowedExtensions) { - if (allowedExtensions == null) - return; - - extensions = Arrays.asList(allowedExtensions.split(",")); - } - - @Override - protected boolean isItemVisible(@NonNull final File file) { - // Some users jump to the conclusion that Dolphin isn't able to detect their - // files if the files don't show up in the file picker when mode == MODE_DIR. - // To avoid this, show files even when the user needs to select a directory. - return (showHiddenItems || !file.isHidden()) && - (file.isDirectory() || extensions.contains(ALL_FILES) || - extensions.contains(fileExtension(file.getName()).toLowerCase())); - } - - @Override - public boolean isCheckable(@NonNull final File file) { - // We need to make a small correction to the isCheckable logic due to - // overriding isItemVisible to show files when mode == MODE_DIR. - // AbstractFilePickerFragment always treats files as checkable when - // allowExistingFile == true, but we don't want files to be checkable when mode == MODE_DIR. - return super.isCheckable(file) && !(mode == MODE_DIR && file.isFile()); - } - - @Override - public void goUp() { - if (Environment.getExternalStorageDirectory().getPath().equals(mCurrentPath.getPath())) { - goToDir(new File("/storage/")); - return; - } - if (mCurrentPath.equals(new File("/storage/"))){ - return; - } - super.goUp(); - } - - @Override - public void onClickDir(@NonNull View view, @NonNull DirViewHolder viewHolder) { - if(viewHolder.file.equals(new File("/storage/emulated/"))) - viewHolder.file = new File("/storage/emulated/0/"); - super.onClickDir(view, viewHolder); - } - - private static String fileExtension(@NonNull String filename) { - int i = filename.lastIndexOf('.'); - return i < 0 ? "" : filename.substring(i + 1); - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/CheapDocument.java b/src/android/app/src/main/java/org/citra/citra_emu/model/CheapDocument.java new file mode 100644 index 000000000..743e1d842 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/model/CheapDocument.java @@ -0,0 +1,36 @@ +package org.citra.citra_emu.model; + +import android.net.Uri; +import android.provider.DocumentsContract; + +/** + * A struct that is much more "cheaper" than DocumentFile. + * Only contains the information we needed. + */ +public class CheapDocument { + private final String filename; + private final Uri uri; + private final String mimeType; + + public CheapDocument(String filename, String mimeType, Uri uri) { + this.filename = filename; + this.mimeType = mimeType; + this.uri = uri; + } + + public String getFilename() { + return filename; + } + + public Uri getUri() { + return uri; + } + + public String getMimeType() { + return mimeType; + } + + public boolean isDirectory() { + return mimeType.equals(DocumentsContract.Document.MIME_TYPE_DIR); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/GameDatabase.java b/src/android/app/src/main/java/org/citra/citra_emu/model/GameDatabase.java index 215528541..f4086386f 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/model/GameDatabase.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/model/GameDatabase.java @@ -5,8 +5,10 @@ import android.content.Context; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; +import android.net.Uri; import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.utils.FileUtil; import org.citra.citra_emu.utils.Log; import java.io.File; @@ -64,10 +66,12 @@ public final class GameDatabase extends SQLiteOpenHelper { private static final String SQL_DELETE_FOLDERS = "DROP TABLE IF EXISTS " + TABLE_NAME_FOLDERS; private static final String SQL_DELETE_GAMES = "DROP TABLE IF EXISTS " + TABLE_NAME_GAMES; + private final Context mContext; public GameDatabase(Context context) { // Superclass constructor builds a database or uses an existing one. super(context, "games.db", null, DB_VERSION); + mContext = context; } @Override @@ -151,9 +155,10 @@ public final class GameDatabase extends SQLiteOpenHelper { while (folderCursor.moveToNext()) { String folderPath = folderCursor.getString(FOLDER_COLUMN_PATH); - File folder = new File(folderPath); + Uri folder = Uri.parse(folderPath); // If the folder is empty because it no longer exists, remove it from the library. - if (!folder.exists()) { + CheapDocument[] files = FileUtil.listFiles(mContext, folder); + if (files.length == 0) { Log.error( "[GameDatabase] Folder no longer exists. Removing from the library: " + folderPath); database.delete(TABLE_NAME_FOLDERS, @@ -161,7 +166,7 @@ public final class GameDatabase extends SQLiteOpenHelper { new String[]{Long.toString(folderCursor.getLong(COLUMN_DB_ID))}); } - addGamesRecursive(database, folder, allowedExtensions, 3); + addGamesRecursive(database, files, allowedExtensions, 3); } fileCursor.close(); @@ -173,33 +178,28 @@ public final class GameDatabase extends SQLiteOpenHelper { database.close(); } - private static void addGamesRecursive(SQLiteDatabase database, File parent, Set allowedExtensions, int depth) { + private void addGamesRecursive(SQLiteDatabase database, CheapDocument[] files, + Set allowedExtensions, int depth) { if (depth <= 0) { return; } - File[] children = parent.listFiles(); - if (children != null) { - for (File file : children) { - if (file.isHidden()) { - continue; - } + for (CheapDocument file : files) { + if (file.isDirectory()) { + Set newExtensions = new HashSet<>(Arrays.asList( + ".3ds", ".3dsx", ".elf", ".axf", ".cci", ".cxi", ".app")); + CheapDocument[] children = FileUtil.listFiles(mContext, file.getUri()); + this.addGamesRecursive(database, children, newExtensions, depth - 1); + } else { + String filename = file.getUri().toString(); - if (file.isDirectory()) { - Set newExtensions = new HashSet<>(Arrays.asList( - ".3ds", ".3dsx", ".elf", ".axf", ".cci", ".cxi", ".app")); - addGamesRecursive(database, file, newExtensions, depth - 1); - } else { - String filePath = file.getPath(); + int extensionStart = filename.lastIndexOf('.'); + if (extensionStart > 0) { + String fileExtension = filename.substring(extensionStart); - int extensionStart = filePath.lastIndexOf('.'); - if (extensionStart > 0) { - String fileExtension = filePath.substring(extensionStart); - - // Check that the file has an extension we care about before trying to read out of it. - if (allowedExtensions.contains(fileExtension.toLowerCase())) { - attemptToAddGame(database, filePath); - } + // Check that the file has an extension we care about before trying to read out of it. + if (allowedExtensions.contains(fileExtension.toLowerCase())) { + attemptToAddGame(database, filename); } } } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java index 75e25c4b1..99bf494b2 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java @@ -1,25 +1,30 @@ package org.citra.citra_emu.ui.main; import android.content.Intent; -import android.content.pm.PackageManager; +import android.net.Uri; import android.os.Bundle; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.widget.Toast; - +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.widget.Toolbar; - +import androidx.core.splashscreen.SplashScreen; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import java.util.Collections; import org.citra.citra_emu.NativeLibrary; import org.citra.citra_emu.R; import org.citra.citra_emu.activities.EmulationActivity; +import org.citra.citra_emu.contracts.OpenFileResultContract; import org.citra.citra_emu.features.settings.ui.SettingsActivity; import org.citra.citra_emu.model.GameProvider; import org.citra.citra_emu.ui.platform.PlatformGamesFragment; import org.citra.citra_emu.utils.AddDirectoryHelper; import org.citra.citra_emu.utils.BillingManager; +import org.citra.citra_emu.utils.CitraDirectoryHelper; import org.citra.citra_emu.utils.DirectoryInitialization; import org.citra.citra_emu.utils.FileBrowserHelper; import org.citra.citra_emu.utils.PermissionsHandler; @@ -27,9 +32,6 @@ import org.citra.citra_emu.utils.PicassoUtils; import org.citra.citra_emu.utils.StartupHandler; import org.citra.citra_emu.utils.ThemeUtil; -import java.util.Arrays; -import java.util.Collections; - /** * The main Activity of the Lollipop style UI. Manages several PlatformGamesFragments, which * individually display a grid of available games for each Fragment, in a tabbed layout. @@ -46,8 +48,65 @@ public final class MainActivity extends AppCompatActivity implements MainView { private static MenuItem mPremiumButton; + private final CitraDirectoryHelper citraDirectoryHelper = new CitraDirectoryHelper(this, () -> { + // If mPlatformGamesFragment is null means game directory have not been set yet. + if (mPlatformGamesFragment == null) { + mPlatformGamesFragment = new PlatformGamesFragment(); + getSupportFragmentManager() + .beginTransaction() + .add(mFrameLayoutId, mPlatformGamesFragment) + .commit(); + showGameInstallDialog(); + } + }); + + private final ActivityResultLauncher mOpenCitraDirectory = + registerForActivityResult(new ActivityResultContracts.OpenDocumentTree(), result -> { + if (result == null) + return; + citraDirectoryHelper.showCitraDirectoryDialog(result); + }); + + private final ActivityResultLauncher mOpenGameListLauncher = + registerForActivityResult(new ActivityResultContracts.OpenDocumentTree(), result -> { + if (result == null) + return; + int takeFlags = + (Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION); + getContentResolver().takePersistableUriPermission(result, takeFlags); + // When a new directory is picked, we currently will reset the existing games + // database. This effectively means that only one game directory is supported. + // TODO(bunnei): Consider fixing this in the future, or removing code for this. + getContentResolver().insert(GameProvider.URI_RESET, null); + // Add the new directory + mPresenter.onDirectorySelected(result.toString()); + }); + + private final ActivityResultLauncher mOpenFileLauncher = + registerForActivityResult(new OpenFileResultContract(), result -> { + if (result == null) + return; + String[] selectedFiles = FileBrowserHelper.getSelectedFiles( + result, getApplicationContext(), Collections.singletonList("cia")); + if (selectedFiles == null) { + Toast + .makeText(getApplicationContext(), R.string.cia_file_not_found, + Toast.LENGTH_LONG) + .show(); + return; + } + NativeLibrary.InstallCIAS(selectedFiles); + mPresenter.refreshGameList(); + }); + @Override protected void onCreate(Bundle savedInstanceState) { + SplashScreen splashScreen = SplashScreen.installSplashScreen(this); + splashScreen.setKeepOnScreenCondition( + () + -> (PermissionsHandler.hasWriteAccess(this) && + !DirectoryInitialization.areCitraDirectoriesReady())); + ThemeUtil.applyTheme(this); super.onCreate(savedInstanceState); @@ -61,7 +120,7 @@ public final class MainActivity extends AppCompatActivity implements MainView { mPresenter.onCreate(); if (savedInstanceState == null) { - StartupHandler.HandleInit(this); + StartupHandler.HandleInit(this, mOpenCitraDirectory); if (PermissionsHandler.hasWriteAccess(this)) { mPlatformGamesFragment = new PlatformGamesFragment(); getSupportFragmentManager().beginTransaction().add(mFrameLayoutId, mPlatformGamesFragment) @@ -144,7 +203,7 @@ public final class MainActivity extends AppCompatActivity implements MainView { if (PermissionsHandler.hasWriteAccess(this)) { SettingsActivity.launch(this, menuTag, ""); } else { - PermissionsHandler.checkWritePermission(this); + PermissionsHandler.checkWritePermission(this, mOpenCitraDirectory); } } @@ -152,79 +211,18 @@ public final class MainActivity extends AppCompatActivity implements MainView { public void launchFileListActivity(int request) { if (PermissionsHandler.hasWriteAccess(this)) { switch (request) { + case MainPresenter.REQUEST_SELECT_CITRA_DIRECTORY: + mOpenCitraDirectory.launch(null); + break; case MainPresenter.REQUEST_ADD_DIRECTORY: - FileBrowserHelper.openDirectoryPicker(this, - MainPresenter.REQUEST_ADD_DIRECTORY, - R.string.select_game_folder, - Arrays.asList("elf", "axf", "cci", "3ds", - "cxi", "app", "3dsx", "cia", - "rar", "zip", "7z", "torrent", - "tar", "gz")); - break; + mOpenGameListLauncher.launch(null); + break; case MainPresenter.REQUEST_INSTALL_CIA: - FileBrowserHelper.openFilePicker(this, MainPresenter.REQUEST_INSTALL_CIA, - R.string.install_cia_title, - Collections.singletonList("cia"), true); - break; + mOpenFileLauncher.launch(true); + break; } } else { - PermissionsHandler.checkWritePermission(this); - } - } - - /** - * @param requestCode An int describing whether the Activity that is returning did so successfully. - * @param resultCode An int describing what Activity is giving us this callback. - * @param result The information the returning Activity is providing us. - */ - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent result) { - super.onActivityResult(requestCode, resultCode, result); - switch (requestCode) { - case MainPresenter.REQUEST_ADD_DIRECTORY: - // If the user picked a file, as opposed to just backing out. - if (resultCode == MainActivity.RESULT_OK) { - // When a new directory is picked, we currently will reset the existing games - // database. This effectively means that only one game directory is supported. - // TODO(bunnei): Consider fixing this in the future, or removing code for this. - getContentResolver().insert(GameProvider.URI_RESET, null); - // Add the new directory - mPresenter.onDirectorySelected(FileBrowserHelper.getSelectedDirectory(result)); - } - break; - case MainPresenter.REQUEST_INSTALL_CIA: - // If the user picked a file, as opposed to just backing out. - if (resultCode == MainActivity.RESULT_OK) { - NativeLibrary.InstallCIAS(FileBrowserHelper.getSelectedFiles(result)); - mPresenter.refeshGameList(); - } - break; - } - } - - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - switch (requestCode) { - case PermissionsHandler.REQUEST_CODE_WRITE_PERMISSION: - if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { - DirectoryInitialization.start(this); - - mPlatformGamesFragment = new PlatformGamesFragment(); - getSupportFragmentManager().beginTransaction().add(mFrameLayoutId, mPlatformGamesFragment) - .commit(); - - // Immediately prompt user to select a game directory on first boot - if (mPresenter != null) { - mPresenter.launchFileListActivity(MainPresenter.REQUEST_ADD_DIRECTORY); - } - } else { - Toast.makeText(this, R.string.write_permission_needed, Toast.LENGTH_SHORT) - .show(); - } - break; - default: - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - break; + PermissionsHandler.checkWritePermission(this, mOpenCitraDirectory); } } @@ -245,6 +243,18 @@ public final class MainActivity extends AppCompatActivity implements MainView { } } + private void showGameInstallDialog() { + new MaterialAlertDialogBuilder(this) + .setIcon(R.mipmap.ic_launcher) + .setTitle(R.string.app_name) + .setMessage(R.string.app_game_install_description) + .setCancelable(false) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, + (d, v) -> mOpenGameListLauncher.launch(null)) + .show(); + } + @Override protected void onDestroy() { EmulationActivity.tryDismissRunningNotification(this); diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java index 4e9994c2a..b25cbe53fe 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java @@ -1,5 +1,6 @@ package org.citra.citra_emu.ui.main; +import android.content.Context; import android.os.SystemClock; import org.citra.citra_emu.BuildConfig; @@ -9,10 +10,12 @@ import org.citra.citra_emu.features.settings.model.Settings; import org.citra.citra_emu.features.settings.utils.SettingsFile; import org.citra.citra_emu.model.GameDatabase; import org.citra.citra_emu.utils.AddDirectoryHelper; +import org.citra.citra_emu.utils.PermissionsHandler; public final class MainPresenter { public static final int REQUEST_ADD_DIRECTORY = 1; public static final int REQUEST_INSTALL_CIA = 2; + public static final int REQUEST_SELECT_CITRA_DIRECTORY = 3; private final MainView mView; private String mDirToAdd; @@ -25,7 +28,7 @@ public final class MainPresenter { public void onCreate() { String versionName = BuildConfig.VERSION_NAME; mView.setVersionString(versionName); - refeshGameList(); + refreshGameList(); } public void launchFileListActivity(int request) { @@ -46,6 +49,10 @@ public final class MainPresenter { mView.launchSettingsActivity(SettingsFile.FILE_NAME_CONFIG); return true; + case R.id.button_select_root: + mView.launchFileListActivity(REQUEST_SELECT_CITRA_DIRECTORY); + return true; + case R.id.button_add_directory: launchFileListActivity(REQUEST_ADD_DIRECTORY); return true; @@ -74,9 +81,12 @@ public final class MainPresenter { mDirToAdd = dir; } - public void refeshGameList() { - GameDatabase databaseHelper = CitraApplication.databaseHelper; - databaseHelper.scanLibrary(databaseHelper.getWritableDatabase()); - mView.refresh(); + public void refreshGameList() { + Context context = CitraApplication.getAppContext(); + if (PermissionsHandler.hasWriteAccess(context)) { + GameDatabase databaseHelper = CitraApplication.databaseHelper; + databaseHelper.scanLibrary(databaseHelper.getWritableDatabase()); + mView.refresh(); + } } } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/CitraDirectoryHelper.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/CitraDirectoryHelper.java new file mode 100644 index 000000000..5a3ff6119 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/CitraDirectoryHelper.java @@ -0,0 +1,87 @@ +package org.citra.citra_emu.utils; + +import android.content.Intent; +import android.net.Uri; +import androidx.fragment.app.FragmentActivity; +import java.util.concurrent.Executors; +import org.citra.citra_emu.dialogs.CitraDirectoryDialog; +import org.citra.citra_emu.dialogs.CopyDirProgressDialog; + +/** + * Citra directory initialization ui flow controller. + */ +public class CitraDirectoryHelper { + public interface Listener { + void onDirectoryInitialized(); + } + + private final FragmentActivity mFragmentActivity; + private final Listener mListener; + + public CitraDirectoryHelper(FragmentActivity mFragmentActivity, Listener mListener) { + this.mFragmentActivity = mFragmentActivity; + this.mListener = mListener; + } + + public void showCitraDirectoryDialog(Uri result) { + CitraDirectoryDialog citraDirectoryDialog = CitraDirectoryDialog.newInstance( + result.toString(), ((moveData, path) -> { + Uri previous = PermissionsHandler.getCitraDirectory(); + // Do noting if user select the previous path. + if (path.equals(previous)) { + return; + } + int takeFlags = (Intent.FLAG_GRANT_WRITE_URI_PERMISSION | + Intent.FLAG_GRANT_READ_URI_PERMISSION); + mFragmentActivity.getContentResolver().takePersistableUriPermission(path, + takeFlags); + if (!moveData || previous == null) { + initializeCitraDirectory(path); + mListener.onDirectoryInitialized(); + return; + } + + // If user check move data, show copy progress dialog. + showCopyDialog(previous, path); + })); + citraDirectoryDialog.show(mFragmentActivity.getSupportFragmentManager(), + CitraDirectoryDialog.TAG); + } + + private void showCopyDialog(Uri previous, Uri path) { + CopyDirProgressDialog copyDirProgressDialog = new CopyDirProgressDialog(); + copyDirProgressDialog.showNow(mFragmentActivity.getSupportFragmentManager(), + CopyDirProgressDialog.TAG); + + // Run copy dir in background + Executors.newSingleThreadExecutor().execute(() -> { + FileUtil.copyDir( + mFragmentActivity, previous.toString(), path.toString(), + new FileUtil.CopyDirListener() { + @Override + public void onSearchProgress(String directoryName) { + copyDirProgressDialog.onUpdateSearchProgress(directoryName); + } + + @Override + public void onCopyProgress(String filename, int progress, int max) { + copyDirProgressDialog.onUpdateCopyProgress(filename, progress, max); + } + + @Override + public void onComplete() { + initializeCitraDirectory(path); + copyDirProgressDialog.dismissAllowingStateLoss(); + mListener.onDirectoryInitialized(); + } + }); + }); + } + + private void initializeCitraDirectory(Uri path) { + if (!PermissionsHandler.setCitraDirectory(path.toString())) + return; + DirectoryInitialization.resetCitraDirectoryState(); + DirectoryInitialization.start(mFragmentActivity); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.java index 58e552f5e..5de5d9a74 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.java @@ -9,19 +9,18 @@ package org.citra.citra_emu.utils; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; +import android.net.Uri; import android.os.Environment; import android.preference.PreferenceManager; - import androidx.localbroadcastmanager.content.LocalBroadcastManager; - -import org.citra.citra_emu.NativeLibrary; - import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.util.concurrent.atomic.AtomicBoolean; +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.NativeLibrary; /** * A service that spawns its own thread in order to copy several binary and shader files @@ -49,6 +48,9 @@ public final class DirectoryInitialization { if (PermissionsHandler.hasWriteAccess(context)) { if (setCitraUserDirectory()) { initializeInternalStorage(context); + CitraApplication.documentsTree.setRoot(Uri.parse(userPath)); + NativeLibrary.CreateLogFile(); + NativeLibrary.LogUserDirectory(userPath); NativeLibrary.CreateConfigFile(); directoryState = DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED; } else { @@ -75,6 +77,11 @@ public final class DirectoryInitialization { return directoryState == DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED; } + public static void resetCitraDirectoryState() { + directoryState = null; + isCitraDirectoryInitializationRunning.compareAndSet(true, false); + } + public static String getUserDirectory() { if (directoryState == null) { throw new IllegalStateException("DirectoryInitialization has to run at least once!"); @@ -88,15 +95,11 @@ public final class DirectoryInitialization { private static native void SetSysDirectory(String path); private static boolean setCitraUserDirectory() { - if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) { - File externalPath = Environment.getExternalStorageDirectory(); - if (externalPath != null) { - userPath = externalPath.getAbsolutePath() + "/citra-emu"; - Log.debug("[DirectoryInitialization] User Dir: " + userPath); - // NativeLibrary.SetUserDirectory(userPath); - return true; - } - + Uri dataPath = PermissionsHandler.getCitraDirectory(); + if (dataPath != null) { + userPath = dataPath.toString(); + Log.debug("[DirectoryInitialization] User Dir: " + userPath); + return true; } return false; diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/DocumentsTree.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/DocumentsTree.java new file mode 100644 index 000000000..22e4baf60 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/DocumentsTree.java @@ -0,0 +1,271 @@ +package org.citra.citra_emu.utils; + +import android.content.Context; +import android.net.Uri; +import android.provider.DocumentsContract; + +import androidx.annotation.Nullable; +import androidx.documentfile.provider.DocumentFile; + +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.model.CheapDocument; + +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URLDecoder; +import java.util.HashMap; +import java.util.Map; +import java.util.StringTokenizer; + +/** + * A cached document tree for citra user directory. + * For every filepath which is not startsWith "content://" will need to use this class to traverse. + * For example: + * C++ citra log file directory will be /log/citra_log.txt. + * After DocumentsTree.resolvePath() it will become content URI. + */ +public class DocumentsTree { + private DocumentsNode root; + private final Context context; + public static final String DELIMITER = "/"; + + public DocumentsTree() { + context = CitraApplication.getAppContext(); + } + + public void setRoot(Uri rootUri) { + root = null; + root = new DocumentsNode(); + root.uri = rootUri; + root.isDirectory = true; + } + + public boolean createFile(String filepath, String name) { + DocumentsNode node = resolvePath(filepath); + if (node == null) return false; + if (!node.isDirectory) return false; + if (!node.loaded) structTree(node); + Uri mUri = node.uri; + try { + String filename = URLDecoder.decode(name, FileUtil.DECODE_METHOD); + if (node.children.get(filename) != null) return true; + DocumentFile createdFile = FileUtil.createFile(context, mUri.toString(), name); + if (createdFile == null) return false; + DocumentsNode document = new DocumentsNode(createdFile, false); + document.parent = node; + node.children.put(document.key, document); + return true; + } catch (Exception e) { + Log.error("[DocumentsTree]: Cannot create file, error: " + e.getMessage()); + } + return false; + } + + public boolean createDir(String filepath, String name) { + DocumentsNode node = resolvePath(filepath); + if (node == null) return false; + if (!node.isDirectory) return false; + if (!node.loaded) structTree(node); + Uri mUri = node.uri; + try { + String filename = URLDecoder.decode(name, FileUtil.DECODE_METHOD); + if (node.children.get(filename) != null) return true; + DocumentFile createdDirectory = FileUtil.createDir(context, mUri.toString(), name); + if (createdDirectory == null) return false; + DocumentsNode document = new DocumentsNode(createdDirectory, true); + document.parent = node; + node.children.put(document.key, document); + return true; + } catch (Exception e) { + Log.error("[DocumentsTree]: Cannot create file, error: " + e.getMessage()); + } + return false; + } + + public int openContentUri(String filepath, String openmode) { + DocumentsNode node = resolvePath(filepath); + if (node == null) { + return -1; + } + return FileUtil.openContentUri(context, node.uri.toString(), openmode); + } + + public String getFilename(String filepath) { + DocumentsNode node = resolvePath(filepath); + if (node == null) { + return ""; + } + return node.name; + } + + public String[] getFilesName(String filepath) { + DocumentsNode node = resolvePath(filepath); + if (node == null || !node.isDirectory) { + return new String[0]; + } + // If this directory have not been iterate struct it. + if (!node.loaded) structTree(node); + return node.children.keySet().toArray(new String[0]); + } + + public long getFileSize(String filepath) { + DocumentsNode node = resolvePath(filepath); + if (node == null || node.isDirectory) { + return 0; + } + return FileUtil.getFileSize(context, node.uri.toString()); + } + + public boolean isDirectory(String filepath) { + DocumentsNode node = resolvePath(filepath); + if (node == null) return false; + return node.isDirectory; + } + + public boolean Exists(String filepath) { + return resolvePath(filepath) != null; + } + + public boolean copyFile(String sourcePath, String destinationParentPath, String destinationFilename) { + DocumentsNode sourceNode = resolvePath(sourcePath); + if (sourceNode == null) return false; + DocumentsNode destinationNode = resolvePath(destinationParentPath); + if (destinationNode == null) return false; + try { + DocumentFile destinationParent = DocumentFile.fromTreeUri(context, destinationNode.uri); + if (destinationParent == null) return false; + String filename = URLDecoder.decode(destinationFilename, "UTF-8"); + DocumentFile destination = destinationParent.createFile("application/octet-stream", filename); + if (destination == null) return false; + DocumentsNode document = new DocumentsNode(); + document.uri = destination.getUri(); + document.parent = destinationNode; + document.name = destination.getName(); + document.isDirectory = destination.isDirectory(); + document.loaded = true; + InputStream input = context.getContentResolver().openInputStream(sourceNode.uri); + OutputStream output = context.getContentResolver().openOutputStream(destination.getUri()); + byte[] buffer = new byte[1024]; + int len; + while ((len = input.read(buffer)) != -1) { + output.write(buffer, 0, len); + } + input.close(); + output.flush(); + output.close(); + destinationNode.children.put(document.key, document); + return true; + } catch (Exception e) { + Log.error("[DocumentsTree]: Cannot copy file, error: " + e.getMessage()); + } + return false; + } + + public boolean renameFile(String filepath, String destinationFilename) { + DocumentsNode node = resolvePath(filepath); + if (node == null) return false; + try { + Uri mUri = node.uri; + String filename = URLDecoder.decode(destinationFilename, FileUtil.DECODE_METHOD); + DocumentsContract.renameDocument(context.getContentResolver(), mUri, filename); + node.rename(filename); + return true; + } catch (Exception e) { + Log.error("[DocumentsTree]: Cannot rename file, error: " + e.getMessage()); + } + return false; + } + + public boolean deleteDocument(String filepath) { + DocumentsNode node = resolvePath(filepath); + if (node == null) return false; + try { + Uri mUri = node.uri; + if (!DocumentsContract.deleteDocument(context.getContentResolver(), mUri)) { + return false; + } + if (node.parent != null) { + node.parent.children.remove(node.key); + } + return true; + } catch (Exception e) { + Log.error("[DocumentsTree]: Cannot rename file, error: " + e.getMessage()); + } + return false; + } + + @Nullable + private DocumentsNode resolvePath(String filepath) { + if (root == null) + return null; + StringTokenizer tokens = new StringTokenizer(filepath, DELIMITER, false); + DocumentsNode iterator = root; + while (tokens.hasMoreTokens()) { + String token = tokens.nextToken(); + if (token.isEmpty()) continue; + iterator = find(iterator, token); + if (iterator == null) return null; + } + return iterator; + } + + @Nullable + private DocumentsNode find(DocumentsNode parent, String filename) { + if (parent.isDirectory && !parent.loaded) { + structTree(parent); + } + return parent.children.get(filename); + } + + /** + * Construct current level directory tree + * + * @param parent parent node of this level + */ + private void structTree(DocumentsNode parent) { + CheapDocument[] documents = FileUtil.listFiles(context, parent.uri); + for (CheapDocument document : documents) { + DocumentsNode node = new DocumentsNode(document); + node.parent = parent; + parent.children.put(node.key, node); + } + parent.loaded = true; + } + + private static class DocumentsNode { + private DocumentsNode parent; + private final Map children = new HashMap<>(); + private String key; + private String name; + private Uri uri; + private boolean loaded = false; + private boolean isDirectory = false; + + private DocumentsNode() {} + + private DocumentsNode(CheapDocument document) { + name = document.getFilename(); + uri = document.getUri(); + key = FileUtil.getFilenameWithExtensions(uri); + isDirectory = document.isDirectory(); + loaded = !isDirectory; + } + + private DocumentsNode(DocumentFile document, boolean isCreateDir) { + name = document.getName(); + uri = document.getUri(); + key = FileUtil.getFilenameWithExtensions(uri); + isDirectory = isCreateDir; + loaded = true; + } + + private void rename(String key) { + if (parent == null) { + return; + } + parent.children.remove(this.key); + this.name = key; + parent.children.put(key, this); + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/FileBrowserHelper.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/FileBrowserHelper.java index baf691f5c..cbdc0742c 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/FileBrowserHelper.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/FileBrowserHelper.java @@ -1,71 +1,48 @@ package org.citra.citra_emu.utils; +import android.content.ClipData; +import android.content.Context; import android.content.Intent; import android.net.Uri; -import android.os.Environment; import androidx.annotation.Nullable; -import androidx.fragment.app.FragmentActivity; +import androidx.documentfile.provider.DocumentFile; -import com.nononsenseapps.filepicker.FilePickerActivity; -import com.nononsenseapps.filepicker.Utils; - -import org.citra.citra_emu.activities.CustomFilePickerActivity; - -import java.io.File; +import java.util.ArrayList; import java.util.List; public final class FileBrowserHelper { - public static void openDirectoryPicker(FragmentActivity activity, int requestCode, int title, List extensions) { - Intent i = new Intent(activity, CustomFilePickerActivity.class); - - i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, false); - i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, false); - i.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_DIR); - i.putExtra(FilePickerActivity.EXTRA_START_PATH, - Environment.getExternalStorageDirectory().getPath()); - i.putExtra(CustomFilePickerActivity.EXTRA_TITLE, title); - i.putExtra(CustomFilePickerActivity.EXTRA_EXTENSIONS, String.join(",", extensions)); - - activity.startActivityForResult(i, requestCode); - } - - public static void openFilePicker(FragmentActivity activity, int requestCode, int title, - List extensions, boolean allowMultiple) { - Intent i = new Intent(activity, CustomFilePickerActivity.class); - - i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, allowMultiple); - i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, false); - i.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_FILE); - i.putExtra(FilePickerActivity.EXTRA_START_PATH, - Environment.getExternalStorageDirectory().getPath()); - i.putExtra(CustomFilePickerActivity.EXTRA_TITLE, title); - i.putExtra(CustomFilePickerActivity.EXTRA_EXTENSIONS, String.join(",", extensions)); - - activity.startActivityForResult(i, requestCode); - } @Nullable - public static String getSelectedDirectory(Intent result) { - // Use the provided utility method to parse the result - List files = Utils.getSelectedFilesFromResult(result); - if (!files.isEmpty()) { - File file = Utils.getFileForUri(files.get(0)); - return file.getAbsolutePath(); + public static String[] getSelectedFiles(Intent result, Context context, List extension) { + ClipData clipData = result.getClipData(); + List files = new ArrayList<>(); + if (clipData == null) { + files.add(DocumentFile.fromSingleUri(context, result.getData())); + } else { + for (int i = 0; i < clipData.getItemCount(); i++) { + ClipData.Item item = clipData.getItemAt(i); + Uri uri = item.getUri(); + files.add(DocumentFile.fromSingleUri(context, uri)); + } } - - return null; - } - - @Nullable - public static String[] getSelectedFiles(Intent result) { - // Use the provided utility method to parse the result - List files = Utils.getSelectedFilesFromResult(result); if (!files.isEmpty()) { - String[] paths = new String[files.size()]; - for (int i = 0; i < files.size(); i++) - paths[i] = Utils.getFileForUri(files.get(i)).getAbsolutePath(); - return paths; + List filePaths = new ArrayList<>(); + for (int i = 0; i < files.size(); i++) { + DocumentFile file = files.get(i); + String filename = file.getName(); + int extensionStart = filename.lastIndexOf('.'); + if (extensionStart > 0) { + String fileExtension = filename.substring(extensionStart + 1); + if (extension.contains(fileExtension)) { + filePaths.add(file.getUri().toString()); + } + } + } + if (filePaths.isEmpty()) { + return null; + } + return filePaths.toArray(new String[0]); } return null; diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.java index f9025171b..202621e11 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.java @@ -1,11 +1,385 @@ package org.citra.citra_emu.utils; +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.provider.DocumentsContract; +import android.system.Os; +import android.system.StructStatVfs; +import android.util.Pair; +import androidx.annotation.Nullable; +import androidx.documentfile.provider.DocumentFile; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; +import java.net.URLDecoder; +import java.util.ArrayList; +import java.util.List; +import org.citra.citra_emu.model.CheapDocument; public class FileUtil { + static final String PATH_TREE = "tree"; + static final String DECODE_METHOD = "UTF-8"; + static final String APPLICATION_OCTET_STREAM = "application/octet-stream"; + static final String TEXT_PLAIN = "text/plain"; + + public interface CopyDirListener { + void onSearchProgress(String directoryName); + void onCopyProgress(String filename, int progress, int max); + + void onComplete(); + } + + /** + * Create a file from directory with filename. + * + * @param context Application context + * @param directory parent path for file. + * @param filename file display name. + * @return boolean + */ + @Nullable + public static DocumentFile createFile(Context context, String directory, String filename) { + try { + Uri directoryUri = Uri.parse(directory); + DocumentFile parent; + parent = DocumentFile.fromTreeUri(context, directoryUri); + if (parent == null) return null; + filename = URLDecoder.decode(filename, DECODE_METHOD); + int extensionPosition = filename.lastIndexOf('.'); + String extension = ""; + if (extensionPosition > 0) { + extension = filename.substring(extensionPosition); + } + String mimeType = APPLICATION_OCTET_STREAM; + if (extension.equals(".txt")) { + mimeType = TEXT_PLAIN; + } + DocumentFile isExist = parent.findFile(filename); + if (isExist != null) return isExist; + return parent.createFile(mimeType, filename); + } catch (Exception e) { + Log.error("[FileUtil]: Cannot create file, error: " + e.getMessage()); + } + return null; + } + + /** + * Create a directory from directory with filename. + * + * @param context Application context + * @param directory parent path for directory. + * @param directoryName directory display name. + * @return boolean + */ + @Nullable + public static DocumentFile createDir(Context context, String directory, String directoryName) { + try { + Uri directoryUri = Uri.parse(directory); + DocumentFile parent; + parent = DocumentFile.fromTreeUri(context, directoryUri); + if (parent == null) return null; + directoryName = URLDecoder.decode(directoryName, DECODE_METHOD); + DocumentFile isExist = parent.findFile(directoryName); + if (isExist != null) return isExist; + return parent.createDirectory(directoryName); + } catch (Exception e) { + Log.error("[FileUtil]: Cannot create file, error: " + e.getMessage()); + } + return null; + } + + /** + * Open content uri and return file descriptor to JNI. + * + * @param context Application context + * @param path Native content uri path + * @param openmode will be one of "r", "r", "rw", "wa", "rwa" + * @return file descriptor + */ + public static int openContentUri(Context context, String path, String openmode) { + try (ParcelFileDescriptor parcelFileDescriptor = + context.getContentResolver().openFileDescriptor(Uri.parse(path), openmode)) { + if (parcelFileDescriptor == null) { + Log.error("[FileUtil]: Cannot get the file descriptor from uri: " + path); + return -1; + } + return parcelFileDescriptor.detachFd(); + } catch (Exception e) { + Log.error("[FileUtil]: Cannot open content uri, error: " + e.getMessage()); + } + return -1; + } + + /** + * Reference: https://stackoverflow.com/questions/42186820/documentfile-is-very-slow + * This function will be faster than DocumentFile.listFiles + * + * @param context Application context + * @param uri Directory uri. + * @return CheapDocument lists. + */ + public static CheapDocument[] listFiles(Context context, Uri uri) { + final ContentResolver resolver = context.getContentResolver(); + final String[] columns = new String[]{ + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE, + }; + Cursor c = null; + final List results = new ArrayList<>(); + try { + String docId; + if (isRootTreeUri(uri)) { + docId = DocumentsContract.getTreeDocumentId(uri); + } else { + docId = DocumentsContract.getDocumentId(uri); + } + final Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, docId); + c = resolver.query(childrenUri, columns, null, null, null); + while (c.moveToNext()) { + final String documentId = c.getString(0); + final String documentName = c.getString(1); + final String documentMimeType = c.getString(2); + final Uri documentUri = DocumentsContract.buildDocumentUriUsingTree(uri, documentId); + CheapDocument document = new CheapDocument(documentName, documentMimeType, documentUri); + results.add(document); + } + } catch (Exception e) { + Log.error("[FileUtil]: Cannot list file error: " + e.getMessage()); + } finally { + closeQuietly(c); + } + return results.toArray(new CheapDocument[0]); + } + + /** + * Check whether given path exists. + * + * @param path Native content uri path + * @return bool + */ + public static boolean Exists(Context context, String path) { + Cursor c = null; + try { + Uri mUri = Uri.parse(path); + final String[] columns = new String[] {DocumentsContract.Document.COLUMN_DOCUMENT_ID}; + c = context.getContentResolver().query(mUri, columns, null, null, null); + return c.getCount() > 0; + } catch (Exception e) { + Log.info("[FileUtil] Cannot find file from given path, error: " + e.getMessage()); + } finally { + closeQuietly(c); + } + return false; + } + + /** + * Check whether given path is a directory + * + * @param path content uri path + * @return bool + */ + public static boolean isDirectory(Context context, String path) { + final ContentResolver resolver = context.getContentResolver(); + final String[] columns = new String[] {DocumentsContract.Document.COLUMN_MIME_TYPE}; + boolean isDirectory = false; + Cursor c = null; + try { + Uri mUri = Uri.parse(path); + c = resolver.query(mUri, columns, null, null, null); + c.moveToNext(); + final String mimeType = c.getString(0); + isDirectory = mimeType.equals(DocumentsContract.Document.MIME_TYPE_DIR); + } catch (Exception e) { + Log.error("[FileUtil]: Cannot list files, error: " + e.getMessage()); + } finally { + closeQuietly(c); + } + return isDirectory; + } + + /** + * Get file display name from given path + * + * @param path content uri path + * @return String display name + */ + public static String getFilename(Context context, String path) { + final ContentResolver resolver = context.getContentResolver(); + final String[] columns = new String[] {DocumentsContract.Document.COLUMN_DISPLAY_NAME}; + String filename = ""; + Cursor c = null; + try { + Uri mUri = Uri.parse(path); + c = resolver.query(mUri, columns, null, null, null); + c.moveToNext(); + filename = c.getString(0); + } catch (Exception e) { + Log.error("[FileUtil]: Cannot get file size, error: " + e.getMessage()); + } finally { + closeQuietly(c); + } + return filename; + } + + public static String[] getFilesName(Context context, String path) { + Uri uri = Uri.parse(path); + List files = new ArrayList<>(); + for (CheapDocument file : FileUtil.listFiles(context, uri)) { + files.add(file.getFilename()); + } + return files.toArray(new String[0]); + } + + /** + * Get file size from given path. + * + * @param path content uri path + * @return long file size + */ + public static long getFileSize(Context context, String path) { + final ContentResolver resolver = context.getContentResolver(); + final String[] columns = new String[] {DocumentsContract.Document.COLUMN_SIZE}; + long size = 0; + Cursor c = null; + try { + Uri mUri = Uri.parse(path); + c = resolver.query(mUri, columns, null, null, null); + c.moveToNext(); + size = c.getLong(0); + } catch (Exception e) { + Log.error("[FileUtil]: Cannot get file size, error: " + e.getMessage()); + } finally { + closeQuietly(c); + } + return size; + } + + public static boolean copyFile(Context context, String sourcePath, String destinationParentPath, String destinationFilename) { + try { + Uri sourceUri = Uri.parse(sourcePath); + Uri destinationUri = Uri.parse(destinationParentPath); + DocumentFile destinationParent = DocumentFile.fromTreeUri(context, destinationUri); + if (destinationParent == null) return false; + String filename = URLDecoder.decode(destinationFilename, "UTF-8"); + DocumentFile destination = destinationParent.findFile(filename); + if (destination == null) { + destination = destinationParent.createFile("application/octet-stream", filename); + } + if (destination == null) return false; + InputStream input = context.getContentResolver().openInputStream(sourceUri); + OutputStream output = context.getContentResolver().openOutputStream(destination.getUri()); + byte[] buffer = new byte[1024]; + int len; + while ((len = input.read(buffer)) != -1) { + output.write(buffer, 0, len); + } + input.close(); + output.flush(); + output.close(); + return true; + } catch (Exception e) { + Log.error("[FileUtil]: Cannot copy file, error: " + e.getMessage()); + } + return false; + } + + public static void copyDir(Context context, String sourcePath, String destinationPath, + CopyDirListener listener) { + try { + Uri sourceUri = Uri.parse(sourcePath); + Uri destinationUri = Uri.parse(destinationPath); + final List> files = new ArrayList<>(); + final List> dirs = new ArrayList<>(); + dirs.add(new Pair<>(sourceUri, destinationUri)); + // Searching all files which need to be copied and struct the directory in destination. + while (!dirs.isEmpty()) { + DocumentFile fromDir = DocumentFile.fromTreeUri(context, dirs.get(0).first); + DocumentFile toDir = DocumentFile.fromTreeUri(context, dirs.get(0).second); + if (fromDir == null || toDir == null) + continue; + Uri fromUri = fromDir.getUri(); + if (listener != null) { + listener.onSearchProgress(fromUri.getPath()); + } + CheapDocument[] documents = FileUtil.listFiles(context, fromUri); + for (CheapDocument document : documents) { + String filename = document.getFilename(); + if (document.isDirectory()) { + DocumentFile target = toDir.findFile(filename); + if (target == null || !target.exists()) { + target = toDir.createDirectory(filename); + } + if (target == null) + continue; + dirs.add(new Pair<>(document.getUri(), target.getUri())); + } else { + DocumentFile target = toDir.findFile(filename); + if (target == null || !target.exists()) { + target = + toDir.createFile(document.getMimeType(), document.getFilename()); + } + if (target == null) + continue; + files.add(new Pair<>(document, target)); + } + } + + dirs.remove(0); + } + + int total = files.size(); + int progress = 0; + for (Pair file : files) { + DocumentFile to = file.second; + Uri toUri = to.getUri(); + String filename = getFilenameWithExtensions(toUri); + String toPath = toUri.getPath(); + DocumentFile toParent = to.getParentFile(); + if (toParent == null) + continue; + FileUtil.copyFile(context, file.first.getUri().toString(), + toParent.getUri().toString(), filename); + progress++; + if (listener != null) { + listener.onCopyProgress(toPath, progress, total); + } + } + if (listener != null) { + listener.onComplete(); + } + } catch (Exception e) { + Log.error("[FileUtil]: Cannot copy directory, error: " + e.getMessage()); + } + } + + public static boolean renameFile(Context context, String path, String destinationFilename) { + try { + Uri uri = Uri.parse(path); + DocumentsContract.renameDocument(context.getContentResolver(), uri, destinationFilename); + return true; + } catch (Exception e) { + Log.error("[FileUtil]: Cannot rename file, error: " + e.getMessage()); + } + return false; + } + + public static boolean deleteDocument(Context context, String path) { + try { + Uri uri = Uri.parse(path); + DocumentsContract.deleteDocument(context.getContentResolver(), uri); + return true; + } catch (Exception e) { + Log.error("[FileUtil]: Cannot delete document, error: " + e.getMessage()); + } + return false; + } + public static byte[] getBytesFromFile(File file) throws IOException { final long length = file.length(); @@ -21,8 +395,8 @@ public class FileUtil { int numRead; try (InputStream is = new FileInputStream(file)) { - while (offset < bytes.length - && (numRead = is.read(bytes, offset, bytes.length - offset)) >= 0) { + while (offset < bytes.length && + (numRead = is.read(bytes, offset, bytes.length - offset)) >= 0) { offset += numRead; } } @@ -34,4 +408,53 @@ public class FileUtil { return bytes; } + + public static boolean isRootTreeUri(Uri uri) { + final List paths = uri.getPathSegments(); + return paths.size() == 2 && PATH_TREE.equals(paths.get(0)); + } + + public static boolean isNativePath(String path) { + try { + return path.charAt(0) == '/'; + } catch (StringIndexOutOfBoundsException e) { + Log.error("[FileUtil] Cannot determine the string is native path or not."); + } + return false; + } + + public static String getFilenameWithExtensions(Uri uri) { + final String path = uri.getPath(); + final int index = path.lastIndexOf('/'); + return path.substring(index + 1); + } + + public static double getFreeSpace(Context context, Uri uri) { + try { + Uri docTreeUri = DocumentsContract.buildDocumentUriUsingTree( + uri, DocumentsContract.getTreeDocumentId(uri)); + ParcelFileDescriptor pfd = + context.getContentResolver().openFileDescriptor(docTreeUri, "r"); + assert pfd != null; + StructStatVfs stats = Os.fstatvfs(pfd.getFileDescriptor()); + double spaceInGigaBytes = stats.f_bavail * stats.f_bsize / 1024.0 / 1024 / 1024; + pfd.close(); + return spaceInGigaBytes; + } catch (Exception e) { + Log.error("[FileUtil] Cannot get storage size."); + } + + return 0; + } + + public static void closeQuietly(AutoCloseable closeable) { + if (closeable != null) { + try { + closeable.close(); + } catch (RuntimeException rethrown) { + throw rethrown; + } catch (Exception ignored) { + } + } + } } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/ForegroundService.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/ForegroundService.java index bc256877b..021179ab1 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/ForegroundService.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/ForegroundService.java @@ -27,7 +27,7 @@ public class ForegroundService extends Service { private void showRunningNotification() { // Intent is used to resume emulation if the notification is clicked PendingIntent contentIntent = PendingIntent.getActivity(this, 0, - new Intent(this, EmulationActivity.class), PendingIntent.FLAG_UPDATE_CURRENT); + new Intent(this, EmulationActivity.class), PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); NotificationCompat.Builder builder = new NotificationCompat.Builder(this, getString(R.string.app_notification_channel_id)) .setSmallIcon(R.drawable.ic_stat_notification_logo) diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconRequestHandler.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconRequestHandler.java index b790c2480..6ebe70161 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconRequestHandler.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconRequestHandler.java @@ -13,12 +13,12 @@ import java.nio.IntBuffer; public class GameIconRequestHandler extends RequestHandler { @Override public boolean canHandleRequest(Request data) { - return "iso".equals(data.uri.getScheme()); + return "content".equals(data.uri.getScheme()) || data.uri.getScheme() == null; } @Override public Result load(Request request, int networkPolicy) { - String url = request.uri.getHost() + request.uri.getPath(); + String url = request.uri.toString(); int[] vector = NativeLibrary.GetIcon(url); Bitmap bitmap = Bitmap.createBitmap(48, 48, Bitmap.Config.RGB_565); bitmap.copyPixelsFromBuffer(IntBuffer.wrap(vector)); diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.java index a29e23e8d..6cbe19b76 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.java @@ -1,28 +1,32 @@ package org.citra.citra_emu.utils; -import android.annotation.TargetApi; import android.content.Context; -import android.content.pm.PackageManager; -import android.os.Build; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.preference.PreferenceManager; -import androidx.core.content.ContextCompat; +import androidx.activity.result.ActivityResultLauncher; +import androidx.annotation.Nullable; +import androidx.documentfile.provider.DocumentFile; import androidx.fragment.app.FragmentActivity; -import static android.Manifest.permission.WRITE_EXTERNAL_STORAGE; +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.R; public class PermissionsHandler { - public static final int REQUEST_CODE_WRITE_PERMISSION = 500; + public static final String CITRA_DIRECTORY = "CITRA_DIRECTORY"; + public static final SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); // We use permissions acceptance as an indicator if this is a first boot for the user. - public static boolean isFirstBoot(final FragmentActivity activity) { - return ContextCompat.checkSelfPermission(activity, WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED; + public static boolean isFirstBoot(FragmentActivity activity) { + return !hasWriteAccess(activity.getApplicationContext()); } - @TargetApi(Build.VERSION_CODES.M) - public static boolean checkWritePermission(final FragmentActivity activity) { + public static boolean checkWritePermission(FragmentActivity activity, + ActivityResultLauncher launcher) { if (isFirstBoot(activity)) { - activity.requestPermissions(new String[]{WRITE_EXTERNAL_STORAGE}, - REQUEST_CODE_WRITE_PERMISSION); + launcher.launch(null); return false; } @@ -30,6 +34,31 @@ public class PermissionsHandler { } public static boolean hasWriteAccess(Context context) { - return ContextCompat.checkSelfPermission(context, WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED; + try { + Uri uri = getCitraDirectory(); + if (uri == null) + return false; + int takeFlags = (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + context.getContentResolver().takePersistableUriPermission(uri, takeFlags); + DocumentFile root = DocumentFile.fromTreeUri(context, uri); + if (root != null && root.exists()) return true; + context.getContentResolver().releasePersistableUriPermission(uri, takeFlags); + } catch (Exception e) { + Log.error("[PermissionsHandler]: Cannot check citra data directory permission, error: " + e.getMessage()); + } + return false; + } + + @Nullable + public static Uri getCitraDirectory() { + String directoryString = mPreferences.getString(CITRA_DIRECTORY, ""); + if (directoryString.isEmpty()) { + return null; + } + return Uri.parse(directoryString); + } + + public static boolean setCitraDirectory(String uriString) { + return mPreferences.edit().putString(CITRA_DIRECTORY, uriString).commit(); } } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoUtils.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoUtils.java index c99726685..65d6d4a88 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoUtils.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoUtils.java @@ -31,7 +31,7 @@ public class PicassoUtils { public static void loadGameIcon(ImageView imageView, String gamePath) { Picasso .get() - .load(Uri.parse("iso:/" + gamePath)) + .load(Uri.parse(gamePath)) .fit() .centerInside() .config(Bitmap.Config.RGB_565) diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/StartupHandler.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/StartupHandler.java index 56820eb33..5e52529d3 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/StartupHandler.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/StartupHandler.java @@ -1,21 +1,23 @@ package org.citra.citra_emu.utils; import android.content.Intent; +import android.net.Uri; import android.os.Bundle; import android.text.TextUtils; - +import android.text.method.LinkMovementMethod; +import android.widget.TextView; +import androidx.activity.result.ActivityResultLauncher; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.FragmentActivity; - import com.google.android.material.dialog.MaterialAlertDialogBuilder; - import org.citra.citra_emu.R; import org.citra.citra_emu.activities.EmulationActivity; public final class StartupHandler { - private static void handlePermissionsCheck(FragmentActivity parent) { + private static void handlePermissionsCheck(FragmentActivity parent, + ActivityResultLauncher launcher) { // Ask the user to grant write permission if it's not already granted - PermissionsHandler.checkWritePermission(parent); + PermissionsHandler.checkWritePermission(parent, launcher); String start_file = ""; Bundle extras = parent.getIntent().getExtras(); @@ -32,16 +34,23 @@ public final class StartupHandler { } } - public static void HandleInit(FragmentActivity parent) { + public static void HandleInit(FragmentActivity parent, ActivityResultLauncher launcher) { if (PermissionsHandler.isFirstBoot(parent)) { // Prompt user with standard first boot disclaimer - new MaterialAlertDialogBuilder(parent) + AlertDialog dialog = + new MaterialAlertDialogBuilder(parent) .setTitle(R.string.app_name) .setIcon(R.mipmap.ic_launcher) - .setMessage(parent.getResources().getString(R.string.app_disclaimer)) + .setMessage(R.string.app_disclaimer) .setPositiveButton(android.R.string.ok, null) - .setOnDismissListener(dialogInterface -> handlePermissionsCheck(parent)) + .setCancelable(false) + .setOnDismissListener( + dialogInterface -> handlePermissionsCheck(parent, launcher)) .show(); + TextView textView = dialog.findViewById(android.R.id.message); + if (textView == null) + return; + textView.setMovementMethod(LinkMovementMethod.getInstance()); } } } diff --git a/src/android/app/src/main/jni/config.cpp b/src/android/app/src/main/jni/config.cpp index 768152c77..019dcee5d 100644 --- a/src/android/app/src/main/jni/config.cpp +++ b/src/android/app/src/main/jni/config.cpp @@ -26,7 +26,11 @@ Config::Config() { // TODO: Don't hardcode the path; let the frontend decide where to put the config files. sdl2_config_loc = FileUtil::GetUserPath(FileUtil::UserPath::ConfigDir) + "config.ini"; - sdl2_config = std::make_unique(sdl2_config_loc); + std::string ini_buffer; + FileUtil::ReadFileToString(true, sdl2_config_loc, ini_buffer); + if (!ini_buffer.empty()) { + sdl2_config = std::make_unique(ini_buffer.c_str(), ini_buffer.size()); + } Reload(); } @@ -35,12 +39,15 @@ Config::~Config() = default; bool Config::LoadINI(const std::string& default_contents, bool retry) { const std::string& location = this->sdl2_config_loc; - if (sdl2_config->ParseError() < 0) { + if (sdl2_config == nullptr || sdl2_config->ParseError() < 0) { if (retry) { LOG_WARNING(Config, "Failed to load {}. Creating file from defaults...", location); FileUtil::CreateFullPath(location); FileUtil::WriteStringToFile(true, location, default_contents); - sdl2_config = std::make_unique(location); // Reopen file + std::string ini_buffer; + FileUtil::ReadFileToString(true, location, ini_buffer); + sdl2_config = + std::make_unique(ini_buffer.c_str(), ini_buffer.size()); // Reopen file return LoadINI(default_contents, false); } diff --git a/src/android/app/src/main/jni/id_cache.cpp b/src/android/app/src/main/jni/id_cache.cpp index b1b372923..255e1ae0a 100644 --- a/src/android/app/src/main/jni/id_cache.cpp +++ b/src/android/app/src/main/jni/id_cache.cpp @@ -2,6 +2,7 @@ // Licensed under GPLv2 or any later version // Refer to the license.txt file included. +#include "common/android_storage.h" #include "common/common_paths.h" #include "common/logging/backend.h" #include "common/logging/filter.h" @@ -159,10 +160,6 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) { log_filter.ParseFilterString(Settings::values.log_filter.GetValue()); Log::SetGlobalFilter(log_filter); Log::AddBackend(std::make_unique()); - FileUtil::CreateFullPath(FileUtil::GetUserPath(FileUtil::UserPath::LogDir)); - Log::AddBackend(std::make_unique( - FileUtil::GetUserPath(FileUtil::UserPath::LogDir) + LOG_FILE)); - LOG_INFO(Frontend, "Logging backend initialised"); // Initialize misc classes s_savestate_info_class = reinterpret_cast( @@ -230,6 +227,7 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) { MiiSelector::InitJNI(env); SoftwareKeyboard::InitJNI(env); Camera::StillImage::InitJNI(env); + AndroidStorage::InitJNI(env, s_native_library_class); return JNI_VERSION; } @@ -254,6 +252,7 @@ void JNI_OnUnload(JavaVM* vm, void* reserved) { MiiSelector::CleanupJNI(env); SoftwareKeyboard::CleanupJNI(env); Camera::StillImage::CleanupJNI(env); + AndroidStorage::CleanupJNI(); } #ifdef __cplusplus diff --git a/src/android/app/src/main/jni/lodepng_image_interface.cpp b/src/android/app/src/main/jni/lodepng_image_interface.cpp index e42c3a82c..6bfcb81f3 100644 --- a/src/android/app/src/main/jni/lodepng_image_interface.cpp +++ b/src/android/app/src/main/jni/lodepng_image_interface.cpp @@ -3,12 +3,19 @@ // Refer to the license.txt file included. #include +#include "common/file_util.h" #include "common/logging/log.h" #include "jni/lodepng_image_interface.h" bool LodePNGImageInterface::DecodePNG(std::vector& dst, u32& width, u32& height, const std::string& path) { - u32 lodepng_ret = lodepng::decode(dst, width, height, path); + FileUtil::IOFile file(path, "rb"); + size_t read_size = file.GetSize(); + std::vector in(read_size); + if (file.ReadBytes(&in[0], read_size) != read_size) { + LOG_CRITICAL(Frontend, "Failed to decode {}", path); + } + u32 lodepng_ret = lodepng::decode(dst, width, height, in); if (lodepng_ret) { LOG_CRITICAL(Frontend, "Failed to decode {} because {}", path, lodepng_error_text(lodepng_ret)); @@ -19,11 +26,19 @@ bool LodePNGImageInterface::DecodePNG(std::vector& dst, u32& width, u32& hei bool LodePNGImageInterface::EncodePNG(const std::string& path, const std::vector& src, u32 width, u32 height) { - u32 lodepng_ret = lodepng::encode(path, src, width, height); + std::vector out; + u32 lodepng_ret = lodepng::encode(out, src, width, height); if (lodepng_ret) { LOG_CRITICAL(Frontend, "Failed to encode {} because {}", path, lodepng_error_text(lodepng_ret)); return false; } + + FileUtil::IOFile file(path, "wb"); + if (file.WriteBytes(&out[0], out.size()) != out.size()) { + LOG_CRITICAL(Frontend, "Failed to save encode to path={}", path); + return false; + } + return true; } diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp index 73e019e46..a91971a15 100644 --- a/src/android/app/src/main/jni/native.cpp +++ b/src/android/app/src/main/jni/native.cpp @@ -12,7 +12,9 @@ #include "audio_core/dsp_interface.h" #include "common/aarch64/cpu_detect.h" +#include "common/common_paths.h" #include "common/file_util.h" +#include "common/logging/backend.h" #include "common/logging/log.h" #include "common/microprofile.h" #include "common/scm_rev.h" @@ -319,6 +321,8 @@ jobjectArray Java_org_citra_citra_1emu_NativeLibrary_GetInstalledGamePaths( path += '/'; FileUtil::ForeachDirectoryEntry(nullptr, path, ScanDir); } else { + if (!FileUtil::Exists(path)) + return false; auto loader = Loader::GetLoader(path); if (loader) { bool executable{}; @@ -492,6 +496,23 @@ void Java_org_citra_citra_1emu_NativeLibrary_CreateConfigFile(JNIEnv* env, Config{}; } +void Java_org_citra_citra_1emu_NativeLibrary_CreateLogFile(JNIEnv* env, + [[maybe_unused]] jclass clazz) { + Log::RemoveBackend(Log::FileBackend::Name()); + FileUtil::CreateFullPath(FileUtil::GetUserPath(FileUtil::UserPath::LogDir)); + Log::AddBackend(std::make_unique( + FileUtil::GetUserPath(FileUtil::UserPath::LogDir) + LOG_FILE)); + LOG_INFO(Frontend, "Logging backend initialised"); +} + +void Java_org_citra_citra_1emu_NativeLibrary_LogUserDirectory(JNIEnv* env, + [[maybe_unused]] jclass clazz, + jstring j_path) { + std::string_view path = env->GetStringUTFChars(j_path, 0); + LOG_INFO(Frontend, "User directory path: {}", path); + env->ReleaseStringUTFChars(j_path, path.data()); +} + jint Java_org_citra_citra_1emu_NativeLibrary_DefaultCPUCore(JNIEnv* env, [[maybe_unused]] jclass clazz) { return 0; diff --git a/src/android/app/src/main/jni/native.h b/src/android/app/src/main/jni/native.h index 52bfeeecb..184b46145 100644 --- a/src/android/app/src/main/jni/native.h +++ b/src/android/app/src/main/jni/native.h @@ -83,6 +83,13 @@ JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_SetSysDirectory(J JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_CreateConfigFile(JNIEnv* env, jclass clazz); +JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_CreateLogFile(JNIEnv* env, + jclass clazz); + +JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_LogUserDirectory(JNIEnv* env, + jclass clazz, + jstring path); + JNIEXPORT jint JNICALL Java_org_citra_citra_1emu_NativeLibrary_DefaultCPUCore(JNIEnv* env, jclass clazz); JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_SetProfiling(JNIEnv* env, diff --git a/src/android/app/src/main/res/layout/dialog_citra_directory.xml b/src/android/app/src/main/res/layout/dialog_citra_directory.xml new file mode 100644 index 000000000..d2f251e44 --- /dev/null +++ b/src/android/app/src/main/res/layout/dialog_citra_directory.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout/filepicker_toolbar.xml b/src/android/app/src/main/res/layout/filepicker_toolbar.xml deleted file mode 100644 index 644934171..000000000 --- a/src/android/app/src/main/res/layout/filepicker_toolbar.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - diff --git a/src/android/app/src/main/res/menu/menu_game_grid.xml b/src/android/app/src/main/res/menu/menu_game_grid.xml index 1a4bee6df..e7fe944d6 100644 --- a/src/android/app/src/main/res/menu/menu_game_grid.xml +++ b/src/android/app/src/main/res/menu/menu_game_grid.xml @@ -13,6 +13,11 @@ android:title="@string/select_game_folder" app:showAsAction="ifRoom"> + Spieleordner auswählen - Ordner zur Bibliothek hinzufügen Einstellungen diff --git a/src/android/app/src/main/res/values-es/strings.xml b/src/android/app/src/main/res/values-es/strings.xml index cd64b9d6c..22d8f0da3 100644 --- a/src/android/app/src/main/res/values-es/strings.xml +++ b/src/android/app/src/main/res/values-es/strings.xml @@ -102,7 +102,6 @@ Seleccionar Directorio de Juego - Añadir Carpeta a la Librería de Juegos Configuración diff --git a/src/android/app/src/main/res/values-fi/strings.xml b/src/android/app/src/main/res/values-fi/strings.xml index dfdfdc88d..60c131349 100644 --- a/src/android/app/src/main/res/values-fi/strings.xml +++ b/src/android/app/src/main/res/values-fi/strings.xml @@ -63,7 +63,6 @@ Valitse pelikansio - Lisää kansio kirjastoosi Asetukset diff --git a/src/android/app/src/main/res/values-fr/strings.xml b/src/android/app/src/main/res/values-fr/strings.xml index 65a21f066..7752f7f13 100644 --- a/src/android/app/src/main/res/values-fr/strings.xml +++ b/src/android/app/src/main/res/values-fr/strings.xml @@ -98,7 +98,6 @@ Choisir un répertoire de jeu - Ajouter un répertoire à la bibliothèque Paramètres diff --git a/src/android/app/src/main/res/values-it/strings.xml b/src/android/app/src/main/res/values-it/strings.xml index c1aa19da6..92cab12ba 100644 --- a/src/android/app/src/main/res/values-it/strings.xml +++ b/src/android/app/src/main/res/values-it/strings.xml @@ -98,7 +98,6 @@ Seleziona Cartella di Gioco - Aggiungi una Cartella alla Libreria Impostazioni diff --git a/src/android/app/src/main/res/values-ja/strings.xml b/src/android/app/src/main/res/values-ja/strings.xml index 8b3a0e067..dc391230f 100644 --- a/src/android/app/src/main/res/values-ja/strings.xml +++ b/src/android/app/src/main/res/values-ja/strings.xml @@ -66,7 +66,6 @@ ゲームフォルダを選択 - ライブラリにフォルダを追加 設定 diff --git a/src/android/app/src/main/res/values-ko/strings.xml b/src/android/app/src/main/res/values-ko/strings.xml index 9817cd022..3499f17b7 100644 --- a/src/android/app/src/main/res/values-ko/strings.xml +++ b/src/android/app/src/main/res/values-ko/strings.xml @@ -100,7 +100,6 @@ 게임 폴더 선택 - 폴더를 라이브러리에 추가 설정 diff --git a/src/android/app/src/main/res/values-nb/strings.xml b/src/android/app/src/main/res/values-nb/strings.xml index d8314c8b5..bdbf0206f 100644 --- a/src/android/app/src/main/res/values-nb/strings.xml +++ b/src/android/app/src/main/res/values-nb/strings.xml @@ -98,7 +98,6 @@ Velg Spill Mappe - Lett til Mappe til Bibliotek Innstillinger diff --git a/src/android/app/src/main/res/values-night/styles_filepicker.xml b/src/android/app/src/main/res/values-night/styles_filepicker.xml deleted file mode 100644 index 1a175cdcf..000000000 --- a/src/android/app/src/main/res/values-night/styles_filepicker.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - + + - - - diff --git a/src/common/CMakeLists.txt b/src/common/CMakeLists.txt index 714c6e647..d0c8f9f63 100644 --- a/src/common/CMakeLists.txt +++ b/src/common/CMakeLists.txt @@ -57,6 +57,8 @@ add_library(common STATIC aarch64/cpu_detect.cpp aarch64/cpu_detect.h alignment.h + android_storage.h + android_storage.cpp announce_multiplayer_room.h arch.h archives.h @@ -137,7 +139,7 @@ add_library(common STATIC create_target_directory_groups(common) -target_link_libraries(common PUBLIC fmt::fmt microprofile Boost::boost Boost::serialization) +target_link_libraries(common PUBLIC fmt::fmt microprofile Boost::boost Boost::serialization Boost::iostreams) target_link_libraries(common PRIVATE libzstd_static) set_target_properties(common PROPERTIES INTERPROCEDURAL_OPTIMIZATION ${ENABLE_LTO}) diff --git a/src/common/android_storage.cpp b/src/common/android_storage.cpp new file mode 100644 index 000000000..d5975ee77 --- /dev/null +++ b/src/common/android_storage.cpp @@ -0,0 +1,190 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#ifdef ANDROID +#include "common/android_storage.h" + +namespace AndroidStorage { +JNIEnv* GetEnvForThread() { + thread_local static struct OwnedEnv { + OwnedEnv() { + status = g_jvm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6); + if (status == JNI_EDETACHED) + g_jvm->AttachCurrentThread(&env, nullptr); + } + + ~OwnedEnv() { + if (status == JNI_EDETACHED) + g_jvm->DetachCurrentThread(); + } + + int status; + JNIEnv* env = nullptr; + } owned; + return owned.env; +} + +AndroidOpenMode ParseOpenmode(const std::string_view openmode) { + AndroidOpenMode android_open_mode = AndroidOpenMode::NEVER; + const char* mode = openmode.data(); + int o = 0; + switch (*mode++) { + case 'r': + android_open_mode = AndroidStorage::AndroidOpenMode::READ; + break; + case 'w': + android_open_mode = AndroidStorage::AndroidOpenMode::WRITE; + o = O_TRUNC; + break; + case 'a': + android_open_mode = AndroidStorage::AndroidOpenMode::WRITE; + o = O_APPEND; + break; + } + + // [rwa]\+ or [rwa]b\+ means read and write + if (*mode == '+' || (*mode == 'b' && mode[1] == '+')) { + android_open_mode = AndroidStorage::AndroidOpenMode::READ_WRITE; + } + + return android_open_mode | o; +} + +void InitJNI(JNIEnv* env, jclass clazz) { + env->GetJavaVM(&g_jvm); + native_library = clazz; + +#define FR(FunctionName, ReturnValue, JMethodID, Caller, JMethodName, Signature) \ + F(JMethodID, JMethodName, Signature) +#define FS(FunctionName, ReturnValue, Parameters, JMethodID, JMethodName, Signature) \ + F(JMethodID, JMethodName, Signature) +#define F(JMethodID, JMethodName, Signature) \ + JMethodID = env->GetStaticMethodID(native_library, JMethodName, Signature); + ANDROID_SINGLE_PATH_DETERMINE_FUNCTIONS(FR) + ANDROID_STORAGE_FUNCTIONS(FS) +#undef F +#undef FS +#undef FR +} + +void CleanupJNI() { +#define FR(FunctionName, ReturnValue, JMethodID, Caller, JMethodName, Signature) F(JMethodID) +#define FS(FunctionName, ReturnValue, Parameters, JMethodID, JMethodName, Signature) F(JMethodID) +#define F(JMethodID) JMethodID = nullptr; + ANDROID_SINGLE_PATH_DETERMINE_FUNCTIONS(FR) + ANDROID_STORAGE_FUNCTIONS(FS) +#undef F +#undef FS +#undef FR +} + +bool CreateFile(const std::string& directory, const std::string& filename) { + if (create_file == nullptr) + return false; + auto env = GetEnvForThread(); + jstring j_directory = env->NewStringUTF(directory.c_str()); + jstring j_filename = env->NewStringUTF(filename.c_str()); + return env->CallStaticBooleanMethod(native_library, create_file, j_directory, j_filename); +} + +bool CreateDir(const std::string& directory, const std::string& filename) { + if (create_dir == nullptr) + return false; + auto env = GetEnvForThread(); + jstring j_directory = env->NewStringUTF(directory.c_str()); + jstring j_directory_name = env->NewStringUTF(filename.c_str()); + return env->CallStaticBooleanMethod(native_library, create_dir, j_directory, j_directory_name); +} + +int OpenContentUri(const std::string& filepath, AndroidOpenMode openmode) { + if (open_content_uri == nullptr) + return -1; + + const char* mode = ""; + switch (openmode) { + case AndroidOpenMode::READ: + mode = "r"; + break; + case AndroidOpenMode::WRITE: + mode = "w"; + break; + case AndroidOpenMode::READ_WRITE: + mode = "rw"; + break; + case AndroidOpenMode::WRITE_TRUNCATE: + mode = "wt"; + break; + case AndroidOpenMode::WRITE_APPEND: + mode = "wa"; + break; + case AndroidOpenMode::READ_WRITE_APPEND: + mode = "rwa"; + break; + case AndroidOpenMode::READ_WRITE_TRUNCATE: + mode = "rwt"; + break; + case AndroidOpenMode::NEVER: + return -1; + } + auto env = GetEnvForThread(); + jstring j_filepath = env->NewStringUTF(filepath.c_str()); + jstring j_mode = env->NewStringUTF(mode); + return env->CallStaticIntMethod(native_library, open_content_uri, j_filepath, j_mode); +} + +std::vector GetFilesName(const std::string& filepath) { + auto vector = std::vector(); + if (get_files_name == nullptr) + return vector; + auto env = GetEnvForThread(); + jstring j_filepath = env->NewStringUTF(filepath.c_str()); + auto j_object = + (jobjectArray)env->CallStaticObjectMethod(native_library, get_files_name, j_filepath); + jsize j_size = env->GetArrayLength(j_object); + for (int i = 0; i < j_size; i++) { + auto string = (jstring)(env->GetObjectArrayElement(j_object, i)); + vector.emplace_back(env->GetStringUTFChars(string, nullptr)); + } + return vector; +} + +bool CopyFile(const std::string& source, const std::string& destination_path, + const std::string& destination_filename) { + if (copy_file == nullptr) + return false; + auto env = GetEnvForThread(); + jstring j_source_path = env->NewStringUTF(source.c_str()); + jstring j_destination_path = env->NewStringUTF(destination_path.c_str()); + jstring j_destination_filename = env->NewStringUTF(destination_filename.c_str()); + return env->CallStaticBooleanMethod(native_library, copy_file, j_source_path, + j_destination_path, j_destination_filename); +} + +bool RenameFile(const std::string& source, const std::string& filename) { + if (rename_file == nullptr) + return false; + auto env = GetEnvForThread(); + jstring j_source_path = env->NewStringUTF(source.c_str()); + jstring j_destination_path = env->NewStringUTF(filename.c_str()); + return env->CallStaticBooleanMethod(native_library, rename_file, j_source_path, + j_destination_path); +} + +#define FR(FunctionName, ReturnValue, JMethodID, Caller, JMethodName, Signature) \ + F(FunctionName, ReturnValue, JMethodID, Caller) +#define F(FunctionName, ReturnValue, JMethodID, Caller) \ + ReturnValue FunctionName(const std::string& filepath) { \ + if (JMethodID == nullptr) { \ + return 0; \ + } \ + auto env = GetEnvForThread(); \ + jstring j_filepath = env->NewStringUTF(filepath.c_str()); \ + return env->Caller(native_library, JMethodID, j_filepath); \ + } +ANDROID_SINGLE_PATH_DETERMINE_FUNCTIONS(FR) +#undef F +#undef FR + +} // namespace AndroidStorage +#endif diff --git a/src/common/android_storage.h b/src/common/android_storage.h new file mode 100644 index 000000000..2ea0eb57c --- /dev/null +++ b/src/common/android_storage.h @@ -0,0 +1,84 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#ifdef ANDROID +#include +#include +#include +#include + +#define ANDROID_STORAGE_FUNCTIONS(V) \ + V(CreateFile, bool, (const std::string& directory, const std::string& filename), create_file, \ + "createFile", "(Ljava/lang/String;Ljava/lang/String;)Z") \ + V(CreateDir, bool, (const std::string& directory, const std::string& filename), create_dir, \ + "createDir", "(Ljava/lang/String;Ljava/lang/String;)Z") \ + V(OpenContentUri, int, (const std::string& filepath, AndroidOpenMode openmode), \ + open_content_uri, "openContentUri", "(Ljava/lang/String;Ljava/lang/String;)I") \ + V(GetFilesName, std::vector, (const std::string& filepath), get_files_name, \ + "getFilesName", "(Ljava/lang/String;)[Ljava/lang/String;") \ + V(CopyFile, bool, \ + (const std::string& source, const std::string& destination_path, \ + const std::string& destination_filename), \ + copy_file, "copyFile", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Z") \ + V(RenameFile, bool, (const std::string& source, const std::string& filename), rename_file, \ + "renameFile", "(Ljava/lang/String;Ljava/lang/String;)Z") +#define ANDROID_SINGLE_PATH_DETERMINE_FUNCTIONS(V) \ + V(IsDirectory, bool, is_directory, CallStaticBooleanMethod, "isDirectory", \ + "(Ljava/lang/String;)Z") \ + V(FileExists, bool, file_exists, CallStaticBooleanMethod, "fileExists", \ + "(Ljava/lang/String;)Z") \ + V(GetSize, std::uint64_t, get_size, CallStaticLongMethod, "getSize", "(Ljava/lang/String;)J") \ + V(DeleteDocument, bool, delete_document, CallStaticBooleanMethod, "deleteDocument", \ + "(Ljava/lang/String;)Z") +namespace AndroidStorage { +static JavaVM* g_jvm = nullptr; +static jclass native_library = nullptr; +#define FR(FunctionName, ReturnValue, JMethodID, Caller, JMethodName, Signature) F(JMethodID) +#define FS(FunctionName, ReturnValue, Parameters, JMethodID, JMethodName, Signature) F(JMethodID) +#define F(JMethodID) static jmethodID JMethodID = nullptr; +ANDROID_SINGLE_PATH_DETERMINE_FUNCTIONS(FR) +ANDROID_STORAGE_FUNCTIONS(FS) +#undef F +#undef FS +#undef FR +// Reference: +// https://developer.android.com/reference/android/os/ParcelFileDescriptor#parseMode(java.lang.String) +enum class AndroidOpenMode { + READ = O_RDONLY, // "r" + WRITE = O_WRONLY, // "w" + READ_WRITE = O_RDWR, // "rw" + WRITE_APPEND = O_WRONLY | O_APPEND, // "wa" + WRITE_TRUNCATE = O_WRONLY | O_TRUNC, // "wt + READ_WRITE_APPEND = O_RDWR | O_APPEND, // "rwa" + READ_WRITE_TRUNCATE = O_RDWR | O_TRUNC, // "rwt" + NEVER = EINVAL, +}; + +inline AndroidOpenMode operator|(AndroidOpenMode a, int b) { + return static_cast(static_cast(a) | b); +} + +AndroidOpenMode ParseOpenmode(const std::string_view openmode); + +void InitJNI(JNIEnv* env, jclass clazz); + +void CleanupJNI(); + +#define FS(FunctionName, ReturnValue, Parameters, JMethodID, JMethodName, Signature) \ + F(FunctionName, Parameters, ReturnValue) +#define F(FunctionName, Parameters, ReturnValue) ReturnValue FunctionName Parameters; +ANDROID_STORAGE_FUNCTIONS(FS) +#undef F +#undef FS + +#define FR(FunctionName, ReturnValue, JMethodID, Caller, JMethodName, Signature) \ + F(FunctionName, ReturnValue) +#define F(FunctionName, ReturnValue) ReturnValue FunctionName(const std::string& filepath); +ANDROID_SINGLE_PATH_DETERMINE_FUNCTIONS(FR) +#undef F +#undef FR +} // namespace AndroidStorage +#endif diff --git a/src/common/common_paths.h b/src/common/common_paths.h index 1699f14e6..6fe585fad 100644 --- a/src/common/common_paths.h +++ b/src/common/common_paths.h @@ -24,10 +24,6 @@ #define MACOS_EMU_DATA_DIR "Library" DIR_SEP "Application Support" DIR_SEP "Citra" // For compatibility with XDG paths. #define EMU_DATA_DIR "citra-emu" -#elif ANDROID -// On Android internal storage is mounted as "/sdcard" -#define SDCARD_DIR "sdcard" -#define EMU_DATA_DIR "citra-emu" #else #define EMU_DATA_DIR "citra-emu" #endif diff --git a/src/common/file_util.cpp b/src/common/file_util.cpp index 7a53d294a..27ecef10d 100644 --- a/src/common/file_util.cpp +++ b/src/common/file_util.cpp @@ -7,6 +7,8 @@ #include #include #include +#include +#include #include "common/assert.h" #include "common/common_funcs.h" #include "common/common_paths.h" @@ -66,6 +68,11 @@ #endif +#ifdef ANDROID +#include "common/android_storage.h" +#include "common/string_util.h" +#endif + #include #include @@ -104,6 +111,8 @@ bool Exists(const std::string& filename) { copy += DIR_SEP_CHR; int result = _wstat64(Common::UTF8ToUTF16W(copy).c_str(), &file_info); +#elif ANDROID + int result = AndroidStorage::FileExists(filename) ? 0 : -1; #else int result = stat(copy.c_str(), &file_info); #endif @@ -112,6 +121,10 @@ bool Exists(const std::string& filename) { } bool IsDirectory(const std::string& filename) { +#ifdef ANDROID + return AndroidStorage::IsDirectory(filename); +#endif + struct stat file_info; std::string copy(filename); @@ -156,6 +169,11 @@ bool Delete(const std::string& filename) { LOG_ERROR(Common_Filesystem, "DeleteFile failed on {}: {}", filename, GetLastErrorMsg()); return false; } +#elif ANDROID + if (!AndroidStorage::DeleteDocument(filename)) { + LOG_ERROR(Common_Filesystem, "unlink failed on {}", filename); + return false; + } #else if (unlink(filename.c_str()) == -1) { LOG_ERROR(Common_Filesystem, "unlink failed on {}: {}", filename, GetLastErrorMsg()); @@ -178,6 +196,24 @@ bool CreateDir(const std::string& path) { } LOG_ERROR(Common_Filesystem, "CreateDirectory failed on {}: {}", path, error); return false; +#elif ANDROID + std::string directory = path; + std::string filename = path; + if (Common::EndsWith(path, "/")) { + directory = GetParentPath(path); + filename = GetParentPath(path); + } + directory = GetParentPath(directory); + filename = GetFilename(filename); + // If directory path is empty, set it to root. + if (directory.empty()) { + directory = "/"; + } + if (!AndroidStorage::CreateDir(directory, filename)) { + LOG_ERROR(Common_Filesystem, "mkdir failed on {}", path); + return false; + }; + return true; #else if (mkdir(path.c_str(), 0755) == 0) return true; @@ -241,6 +277,9 @@ bool DeleteDir(const std::string& filename) { #ifdef _WIN32 if (::RemoveDirectoryW(Common::UTF8ToUTF16W(filename).c_str())) return true; +#elif ANDROID + if (AndroidStorage::DeleteDocument(filename)) + return true; #else if (rmdir(filename.c_str()) == 0) return true; @@ -256,6 +295,9 @@ bool Rename(const std::string& srcFilename, const std::string& destFilename) { if (_wrename(Common::UTF8ToUTF16W(srcFilename).c_str(), Common::UTF8ToUTF16W(destFilename).c_str()) == 0) return true; +#elif ANDROID + if (AndroidStorage::RenameFile(srcFilename, std::string(GetFilename(destFilename)))) + return true; #else if (rename(srcFilename.c_str(), destFilename.c_str()) == 0) return true; @@ -275,6 +317,9 @@ bool Copy(const std::string& srcFilename, const std::string& destFilename) { LOG_ERROR(Common_Filesystem, "failed {} --> {}: {}", srcFilename, destFilename, GetLastErrorMsg()); return false; +#elif ANDROID + return AndroidStorage::CopyFile(srcFilename, std::string(GetParentPath(destFilename)), + std::string(GetFilename(destFilename))); #else using CFilePointer = std::unique_ptr; @@ -334,6 +379,10 @@ u64 GetSize(const std::string& filename) { struct stat buf; #ifdef _WIN32 if (_wstat64(Common::UTF8ToUTF16W(filename).c_str(), &buf) == 0) +#elif ANDROID + u64 result = AndroidStorage::GetSize(filename); + LOG_TRACE(Common_Filesystem, "{}: {}", filename, result); + return result; #else if (stat(filename.c_str(), &buf) == 0) #endif @@ -403,6 +452,10 @@ bool ForeachDirectoryEntry(u64* num_entries_out, const std::string& directory, // windows loop do { const std::string virtual_name(Common::UTF16ToUTF8(ffd.cFileName)); +#elif ANDROID + // android loop + auto result = AndroidStorage::GetFilesName(directory); + for (auto virtual_name : result) { #else DIR* dirp = opendir(directory.c_str()); if (!dirp) @@ -426,6 +479,8 @@ bool ForeachDirectoryEntry(u64* num_entries_out, const std::string& directory, #ifdef _WIN32 } while (FindNextFileW(handle_find, &ffd) != 0); FindClose(handle_find); +#elif ANDROID + } #else } closedir(dirp); @@ -514,12 +569,18 @@ void CopyDir(const std::string& source_path, const std::string& dest_path) { if (!FileUtil::Exists(dest_path)) FileUtil::CreateFullPath(dest_path); +#ifdef ANDROID + auto result = AndroidStorage::GetFilesName(source_path); + for (auto virtualName : result) { +#else DIR* dirp = opendir(source_path.c_str()); if (!dirp) return; while (struct dirent* result = readdir(dirp)) { const std::string virtualName(result->d_name); +#endif // ANDROID + // check for "." and ".." if (((virtualName[0] == '.') && (virtualName[1] == '\0')) || ((virtualName[0] == '.') && (virtualName[1] == '.') && (virtualName[2] == '\0'))) @@ -537,8 +598,11 @@ void CopyDir(const std::string& source_path, const std::string& dest_path) { } else if (!FileUtil::Exists(dest)) FileUtil::Copy(source, dest); } + +#ifndef ANDROID closedir(dirp); -#endif +#endif // ANDROID +#endif // _WIN32 } std::optional GetCurrentDir() { @@ -698,11 +762,9 @@ void SetUserPath(const std::string& path) { g_paths.emplace(UserPath::ConfigDir, user_path + CONFIG_DIR DIR_SEP); g_paths.emplace(UserPath::CacheDir, user_path + CACHE_DIR DIR_SEP); #elif ANDROID - if (FileUtil::Exists(DIR_SEP SDCARD_DIR)) { - user_path = DIR_SEP SDCARD_DIR DIR_SEP EMU_DATA_DIR DIR_SEP; - g_paths.emplace(UserPath::ConfigDir, user_path + CONFIG_DIR DIR_SEP); - g_paths.emplace(UserPath::CacheDir, user_path + CACHE_DIR DIR_SEP); - } + user_path = "/"; + g_paths.emplace(UserPath::ConfigDir, user_path + CONFIG_DIR DIR_SEP); + g_paths.emplace(UserPath::CacheDir, user_path + CACHE_DIR DIR_SEP); #else if (FileUtil::Exists(ROOT_DIR DIR_SEP USERDATA_DIR)) { user_path = ROOT_DIR DIR_SEP USERDATA_DIR DIR_SEP; @@ -929,6 +991,9 @@ std::string_view RemoveTrailingSlash(std::string_view path) { std::string SanitizePath(std::string_view path_, DirectorySeparator directory_separator) { std::string path(path_); +#ifdef ANDROID + return std::string(RemoveTrailingSlash(path)); +#endif char type1 = directory_separator == DirectorySeparator::BackwardSlash ? '/' : '\\'; char type2 = directory_separator == DirectorySeparator::BackwardSlash ? '\\' : '/'; @@ -975,6 +1040,7 @@ IOFile& IOFile::operator=(IOFile&& other) noexcept { void IOFile::Swap(IOFile& other) noexcept { std::swap(m_file, other.m_file); + std::swap(m_fd, other.m_fd); std::swap(m_good, other.m_good); std::swap(filename, other.filename); std::swap(openmode, other.openmode); @@ -993,6 +1059,36 @@ bool IOFile::Open() { m_good = _wfopen_s(&m_file, Common::UTF8ToUTF16W(filename).c_str(), Common::UTF8ToUTF16W(openmode).c_str()) == 0; } +#elif ANDROID + // Check whether filepath is startsWith content + AndroidStorage::AndroidOpenMode android_open_mode = AndroidStorage::ParseOpenmode(openmode); + if (android_open_mode == AndroidStorage::AndroidOpenMode::WRITE || + android_open_mode == AndroidStorage::AndroidOpenMode::READ_WRITE || + android_open_mode == AndroidStorage::AndroidOpenMode::WRITE_APPEND || + android_open_mode == AndroidStorage::AndroidOpenMode::WRITE_TRUNCATE || + android_open_mode == AndroidStorage::AndroidOpenMode::READ_WRITE_TRUNCATE || + android_open_mode == AndroidStorage::AndroidOpenMode::READ_WRITE_APPEND) { + if (!FileUtil::Exists(filename)) { + std::string directory(GetParentPath(filename)); + std::string display_name(GetFilename(filename)); + if (!AndroidStorage::CreateFile(directory, display_name)) { + m_good = m_file != nullptr; + return m_good; + } + } + } + m_fd = AndroidStorage::OpenContentUri(filename, android_open_mode); + if (m_fd != -1) { + int error_num = 0; + m_file = fdopen(m_fd, openmode.c_str()); + error_num = errno; + if (error_num != 0 && m_file == nullptr) { + LOG_ERROR(Common_Filesystem, "Error on file: {}, error: {}", filename, + strerror(error_num)); + } + } + + m_good = m_file != nullptr; #else m_file = std::fopen(filename.c_str(), openmode.c_str()); m_good = m_file != nullptr; @@ -1083,4 +1179,30 @@ bool IOFile::Resize(u64 size) { return m_good; } +template +using boost_iostreams = boost::iostreams::stream; + +template <> +void OpenFStream( + boost_iostreams& fstream, + const std::string& filename) { + IOFile file(filename, "r"); + int fd = dup(file.GetFd()); + if (fd == -1) + return; + boost::iostreams::file_descriptor_source file_descriptor_source(fd, + boost::iostreams::close_handle); + fstream.open(file_descriptor_source); +} + +template <> +void OpenFStream( + boost_iostreams& fstream, const std::string& filename) { + IOFile file(filename, "w"); + int fd = dup(file.GetFd()); + if (fd == -1) + return; + boost::iostreams::file_descriptor_sink file_descriptor_sink(fd, boost::iostreams::close_handle); + fstream.open(file_descriptor_sink); +} } // namespace FileUtil diff --git a/src/common/file_util.h b/src/common/file_util.h index cdd55b665..6c04ff561 100644 --- a/src/common/file_util.h +++ b/src/common/file_util.h @@ -336,6 +336,15 @@ public: [[nodiscard]] bool IsGood() const { return m_good; } + [[nodiscard]] int GetFd() const { +#ifdef ANDROID + return m_fd; +#else + if (m_file == nullptr) + return -1; + return fileno(m_file); +#endif + } [[nodiscard]] explicit operator bool() const { return IsGood(); } @@ -359,6 +368,7 @@ private: bool Open(); std::FILE* m_file = nullptr; + int m_fd = -1; bool m_good = true; std::string filename; @@ -383,6 +393,8 @@ private: friend class boost::serialization::access; }; +template +void OpenFStream(T& fstream, const std::string& filename); } // namespace FileUtil // To deal with Windows being dumb at unicode: diff --git a/src/common/string_util.cpp b/src/common/string_util.cpp index 47aaddddb..3904b7e8c 100644 --- a/src/common/string_util.cpp +++ b/src/common/string_util.cpp @@ -123,6 +123,12 @@ std::string TabsToSpaces(int tab_size, std::string in) { return in; } +bool EndsWith(const std::string& value, const std::string& ending) { + if (ending.size() > value.size()) + return false; + return std::equal(ending.rbegin(), ending.rend(), value.rbegin()); +} + std::string ReplaceAll(std::string result, const std::string& src, const std::string& dest) { std::size_t pos = 0; diff --git a/src/common/string_util.h b/src/common/string_util.h index d9edbf803..8d3d08d7c 100644 --- a/src/common/string_util.h +++ b/src/common/string_util.h @@ -27,6 +27,8 @@ namespace Common { [[nodiscard]] std::string TabsToSpaces(int tab_size, std::string in); +[[nodiscard]] bool EndsWith(const std::string& value, const std::string& ending); + void SplitString(const std::string& str, char delim, std::vector& output); // "C:/Windows/winhelp.exe" to "C:/Windows/", "winhelp", ".exe" diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 0f473f6fc..9b3d40a07 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -477,7 +477,7 @@ endif() create_target_directory_groups(core) target_link_libraries(core PUBLIC common PRIVATE audio_core network video_core) -target_link_libraries(core PUBLIC Boost::boost PRIVATE cryptopp fmt::fmt open_source_archives Boost::serialization) +target_link_libraries(core PUBLIC Boost::boost PRIVATE cryptopp fmt::fmt open_source_archives Boost::serialization Boost::iostreams) set_target_properties(core PROPERTIES INTERPROCEDURAL_OPTIMIZATION ${ENABLE_LTO}) if (ENABLE_WEB_SERVICE) diff --git a/src/core/cheats/cheats.cpp b/src/core/cheats/cheats.cpp index 89b5f034f..f056aad6b 100644 --- a/src/core/cheats/cheats.cpp +++ b/src/core/cheats/cheats.cpp @@ -71,16 +71,12 @@ void CheatEngine::SaveCheatFile() const { if (!FileUtil::IsDirectory(cheat_dir)) { FileUtil::CreateDir(cheat_dir); } - - std::ofstream file; - OpenFStream(file, filepath, std::ios_base::out); + FileUtil::IOFile file(filepath, "w"); auto cheats = GetCheats(); for (const auto& cheat : cheats) { - file << cheat->ToString(); + file.WriteString(cheat->ToString()); } - - file.flush(); } void CheatEngine::LoadCheatFile() { diff --git a/src/core/cheats/gateway_cheat.cpp b/src/core/cheats/gateway_cheat.cpp index f5b5ff0b4..bb9965af6 100644 --- a/src/core/cheats/gateway_cheat.cpp +++ b/src/core/cheats/gateway_cheat.cpp @@ -8,6 +8,8 @@ #include #include #include +#include +#include #include "common/file_util.h" #include "common/logging/log.h" #include "common/string_util.h" @@ -473,9 +475,9 @@ std::string GatewayCheat::ToString() const { std::vector> GatewayCheat::LoadFile(const std::string& filepath) { std::vector> cheats; - std::ifstream file; - OpenFStream(file, filepath, std::ios_base::in); - if (!file) { + boost::iostreams::stream file; + FileUtil::OpenFStream(file, filepath); + if (!file.is_open()) { return cheats; } diff --git a/src/core/hw/aes/key.cpp b/src/core/hw/aes/key.cpp index 0cf2a217d..c8dc0adcf 100644 --- a/src/core/hw/aes/key.cpp +++ b/src/core/hw/aes/key.cpp @@ -6,6 +6,8 @@ #include #include #include +#include +#include #include #include #include @@ -428,9 +430,10 @@ void LoadNativeFirmKeysNew3DS() { void LoadPresetKeys() { const std::string filepath = FileUtil::GetUserPath(FileUtil::UserPath::SysDataDir) + AES_KEYS; FileUtil::CreateFullPath(filepath); // Create path if not already created - std::ifstream file; - OpenFStream(file, filepath, std::ios_base::in); - if (!file) { + + boost::iostreams::stream file; + FileUtil::OpenFStream(file, filepath); + if (!file.is_open()) { return; } diff --git a/src/video_core/renderer_opengl/post_processing_opengl.cpp b/src/video_core/renderer_opengl/post_processing_opengl.cpp index 80d8be7cb..aa4057f2d 100644 --- a/src/video_core/renderer_opengl/post_processing_opengl.cpp +++ b/src/video_core/renderer_opengl/post_processing_opengl.cpp @@ -10,6 +10,9 @@ #include "common/string_util.h" #include "video_core/renderer_opengl/post_processing_opengl.h" +#include +#include + namespace OpenGL { // The Dolphin shader header is added here for drop-in compatibility with most @@ -193,9 +196,9 @@ std::string GetPostProcessingShaderCode(bool anaglyph, std::string_view shader) return ""; } - std::ifstream file; - OpenFStream(file, shader_path, std::ios_base::in); - if (!file) { + boost::iostreams::stream file; + FileUtil::OpenFStream(file, shader_path); + if (!file.is_open()) { return ""; }