diff --git a/CMakeLists.txt b/CMakeLists.txt index 0bc68e6bd..ceb221156 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -212,6 +212,10 @@ if (ENABLE_QT) find_package(Qt5 REQUIRED COMPONENTS Widgets Multimedia Concurrent ${QT_PREFIX_HINT}) + if (UNIX AND NOT APPLE) + find_package(Qt5 REQUIRED COMPONENTS DBus ${QT_PREFIX_HINT}) + endif() + if (ENABLE_QT_TRANSLATION) find_package(Qt5 REQUIRED COMPONENTS LinguistTools ${QT_PREFIX_HINT}) endif() @@ -387,10 +391,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 d507d78ac..a0545d6ca 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 386abd868..1603a46bf 100644 --- a/src/android/app/build.gradle +++ b/src/android/app/build.gradle @@ -23,7 +23,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 @@ -102,7 +102,8 @@ android { arguments "-DENABLE_QT=0", // Don't use QT "-DENABLE_SDL2=0", // Don't use SDL "-DENABLE_WEB_SERVICE=0", // Don't use telemetry - "-DANDROID_ARM_NEON=true" // cryptopp requires Neon to work + "-DANDROID_ARM_NEON=true", // cryptopp requires Neon to work + "-DBUNDLE_SPEEX=ON" abiFilters abiFilter } @@ -111,22 +112,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' @@ -134,6 +138,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 9dca82b10..c5a0115d1 100644 --- a/src/android/app/src/main/AndroidManifest.xml +++ b/src/android/app/src/main/AndroidManifest.xml @@ -25,7 +25,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 28659589e..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 @@ -5,8 +5,8 @@ import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.os.Bundle; -import android.os.Handler; import android.preference.PreferenceManager; +import android.util.Pair; import android.util.SparseIntArray; import android.view.InputDevice; import android.view.KeyEvent; @@ -21,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; @@ -31,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; @@ -85,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); @@ -124,7 +139,6 @@ public final class EmulationActivity extends AppCompatActivity { .append(R.id.menu_emulation_close_game, EmulationActivity.MENU_ACTION_CLOSE_GAME); } - private View mDecorView; private EmulationFragment mEmulationFragment; private SharedPreferences mPreferences; private ControllerMappingHelper mControllerMappingHelper; @@ -170,16 +184,6 @@ public final class EmulationActivity extends AppCompatActivity { mControllerMappingHelper = new ControllerMappingHelper(); - // Get a handle to the Window containing the UI. - mDecorView = getWindow().getDecorView(); - mDecorView.setOnSystemUiVisibilityChangeListener(visibility -> - { - if ((visibility & View.SYSTEM_UI_FLAG_FULLSCREEN) == 0) { - // Go back to immersive fullscreen mode in 3s - Handler handler = new Handler(getMainLooper()); - handler.postDelayed(this::enableFullscreenImmersive, 3000 /* 3s */); - } - }); // Set these options now so that the SurfaceView the game renders into is the right size. enableFullscreenImmersive(); @@ -275,14 +279,14 @@ public final class EmulationActivity extends AppCompatActivity { } private void enableFullscreenImmersive() { - // It would be nice to use IMMERSIVE_STICKY, but that doesn't show the toolbar. - mDecorView.setSystemUiVisibility( + getWindow().getDecorView().setSystemUiVisibility( View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_FULLSCREEN | - View.SYSTEM_UI_FLAG_IMMERSIVE); + View.SYSTEM_UI_FLAG_IMMERSIVE | + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY); } @Override @@ -465,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: @@ -560,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/disk_shader_cache/DiskShaderCacheProgress.java b/src/android/app/src/main/java/org/citra/citra_emu/disk_shader_cache/DiskShaderCacheProgress.java index 1e3111739..ceaacc12a 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/disk_shader_cache/DiskShaderCacheProgress.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/disk_shader_cache/DiskShaderCacheProgress.java @@ -6,7 +6,6 @@ package org.citra.citra_emu.disk_shader_cache; import android.app.Activity; import android.app.Dialog; -import android.content.DialogInterface; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; @@ -56,10 +55,10 @@ public class DiskShaderCacheProgress { @NonNull @Override public Dialog onCreateDialog(Bundle savedInstanceState) { - final Activity emulationActivity = Objects.requireNonNull(getActivity()); + final Activity emulationActivity = requireActivity(); - final String title = Objects.requireNonNull(Objects.requireNonNull(getArguments()).getString("title")); - final String message = Objects.requireNonNull(Objects.requireNonNull(getArguments()).getString("message")); + final String title = Objects.requireNonNull(requireArguments().getString("title")); + final String message = Objects.requireNonNull(requireArguments().getString("message")); LayoutInflater inflater = LayoutInflater.from(emulationActivity); View view = inflater.inflate(R.layout.dialog_progress_bar, null); @@ -75,15 +74,17 @@ public class DiskShaderCacheProgress { finishLock.notifyAll(); } - return new MaterialAlertDialogBuilder(emulationActivity) + dialog = new MaterialAlertDialogBuilder(emulationActivity) + .setView(view) .setTitle(title) .setMessage(message) .setNegativeButton(android.R.string.cancel, (dialog, which) -> emulationActivity.onBackPressed()) .create(); + return dialog; } private void onUpdateProgress(String msg, int progress, int max) { - Objects.requireNonNull(getActivity()).runOnUiThread(() -> { + requireActivity().runOnUiThread(() -> { progressBar.setProgress(progress); progressBar.setMax(max); progressText.setText(String.format("%d/%d", progress, max)); diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatDetailsFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatDetailsFragment.java index f4833c0c5..83b3430cd 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatDetailsFragment.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatDetailsFragment.java @@ -11,7 +11,6 @@ import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.Fragment; import androidx.lifecycle.ViewModelProvider; diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatListFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatListFragment.java index 6c67a31d4..552cf796e 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatListFragment.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatListFragment.java @@ -7,6 +7,9 @@ import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; import androidx.fragment.app.Fragment; import androidx.lifecycle.ViewModelProvider; import androidx.recyclerview.widget.LinearLayoutManager; @@ -19,6 +22,9 @@ import org.citra.citra_emu.features.cheats.model.CheatsViewModel; import org.citra.citra_emu.ui.DividerItemDecoration; public class CheatListFragment extends Fragment { + private RecyclerView mRecyclerView; + private FloatingActionButton mFab; + @Nullable @Override public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @@ -28,19 +34,38 @@ public class CheatListFragment extends Fragment { @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - RecyclerView recyclerView = view.findViewById(R.id.cheat_list); - FloatingActionButton fab = view.findViewById(R.id.fab); + mRecyclerView = view.findViewById(R.id.cheat_list); + mFab = view.findViewById(R.id.fab); CheatsActivity activity = (CheatsActivity) requireActivity(); CheatsViewModel viewModel = new ViewModelProvider(activity).get(CheatsViewModel.class); - recyclerView.setAdapter(new CheatsAdapter(activity, viewModel)); - recyclerView.setLayoutManager(new LinearLayoutManager(activity)); - recyclerView.addItemDecoration(new DividerItemDecoration(activity, null)); + mRecyclerView.setAdapter(new CheatsAdapter(activity, viewModel)); + mRecyclerView.setLayoutManager(new LinearLayoutManager(activity)); + mRecyclerView.addItemDecoration(new DividerItemDecoration(activity, null)); - fab.setOnClickListener(v -> { + mFab.setOnClickListener(v -> { viewModel.startAddingCheat(); viewModel.openDetailsView(); }); + + setInsets(); + } + + private void setInsets() { + ViewCompat.setOnApplyWindowInsetsListener(mRecyclerView, (v, windowInsets) -> { + Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()); + v.setPadding(0, 0, 0, insets.bottom + getResources().getDimensionPixelSize(R.dimen.spacing_fab_list)); + + ViewGroup.MarginLayoutParams mlpFab = + (ViewGroup.MarginLayoutParams) mFab.getLayoutParams(); + int fabPadding = getResources().getDimensionPixelSize(R.dimen.spacing_large); + mlpFab.leftMargin = insets.left + fabPadding; + mlpFab.bottomMargin = insets.bottom + fabPadding; + mlpFab.rightMargin = insets.right + fabPadding; + mFab.setLayoutParams(mlpFab); + + return windowInsets; + }); } } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsActivity.java index a6ab89429..5df4bc83d 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsActivity.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsActivity.java @@ -10,18 +10,26 @@ import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; +import androidx.core.graphics.Insets; import androidx.core.view.ViewCompat; +import androidx.core.view.WindowCompat; +import androidx.core.view.WindowInsetsAnimationCompat; +import androidx.core.view.WindowInsetsCompat; import androidx.lifecycle.ViewModelProvider; import androidx.slidingpanelayout.widget.SlidingPaneLayout; +import com.google.android.material.appbar.AppBarLayout; import com.google.android.material.appbar.MaterialToolbar; import org.citra.citra_emu.R; import org.citra.citra_emu.features.cheats.model.Cheat; import org.citra.citra_emu.features.cheats.model.CheatsViewModel; import org.citra.citra_emu.ui.TwoPaneOnBackPressedCallback; +import org.citra.citra_emu.utils.InsetsHelper; import org.citra.citra_emu.utils.ThemeUtil; +import java.util.List; + public class CheatsActivity extends AppCompatActivity implements SlidingPaneLayout.PanelSlideListener { private CheatsViewModel mViewModel; @@ -44,14 +52,16 @@ public class CheatsActivity extends AppCompatActivity super.onCreate(savedInstanceState); + WindowCompat.setDecorFitsSystemWindows(getWindow(), false); + mViewModel = new ViewModelProvider(this).get(CheatsViewModel.class); mViewModel.load(); setContentView(R.layout.activity_cheats); mSlidingPaneLayout = findViewById(R.id.sliding_pane_layout); - mCheatList = findViewById(R.id.cheat_list); - mCheatDetails = findViewById(R.id.cheat_details); + mCheatList = findViewById(R.id.cheat_list_container); + mCheatDetails = findViewById(R.id.cheat_details_container); mCheatListLastFocus = mCheatList; mCheatDetailsLastFocus = mCheatDetails; @@ -71,6 +81,8 @@ public class CheatsActivity extends AppCompatActivity MaterialToolbar toolbar = findViewById(R.id.toolbar_cheats); setSupportActionBar(toolbar); getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + setInsets(); } @Override @@ -153,8 +165,7 @@ public class CheatsActivity extends AppCompatActivity } } - public static void setOnFocusChangeListenerRecursively(@NonNull View view, - View.OnFocusChangeListener listener) { + public static void setOnFocusChangeListenerRecursively(@NonNull View view, View.OnFocusChangeListener listener) { view.setOnFocusChangeListener(listener); if (view instanceof ViewGroup) { @@ -165,4 +176,56 @@ public class CheatsActivity extends AppCompatActivity } } } + + private void setInsets() { + AppBarLayout appBarLayout = findViewById(R.id.appbar_cheats); + ViewCompat.setOnApplyWindowInsetsListener(mSlidingPaneLayout, (v, windowInsets) -> { + Insets barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()); + Insets keyboardInsets = windowInsets.getInsets(WindowInsetsCompat.Type.ime()); + + InsetsHelper.insetAppBar(barInsets, appBarLayout); + mSlidingPaneLayout.setPadding(barInsets.left, 0, barInsets.right, 0); + + // Set keyboard insets if the system supports smooth keyboard animations + ViewGroup.MarginLayoutParams mlpDetails = + (ViewGroup.MarginLayoutParams) mCheatDetails.getLayoutParams(); + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.R) { + if (keyboardInsets.bottom > 0) { + mlpDetails.bottomMargin = keyboardInsets.bottom; + } else { + mlpDetails.bottomMargin = barInsets.bottom; + } + } else { + if (mlpDetails.bottomMargin == 0) { + mlpDetails.bottomMargin = barInsets.bottom; + } + } + mCheatDetails.setLayoutParams(mlpDetails); + + return windowInsets; + }); + + // Update the layout for every frame that the keyboard animates in + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) { + ViewCompat.setWindowInsetsAnimationCallback(mCheatDetails, + new WindowInsetsAnimationCompat.Callback( + WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_STOP) { + int keyboardInsets = 0; + int barInsets = 0; + + @NonNull + @Override + public WindowInsetsCompat onProgress(@NonNull WindowInsetsCompat insets, + @NonNull List runningAnimations) { + ViewGroup.MarginLayoutParams mlpDetails = + (ViewGroup.MarginLayoutParams) mCheatDetails.getLayoutParams(); + keyboardInsets = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom; + barInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom; + mlpDetails.bottomMargin = Math.max(keyboardInsets, barInsets); + mCheatDetails.setLayoutParams(mlpDetails); + return insets; + } + }); + } + } } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivity.java index cfbcf5099..19aacb7f5 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivity.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivity.java @@ -8,13 +8,19 @@ import android.os.Bundle; import android.provider.Settings; import android.view.Menu; import android.view.MenuInflater; +import android.widget.FrameLayout; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowCompat; +import androidx.core.view.WindowInsetsCompat; import androidx.fragment.app.FragmentTransaction; import androidx.localbroadcastmanager.content.LocalBroadcastManager; +import com.google.android.material.appbar.AppBarLayout; import com.google.android.material.appbar.MaterialToolbar; import org.citra.citra_emu.NativeLibrary; @@ -22,6 +28,7 @@ import org.citra.citra_emu.R; import org.citra.citra_emu.utils.DirectoryInitialization; import org.citra.citra_emu.utils.DirectoryStateReceiver; import org.citra.citra_emu.utils.EmulationMenuSettings; +import org.citra.citra_emu.utils.InsetsHelper; import org.citra.citra_emu.utils.ThemeUtil; public final class SettingsActivity extends AppCompatActivity implements SettingsActivityView { @@ -44,9 +51,10 @@ public final class SettingsActivity extends AppCompatActivity implements Setting ThemeUtil.applyTheme(this); super.onCreate(savedInstanceState); - setContentView(R.layout.activity_settings); + WindowCompat.setDecorFitsSystemWindows(getWindow(), false); + Intent launcher = getIntent(); String gameID = launcher.getStringExtra(ARG_GAME_ID); String menuTag = launcher.getStringExtra(ARG_MENU_TAG); @@ -57,6 +65,8 @@ public final class SettingsActivity extends AppCompatActivity implements Setting MaterialToolbar toolbar = findViewById(R.id.toolbar_settings); setSupportActionBar(toolbar); getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + setInsets(); } @Override @@ -219,4 +229,14 @@ public final class SettingsActivity extends AppCompatActivity implements Setting private SettingsFragment getFragment() { return (SettingsFragment) getSupportFragmentManager().findFragmentByTag(FRAGMENT_TAG); } + + private void setInsets() { + AppBarLayout appBar = findViewById(R.id.appbar_settings); + FrameLayout frame = findViewById(R.id.frame_content); + ViewCompat.setOnApplyWindowInsetsListener(frame, (v, windowInsets) -> { + Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()); + InsetsHelper.insetAppBar(insets, appBar); + return windowInsets; + }); + } } 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/ui/SettingsFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragment.java index 5799dcb8d..76d4223f5 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragment.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragment.java @@ -8,6 +8,9 @@ import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; import androidx.fragment.app.Fragment; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -29,6 +32,8 @@ public final class SettingsFragment extends Fragment implements SettingsFragment private SettingsAdapter mAdapter; + private RecyclerView mRecyclerView; + public static Fragment newInstance(String menuTag, String gameId) { SettingsFragment fragment = new SettingsFragment(); @@ -71,15 +76,17 @@ public final class SettingsFragment extends Fragment implements SettingsFragment public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { LinearLayoutManager manager = new LinearLayoutManager(getActivity()); - RecyclerView recyclerView = view.findViewById(R.id.list_settings); + mRecyclerView = view.findViewById(R.id.list_settings); - recyclerView.setAdapter(mAdapter); - recyclerView.setLayoutManager(manager); - recyclerView.addItemDecoration(new DividerItemDecoration(getActivity(), null)); + mRecyclerView.setAdapter(mAdapter); + mRecyclerView.setLayoutManager(manager); + mRecyclerView.addItemDecoration(new DividerItemDecoration(getActivity(), null)); SettingsActivityView activity = (SettingsActivityView) getActivity(); mPresenter.onViewCreated(activity.getSettings()); + + setInsets(); } @Override @@ -133,4 +140,12 @@ public final class SettingsFragment extends Fragment implements SettingsFragment public void onSettingChanged() { mActivity.onSettingChanged(); } + + private void setInsets() { + ViewCompat.setOnApplyWindowInsetsListener(mRecyclerView, (v, windowInsets) -> { + Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()); + v.setPadding(insets.left, 0, insets.right, insets.bottom); + return windowInsets; + }); + } } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFrameLayout.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFrameLayout.java deleted file mode 100644 index 67bde5709..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFrameLayout.java +++ /dev/null @@ -1,48 +0,0 @@ -package org.citra.citra_emu.features.settings.ui; - -import android.content.Context; -import android.util.AttributeSet; -import android.widget.FrameLayout; - -/** - * FrameLayout subclass with few Properties added to simplify animations. - * Don't remove the methods appearing as unused, in order not to break the menu animations - */ -public final class SettingsFrameLayout extends FrameLayout { - private float mVisibleness = 1.0f; - - public SettingsFrameLayout(Context context) { - super(context); - } - - public SettingsFrameLayout(Context context, AttributeSet attrs) { - super(context, attrs); - } - - public SettingsFrameLayout(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - public SettingsFrameLayout(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { - super(context, attrs, defStyleAttr, defStyleRes); - } - - public float getYFraction() { - return getY() / getHeight(); - } - - public void setYFraction(float yFraction) { - final int height = getHeight(); - setY((height > 0) ? (yFraction * height) : -9999); - } - - public float getVisibleness() { - return mVisibleness; - } - - public void setVisibleness(float visibleness) { - setScaleX(visibleness); - setScaleY(visibleness); - setAlpha(visibleness); - } -} 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 6748da1d7..b38907a9c 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; @@ -149,13 +154,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; ) { @@ -170,11 +177,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 { @@ -182,7 +189,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()); } } } @@ -216,17 +223,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); @@ -266,14 +279,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..b2833d1ac 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,35 +1,46 @@ 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.FrameLayout; 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 androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowCompat; +import androidx.core.view.WindowInsetsCompat; + +import com.google.android.material.appbar.AppBarLayout; 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.InsetsHelper; import org.citra.citra_emu.utils.PermissionsHandler; 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,13 +57,72 @@ 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); setContentView(R.layout.activity_main); + WindowCompat.setDecorFitsSystemWindows(getWindow(), false); + findViews(); setSupportActionBar(mToolbar); @@ -61,7 +131,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) @@ -77,6 +147,8 @@ public final class MainActivity extends AppCompatActivity implements MainView { // Dismiss previous notifications (should not happen unless a crash occurred) EmulationActivity.tryDismissRunningNotification(this); + + setInsets(); } @Override @@ -144,7 +216,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 +224,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 +256,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); @@ -266,4 +289,15 @@ public final class MainActivity extends AppCompatActivity implements MainView { public static void invokePremiumBilling(Runnable callback) { mBillingManager.invokePremiumBilling(callback); } + + private void setInsets() { + AppBarLayout appBar = findViewById(R.id.appbar); + FrameLayout frame = findViewById(R.id.games_platform_frame); + ViewCompat.setOnApplyWindowInsetsListener(frame, (v, windowInsets) -> { + Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()); + InsetsHelper.insetAppBar(insets, appBar); + frame.setPadding(insets.left, 0, insets.right, 0); + return windowInsets; + }); + } } 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/ui/platform/PlatformGamesFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesFragment.java index 4d863d9f9..2fd91e7ca 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesFragment.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesFragment.java @@ -7,7 +7,9 @@ import android.view.View; import android.view.ViewGroup; import android.widget.TextView; -import androidx.core.content.ContextCompat; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; import androidx.fragment.app.Fragment; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager; @@ -68,6 +70,8 @@ public final class PlatformGamesFragment extends Fragment implements PlatformGam pullToRefresh.setProgressBackgroundColorSchemeColor(MaterialColors.getColor(pullToRefresh, R.attr.colorPrimary)); pullToRefresh.setColorSchemeColors(MaterialColors.getColor(pullToRefresh, R.attr.colorOnPrimary)); + + setInsets(); } @Override @@ -92,4 +96,12 @@ public final class PlatformGamesFragment extends Fragment implements PlatformGam mRecyclerView = root.findViewById(R.id.grid_games); mTextView = root.findViewById(R.id.gamelist_empty_text); } + + private void setInsets() { + ViewCompat.setOnApplyWindowInsetsListener(mRecyclerView, (v, windowInsets) -> { + Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()); + v.setPadding(0, 0, 0, insets.bottom); + return windowInsets; + }); + } } 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/InsetsHelper.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/InsetsHelper.java new file mode 100644 index 000000000..55f8a463e --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/InsetsHelper.java @@ -0,0 +1,33 @@ +package org.citra.citra_emu.utils; + +import android.content.Context; +import android.content.res.Resources; +import android.view.ViewGroup; + +import androidx.core.graphics.Insets; + +import com.google.android.material.appbar.AppBarLayout; + +public class InsetsHelper { + public static final int THREE_BUTTON_NAVIGATION = 0; + public static final int TWO_BUTTON_NAVIGATION = 1; + public static final int GESTURE_NAVIGATION = 2; + + public static void insetAppBar(Insets insets, AppBarLayout appBarLayout) + { + ViewGroup.MarginLayoutParams mlpAppBar = + (ViewGroup.MarginLayoutParams) appBarLayout.getLayoutParams(); + mlpAppBar.leftMargin = insets.left; + mlpAppBar.rightMargin = insets.right; + appBarLayout.setLayoutParams(mlpAppBar); + } + + public static int getSystemGestureType(Context context) { + Resources resources = context.getResources(); + int resourceId = resources.getIdentifier("config_navBarInteractionMode", "integer", "android"); + if (resourceId != 0) { + return resources.getInteger(resourceId); + } + return 0; + } +} 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/java/org/citra/citra_emu/utils/ThemeUtil.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/ThemeUtil.java index 98556514a..fbccc9df1 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/ThemeUtil.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/ThemeUtil.java @@ -1,21 +1,31 @@ package org.citra.citra_emu.utils; +import android.app.Activity; import android.content.SharedPreferences; import android.content.res.Configuration; +import android.graphics.Color; import android.os.Build; import android.preference.PreferenceManager; +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatDelegate; +import androidx.core.content.ContextCompat; import androidx.core.view.WindowCompat; import androidx.core.view.WindowInsetsControllerCompat; +import com.google.android.material.color.MaterialColors; + import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.R; import org.citra.citra_emu.features.settings.utils.SettingsFile; public class ThemeUtil { private static SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + public static final float NAV_BAR_ALPHA = 0.9f; + private static void applyTheme(int designValue, AppCompatActivity activity) { switch (designValue) { case 0: @@ -34,9 +44,40 @@ public class ThemeUtil { int systemReportedThemeMode = activity.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; WindowInsetsControllerCompat windowController = WindowCompat.getInsetsController(activity.getWindow(), activity.getWindow().getDecorView()); windowController.setAppearanceLightStatusBars(systemReportedThemeMode == Configuration.UI_MODE_NIGHT_NO); + windowController.setAppearanceLightNavigationBars(systemReportedThemeMode == Configuration.UI_MODE_NIGHT_NO); + + setNavigationBarColor(activity, MaterialColors.getColor(activity.getWindow().getDecorView(), R.attr.colorSurface)); } public static void applyTheme(AppCompatActivity activity) { applyTheme(mPreferences.getInt(SettingsFile.KEY_DESIGN, 0), activity); } + + public static void setNavigationBarColor(@NonNull Activity activity, @ColorInt int color) { + int gestureType = InsetsHelper.getSystemGestureType(activity.getApplicationContext()); + int orientation = activity.getResources().getConfiguration().orientation; + + // Use a solid color when the navigation bar is on the left/right edge of the screen + if ((gestureType == InsetsHelper.THREE_BUTTON_NAVIGATION || + gestureType == InsetsHelper.TWO_BUTTON_NAVIGATION) && + orientation == Configuration.ORIENTATION_LANDSCAPE) { + activity.getWindow().setNavigationBarColor(color); + } else if (gestureType == InsetsHelper.THREE_BUTTON_NAVIGATION || + gestureType == InsetsHelper.TWO_BUTTON_NAVIGATION) { + // Use semi-transparent color when in portrait mode with three/two button navigation to + // partially see list items behind the navigation bar + activity.getWindow().setNavigationBarColor(ThemeUtil.getColorWithOpacity(color, NAV_BAR_ALPHA)); + } else { + // Use transparent color when using gesture navigation + activity.getWindow().setNavigationBarColor( + ContextCompat.getColor(activity.getApplicationContext(), + android.R.color.transparent)); + } + } + + @ColorInt + public static int getColorWithOpacity(@ColorInt int color, float alphaFactor) { + return Color.argb(Math.round(alphaFactor * Color.alpha(color)), Color.red(color), + Color.green(color), Color.blue(color)); + } } diff --git a/src/android/app/src/main/jni/config.cpp b/src/android/app/src/main/jni/config.cpp index 48fb35f6c..008dfd693 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/default_ini.h b/src/android/app/src/main/jni/default_ini.h index 9d6ffa466..6796dbb39 100644 --- a/src/android/app/src/main/jni/default_ini.h +++ b/src/android/app/src/main/jni/default_ini.h @@ -180,9 +180,12 @@ factor_3d = # The name of the post processing shader to apply. # Loaded from shaders if render_3d is off or side by side. -# Loaded from shaders/anaglyph if render_3d is anaglyph pp_shader_name = +# The name of the shader to apply when render_3d is anaglyph. +# Loaded from shaders/anaglyph +anaglyph_shader_name = + # Whether to enable linear filtering or not # This is required for some shaders to work correctly # 0: Nearest, 1 (default): Linear 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/native.cpp b/src/android/app/src/main/jni/native.cpp index a5a9bd88f..b19b8dbf5 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" @@ -329,6 +331,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{}; @@ -502,6 +506,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/drawable/ic_citra.xml b/src/android/app/src/main/res/drawable/ic_citra.xml new file mode 100644 index 000000000..039dec3c2 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_citra.xml @@ -0,0 +1,255 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/drawable/ic_citra_monochrome.xml b/src/android/app/src/main/res/drawable/ic_citra_monochrome.xml new file mode 100644 index 000000000..77375ecfb --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_citra_monochrome.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout/activity_cheats.xml b/src/android/app/src/main/res/layout/activity_cheats.xml index 0b6204b6d..7b63b11c3 100644 --- a/src/android/app/src/main/res/layout/activity_cheats.xml +++ b/src/android/app/src/main/res/layout/activity_cheats.xml @@ -2,6 +2,7 @@ @@ -17,14 +18,12 @@ android:id="@+id/appbar_cheats" android:layout_width="match_parent" android:layout_height="wrap_content" - app:elevation="0dp" - app:liftOnScroll="false"> + android:fitsSystemWindows="true"> + android:layout_height="?attr/actionBarSize" /> @@ -40,18 +39,20 @@ app:layout_constraintTop_toBottomOf="@id/coordinator_cheats"> + android:id="@+id/cheat_list_container" + android:name="org.citra.citra_emu.features.cheats.ui.CheatListFragment" + tools:layout="@layout/fragment_cheat_list" /> + android:id="@+id/cheat_details_container" + android:name="org.citra.citra_emu.features.cheats.ui.CheatDetailsFragment" + tools:layout="@layout/fragment_cheat_details" /> diff --git a/src/android/app/src/main/res/layout/activity_main.xml b/src/android/app/src/main/res/layout/activity_main.xml index 1f737c321..c567abe69 100644 --- a/src/android/app/src/main/res/layout/activity_main.xml +++ b/src/android/app/src/main/res/layout/activity_main.xml @@ -9,13 +9,14 @@ + android:layout_height="wrap_content" + android:fitsSystemWindows="true" + app:liftOnScrollTargetViewId="@id/grid_games"> diff --git a/src/android/app/src/main/res/layout/activity_settings.xml b/src/android/app/src/main/res/layout/activity_settings.xml index 9da84faea..c57abfd0a 100644 --- a/src/android/app/src/main/res/layout/activity_settings.xml +++ b/src/android/app/src/main/res/layout/activity_settings.xml @@ -8,13 +8,13 @@ + android:layout_height="wrap_content" + android:fitsSystemWindows="true"> + android:layout_height="?attr/actionBarSize" /> 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/layout/fragment_cheat_list.xml b/src/android/app/src/main/res/layout/fragment_cheat_list.xml index 679a49c28..04bf76ffe 100644 --- a/src/android/app/src/main/res/layout/fragment_cheat_list.xml +++ b/src/android/app/src/main/res/layout/fragment_cheat_list.xml @@ -9,6 +9,7 @@ android:id="@+id/cheat_list" android:layout_width="match_parent" android:layout_height="0dp" + android:clipToPadding="false" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" @@ -21,7 +22,7 @@ android:src="@drawable/ic_add" android:contentDescription="@string/cheats_add" android:layout_margin="16dp" - app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent" app:layout_constraintBottom_toBottomOf="parent" /> diff --git a/src/android/app/src/main/res/layout/fragment_grid.xml b/src/android/app/src/main/res/layout/fragment_grid.xml index 5978bf998..3be511789 100644 --- a/src/android/app/src/main/res/layout/fragment_grid.xml +++ b/src/android/app/src/main/res/layout/fragment_grid.xml @@ -26,6 +26,7 @@ android:id="@+id/grid_games" android:layout_width="match_parent" android:layout_height="match_parent" + android:clipToPadding="false" tools:listitem="@layout/card_game" /> diff --git a/src/android/app/src/main/res/layout/fragment_settings.xml b/src/android/app/src/main/res/layout/fragment_settings.xml index f8724552c..46ca12e93 100644 --- a/src/android/app/src/main/res/layout/fragment_settings.xml +++ b/src/android/app/src/main/res/layout/fragment_settings.xml @@ -1,6 +1,7 @@ - @@ -8,6 +9,7 @@ + android:layout_height="match_parent" + android:clipToPadding="false" /> - + diff --git a/src/android/app/src/main/res/layout/list_item_cheat.xml b/src/android/app/src/main/res/layout/list_item_cheat.xml index d31ae63f9..5afa11794 100644 --- a/src/android/app/src/main/res/layout/list_item_cheat.xml +++ b/src/android/app/src/main/res/layout/list_item_cheat.xml @@ -24,11 +24,12 @@ + - - \ No newline at end of file + + + diff --git a/src/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/src/android/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 57ea32d88..000000000 Binary files a/src/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/src/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/src/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png deleted file mode 100644 index 18cc694d1..000000000 Binary files a/src/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/src/android/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 7052f4077..000000000 Binary files a/src/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/src/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/src/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png deleted file mode 100644 index 0e7cdeed6..000000000 Binary files a/src/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/src/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 4d3e9fc41..000000000 Binary files a/src/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/src/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/src/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png deleted file mode 100644 index b57a8d623..000000000 Binary files a/src/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/src/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index d2c6d0692..000000000 Binary files a/src/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/src/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/src/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png deleted file mode 100644 index 22f6eb36f..000000000 Binary files a/src/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/src/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 1aa7f3ae2..000000000 Binary files a/src/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/src/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/src/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png deleted file mode 100644 index b57c8f75b..000000000 Binary files a/src/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src/android/app/src/main/res/values-de/strings.xml b/src/android/app/src/main/res/values-de/strings.xml index 403e94894..56d279596 100644 --- a/src/android/app/src/main/res/values-de/strings.xml +++ b/src/android/app/src/main/res/values-de/strings.xml @@ -98,7 +98,6 @@ 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/citra/config.cpp b/src/citra/config.cpp index 125cdfdb6..431731140 100644 --- a/src/citra/config.cpp +++ b/src/citra/config.cpp @@ -140,15 +140,10 @@ void Config::ReadValues() { sdl2_config->GetInteger("Renderer", "render_3d", 0)); Settings::values.factor_3d = static_cast(sdl2_config->GetInteger("Renderer", "factor_3d", 0)); - std::string default_shader = "none (builtin)"; - if (Settings::values.render_3d.GetValue() == Settings::StereoRenderOption::Anaglyph) - default_shader = "dubois (builtin)"; - else if (Settings::values.render_3d.GetValue() == Settings::StereoRenderOption::Interlaced || - Settings::values.render_3d.GetValue() == - Settings::StereoRenderOption::ReverseInterlaced) - default_shader = "horizontal (builtin)"; Settings::values.pp_shader_name = - sdl2_config->GetString("Renderer", "pp_shader_name", default_shader); + sdl2_config->GetString("Renderer", "pp_shader_name", "none (builtin)"); + Settings::values.anaglyph_shader_name = + sdl2_config->GetString("Renderer", "anaglyph_shader_name", "dubois (builtin)"); Settings::values.filter_mode = sdl2_config->GetBoolean("Renderer", "filter_mode", true); Settings::values.bg_red = static_cast(sdl2_config->GetReal("Renderer", "bg_red", 0.0)); diff --git a/src/citra/default_ini.h b/src/citra/default_ini.h index 19f57afb8..c545aa77d 100644 --- a/src/citra/default_ini.h +++ b/src/citra/default_ini.h @@ -172,9 +172,12 @@ mono_render_option = # The name of the post processing shader to apply. # Loaded from shaders if render_3d is off or side by side. -# Loaded from shaders/anaglyph if render_3d is anaglyph pp_shader_name = +# The name of the shader to apply when render_3d is anaglyph. +# Loaded from shaders/anaglyph +anaglyph_shader_name = + # Whether to enable linear filtering or not # This is required for some shaders to work correctly # 0: Nearest, 1 (default): Linear diff --git a/src/citra_qt/CMakeLists.txt b/src/citra_qt/CMakeLists.txt index efe5de2f8..edc648e5e 100644 --- a/src/citra_qt/CMakeLists.txt +++ b/src/citra_qt/CMakeLists.txt @@ -271,6 +271,10 @@ if (NOT WIN32) target_include_directories(citra-qt PRIVATE ${Qt5Gui_PRIVATE_INCLUDE_DIRS}) endif() +if (UNIX AND NOT APPLE) + target_link_libraries(citra-qt PRIVATE Qt5::DBus) +endif() + target_compile_definitions(citra-qt PRIVATE # Use QStringBuilder for string concatenation to reduce # the overall number of temporary strings created. diff --git a/src/citra_qt/configuration/config.cpp b/src/citra_qt/configuration/config.cpp index f8674c850..910f703c4 100644 --- a/src/citra_qt/configuration/config.cpp +++ b/src/citra_qt/configuration/config.cpp @@ -149,14 +149,14 @@ void Config::ReadGlobalSetting(Settings::SwitchableSetting& settin const bool use_global = qt_config->value(name + QStringLiteral("/use_global"), true).toBool(); setting.SetGlobal(use_global); if (global || !use_global) { - QVariant value{}; + QVariant default_value{}; if constexpr (std::is_enum_v) { using TypeU = std::underlying_type_t; - value = QVariant::fromValue(static_cast(setting.GetDefault())); - setting.SetValue(static_cast(ReadSetting(name, value).value())); + default_value = QVariant::fromValue(static_cast(setting.GetDefault())); + setting.SetValue(static_cast(ReadSetting(name, default_value).value())); } else { - value = QVariant::fromValue(setting.GetDefault()); - setting.SetValue(ReadSetting(name, value).value()); + default_value = QVariant::fromValue(setting.GetDefault()); + setting.SetValue(ReadSetting(name, default_value).value()); } } } @@ -182,6 +182,15 @@ void Config::WriteBasicSetting(const Settings::Setting& setting) { qt_config->setValue(name, QString::fromStdString(value)); } +// Explicit u16 definition: Qt would store it as QMetaType otherwise, which is not human-readable +template <> +void Config::WriteBasicSetting(const Settings::Setting& setting) { + const QString name = QString::fromStdString(setting.GetLabel()); + const u16& value = setting.GetValue(); + qt_config->setValue(name + QStringLiteral("/default"), value == setting.GetDefault()); + qt_config->setValue(name, static_cast(value)); +} + template void Config::WriteBasicSetting(const Settings::Setting& setting) { const QString name = QString::fromStdString(setting.GetLabel()); @@ -224,6 +233,20 @@ void Config::WriteGlobalSetting(const Settings::SwitchableSetting& } } +// Explicit u16 definition: Qt would store it as QMetaType otherwise, which is not human-readable +template <> +void Config::WriteGlobalSetting(const Settings::SwitchableSetting& setting) { + const QString name = QString::fromStdString(setting.GetLabel()); + const u16& value = setting.GetValue(global); + if (!global) { + qt_config->setValue(name + QStringLiteral("/use_global"), setting.UsingGlobal()); + } + if (global || !setting.UsingGlobal()) { + qt_config->setValue(name + QStringLiteral("/default"), value == setting.GetDefault()); + qt_config->setValue(name, static_cast(value)); + } +} + void Config::ReadValues() { if (global) { ReadControlValues(); @@ -233,7 +256,6 @@ void Config::ReadValues() { ReadDebuggingValues(); ReadWebServiceValues(); ReadVideoDumpingValues(); - ReadUtilityValues(); } ReadUIValues(); @@ -242,6 +264,7 @@ void Config::ReadValues() { ReadLayoutValues(); ReadAudioValues(); ReadSystemValues(); + ReadUtilityValues(); } void Config::ReadAudioValues() { @@ -413,9 +436,9 @@ void Config::ReadControlValues() { void Config::ReadUtilityValues() { qt_config->beginGroup(QStringLiteral("Utility")); - ReadBasicSetting(Settings::values.dump_textures); - ReadBasicSetting(Settings::values.custom_textures); - ReadBasicSetting(Settings::values.preload_textures); + ReadGlobalSetting(Settings::values.dump_textures); + ReadGlobalSetting(Settings::values.custom_textures); + ReadGlobalSetting(Settings::values.preload_textures); qt_config->endGroup(); } @@ -476,14 +499,9 @@ void Config::ReadLayoutValues() { ReadGlobalSetting(Settings::values.render_3d); ReadGlobalSetting(Settings::values.factor_3d); - Settings::values.pp_shader_name = - ReadSetting(QStringLiteral("pp_shader_name"), (Settings::values.render_3d.GetValue() == - Settings::StereoRenderOption::Anaglyph) - ? QStringLiteral("dubois (builtin)") - : QStringLiteral("none (builtin)")) - .toString() - .toStdString(); ReadGlobalSetting(Settings::values.filter_mode); + ReadGlobalSetting(Settings::values.pp_shader_name); + ReadGlobalSetting(Settings::values.anaglyph_shader_name); ReadGlobalSetting(Settings::values.layout_option); ReadGlobalSetting(Settings::values.swap_screen); ReadGlobalSetting(Settings::values.upright_screen); @@ -818,7 +836,6 @@ void Config::SaveValues() { SaveDebuggingValues(); SaveWebServiceValues(); SaveVideoDumpingValues(); - SaveUtilityValues(); } SaveUIValues(); @@ -827,6 +844,7 @@ void Config::SaveValues() { SaveLayoutValues(); SaveAudioValues(); SaveSystemValues(); + SaveUtilityValues(); qt_config->sync(); } @@ -939,9 +957,9 @@ void Config::SaveControlValues() { void Config::SaveUtilityValues() { qt_config->beginGroup(QStringLiteral("Utility")); - WriteBasicSetting(Settings::values.dump_textures); - WriteBasicSetting(Settings::values.custom_textures); - WriteBasicSetting(Settings::values.preload_textures); + WriteGlobalSetting(Settings::values.dump_textures); + WriteGlobalSetting(Settings::values.custom_textures); + WriteGlobalSetting(Settings::values.preload_textures); qt_config->endGroup(); } @@ -997,12 +1015,9 @@ void Config::SaveLayoutValues() { WriteGlobalSetting(Settings::values.render_3d); WriteGlobalSetting(Settings::values.factor_3d); - WriteSetting(QStringLiteral("pp_shader_name"), - QString::fromStdString(Settings::values.pp_shader_name.GetValue()), - (Settings::values.render_3d.GetValue() == Settings::StereoRenderOption::Anaglyph) - ? QStringLiteral("dubois (builtin)") - : QStringLiteral("none (builtin)")); WriteGlobalSetting(Settings::values.filter_mode); + WriteGlobalSetting(Settings::values.pp_shader_name); + WriteGlobalSetting(Settings::values.anaglyph_shader_name); WriteGlobalSetting(Settings::values.layout_option); WriteGlobalSetting(Settings::values.swap_screen); WriteGlobalSetting(Settings::values.upright_screen); @@ -1288,15 +1303,6 @@ QVariant Config::ReadSetting(const QString& name, const QVariant& default_value) return result; } -template -void Config::ReadSettingGlobal(Type& setting, const QString& name, - const QVariant& default_value) const { - const bool use_global = qt_config->value(name + QStringLiteral("/use_global"), true).toBool(); - if (global || !use_global) { - setting = ReadSetting(name, default_value).value(); - } -} - void Config::WriteSetting(const QString& name, const QVariant& value) { qt_config->setValue(name, value); } @@ -1307,17 +1313,6 @@ void Config::WriteSetting(const QString& name, const QVariant& value, qt_config->setValue(name, value); } -void Config::WriteSetting(const QString& name, const QVariant& value, const QVariant& default_value, - bool use_global) { - if (!global) { - qt_config->setValue(name + QStringLiteral("/use_global"), use_global); - } - if (global || !use_global) { - qt_config->setValue(name + QStringLiteral("/default"), value == default_value); - qt_config->setValue(name, value); - } -} - void Config::Reload() { ReadValues(); // To apply default value changes diff --git a/src/citra_qt/configuration/config.h b/src/citra_qt/configuration/config.h index d28983a7a..67eaa40a3 100644 --- a/src/citra_qt/configuration/config.h +++ b/src/citra_qt/configuration/config.h @@ -84,31 +84,15 @@ private: QVariant ReadSetting(const QString& name) const; QVariant ReadSetting(const QString& name, const QVariant& default_value) const; - /** - * Only reads a setting from the qt_config if the current config is a global config, or if the - * current config is a custom config and the setting is overriding the global setting. Otherwise - * it does nothing. - * - * @param setting The variable to be modified - * @param name The setting's identifier - * @param default_value The value to use when the setting is not already present in the config - */ - template - void ReadSettingGlobal(Type& setting, const QString& name, const QVariant& default_value) const; - /** * Writes a setting to the qt_config. * * @param name The setting's idetentifier * @param value Value of the setting * @param default_value Default of the setting if not present in qt_config - * @param use_global Specifies if the custom or global config should be in use, for custom - * configs */ void WriteSetting(const QString& name, const QVariant& value); void WriteSetting(const QString& name, const QVariant& value, const QVariant& default_value); - void WriteSetting(const QString& name, const QVariant& value, const QVariant& default_value, - bool use_global); /** * Reads a value from the qt_config and applies it to the setting, using its label and default diff --git a/src/citra_qt/configuration/configuration_shared.cpp b/src/citra_qt/configuration/configuration_shared.cpp index e5039256a..681a7d781 100644 --- a/src/citra_qt/configuration/configuration_shared.cpp +++ b/src/citra_qt/configuration/configuration_shared.cpp @@ -33,6 +33,16 @@ void ConfigurationShared::SetPerGameSetting(QCheckBox* checkbox, } } +template <> +void ConfigurationShared::SetPerGameSetting( + QComboBox* combobox, const Settings::SwitchableSetting* setting) { + const int index = + static_cast(combobox->findText(QString::fromStdString(setting->GetValue()))); + combobox->setCurrentIndex(setting->UsingGlobal() + ? ConfigurationShared::USE_GLOBAL_INDEX + : index + ConfigurationShared::USE_GLOBAL_OFFSET); +} + void ConfigurationShared::SetHighlight(QWidget* widget, bool highlighted) { if (highlighted) { widget->setStyleSheet(QStringLiteral("QWidget#%1 { background-color:rgba(0,203,255,0.5) }") diff --git a/src/citra_qt/configuration/configuration_shared.h b/src/citra_qt/configuration/configuration_shared.h index 63a77edd3..74bd17d2d 100644 --- a/src/citra_qt/configuration/configuration_shared.h +++ b/src/citra_qt/configuration/configuration_shared.h @@ -78,6 +78,11 @@ void SetPerGameSetting(QComboBox* combobox, ConfigurationShared::USE_GLOBAL_OFFSET); } +/// Specialization for string settings +template <> +void SetPerGameSetting(QComboBox* combobox, + const Settings::SwitchableSetting* setting); + /// Given a Qt widget sets the background color to indicate whether the setting /// is per-game overriden (highlighted) or global (non-highlighted) void SetHighlight(QWidget* widget, bool highlighted); diff --git a/src/citra_qt/configuration/configure_enhancements.cpp b/src/citra_qt/configuration/configure_enhancements.cpp index 1c2d6b993..88e5a62ea 100644 --- a/src/citra_qt/configuration/configure_enhancements.cpp +++ b/src/citra_qt/configuration/configure_enhancements.cpp @@ -3,9 +3,9 @@ // Refer to the license.txt file included. #include +#include "citra_qt/configuration/configuration_shared.h" #include "citra_qt/configuration/configure_enhancements.h" #include "common/settings.h" -#include "core/core.h" #include "ui_configure_enhancements.h" #include "video_core/renderer_opengl/post_processing_opengl.h" #include "video_core/renderer_opengl/texture_filters/texture_filterer.h" @@ -17,9 +17,10 @@ ConfigureEnhancements::ConfigureEnhancements(QWidget* parent) for (const auto& filter : OpenGL::TextureFilterer::GetFilterNames()) ui->texture_filter_combobox->addItem(QString::fromStdString(filter.data())); + SetupPerGameUI(); SetConfiguration(); - ui->layoutBox->setEnabled(!Settings::values.custom_layout); + ui->layout_group->setEnabled(!Settings::values.custom_layout); ui->resolution_factor_combobox->setEnabled(Settings::values.use_hw_renderer.GetValue()); @@ -49,8 +50,33 @@ ConfigureEnhancements::ConfigureEnhancements(QWidget* parent) }); } +ConfigureEnhancements::~ConfigureEnhancements() = default; + void ConfigureEnhancements::SetConfiguration() { - ui->resolution_factor_combobox->setCurrentIndex(Settings::values.resolution_factor.GetValue()); + + if (!Settings::IsConfiguringGlobal()) { + ConfigurationShared::SetPerGameSetting(ui->resolution_factor_combobox, + &Settings::values.resolution_factor); + ConfigurationShared::SetPerGameSetting(ui->texture_filter_combobox, + &Settings::values.texture_filter_name); + ConfigurationShared::SetHighlight(ui->widget_texture_filter, + !Settings::values.texture_filter_name.UsingGlobal()); + ConfigurationShared::SetPerGameSetting(ui->layout_combobox, + &Settings::values.layout_option); + } else { + ui->resolution_factor_combobox->setCurrentIndex( + Settings::values.resolution_factor.GetValue()); + ui->layout_combobox->setCurrentIndex( + static_cast(Settings::values.layout_option.GetValue())); + int tex_filter_idx = ui->texture_filter_combobox->findText( + QString::fromStdString(Settings::values.texture_filter_name.GetValue())); + if (tex_filter_idx == -1) { + ui->texture_filter_combobox->setCurrentIndex(0); + } else { + ui->texture_filter_combobox->setCurrentIndex(tex_filter_idx); + } + } + ui->render_3d_combobox->setCurrentIndex( static_cast(Settings::values.render_3d.GetValue())); ui->factor_3d->setValue(Settings::values.factor_3d.GetValue()); @@ -58,17 +84,8 @@ void ConfigureEnhancements::SetConfiguration() { static_cast(Settings::values.mono_render_option.GetValue())); updateShaders(Settings::values.render_3d.GetValue()); ui->toggle_linear_filter->setChecked(Settings::values.filter_mode.GetValue()); - int tex_filter_idx = ui->texture_filter_combobox->findText( - QString::fromStdString(Settings::values.texture_filter_name.GetValue())); - if (tex_filter_idx == -1) { - ui->texture_filter_combobox->setCurrentIndex(0); - } else { - ui->texture_filter_combobox->setCurrentIndex(tex_filter_idx); - } - ui->layout_combobox->setCurrentIndex( - static_cast(Settings::values.layout_option.GetValue())); - ui->swap_screen->setChecked(Settings::values.swap_screen.GetValue()); - ui->upright_screen->setChecked(Settings::values.upright_screen.GetValue()); + ui->toggle_swap_screen->setChecked(Settings::values.swap_screen.GetValue()); + ui->toggle_upright_screen->setChecked(Settings::values.upright_screen.GetValue()); ui->large_screen_proportion->setValue(Settings::values.large_screen_proportion.GetValue()); ui->toggle_dump_textures->setChecked(Settings::values.dump_textures.GetValue()); ui->toggle_custom_textures->setChecked(Settings::values.custom_textures.GetValue()); @@ -84,21 +101,31 @@ void ConfigureEnhancements::SetConfiguration() { void ConfigureEnhancements::updateShaders(Settings::StereoRenderOption stereo_option) { ui->shader_combobox->clear(); + ui->shader_combobox->setEnabled(true); - if (stereo_option == Settings::StereoRenderOption::Anaglyph) - ui->shader_combobox->addItem(QStringLiteral("dubois (builtin)")); - else if (stereo_option == Settings::StereoRenderOption::Interlaced || - stereo_option == Settings::StereoRenderOption::ReverseInterlaced) + if (stereo_option == Settings::StereoRenderOption::Interlaced || + stereo_option == Settings::StereoRenderOption::ReverseInterlaced) { ui->shader_combobox->addItem(QStringLiteral("horizontal (builtin)")); - else + ui->shader_combobox->setCurrentIndex(0); + ui->shader_combobox->setEnabled(false); + return; + } + + std::string current_shader; + if (stereo_option == Settings::StereoRenderOption::Anaglyph) { + ui->shader_combobox->addItem(QStringLiteral("dubois (builtin)")); + current_shader = Settings::values.anaglyph_shader_name.GetValue(); + } else { ui->shader_combobox->addItem(QStringLiteral("none (builtin)")); + current_shader = Settings::values.pp_shader_name.GetValue(); + } ui->shader_combobox->setCurrentIndex(0); for (const auto& shader : OpenGL::GetPostProcessingShaderList( stereo_option == Settings::StereoRenderOption::Anaglyph)) { ui->shader_combobox->addItem(QString::fromStdString(shader)); - if (Settings::values.pp_shader_name.GetValue() == shader) + if (current_shader == shader) ui->shader_combobox->setCurrentIndex(ui->shader_combobox->count() - 1); } } @@ -108,28 +135,83 @@ void ConfigureEnhancements::RetranslateUI() { } void ConfigureEnhancements::ApplyConfiguration() { - Settings::values.resolution_factor = - static_cast(ui->resolution_factor_combobox->currentIndex()); + ConfigurationShared::ApplyPerGameSetting(&Settings::values.resolution_factor, + ui->resolution_factor_combobox); Settings::values.render_3d = static_cast(ui->render_3d_combobox->currentIndex()); Settings::values.factor_3d = ui->factor_3d->value(); Settings::values.mono_render_option = static_cast(ui->mono_rendering_eye->currentIndex()); - Settings::values.pp_shader_name = - ui->shader_combobox->itemText(ui->shader_combobox->currentIndex()).toStdString(); - Settings::values.filter_mode = ui->toggle_linear_filter->isChecked(); - Settings::values.texture_filter_name = ui->texture_filter_combobox->currentText().toStdString(); - Settings::values.layout_option = - static_cast(ui->layout_combobox->currentIndex()); - Settings::values.swap_screen = ui->swap_screen->isChecked(); - Settings::values.upright_screen = ui->upright_screen->isChecked(); + if (Settings::values.render_3d.GetValue() == Settings::StereoRenderOption::Anaglyph) { + Settings::values.anaglyph_shader_name = + ui->shader_combobox->itemText(ui->shader_combobox->currentIndex()).toStdString(); + } else if (Settings::values.render_3d.GetValue() == Settings::StereoRenderOption::Off) { + Settings::values.pp_shader_name = + ui->shader_combobox->itemText(ui->shader_combobox->currentIndex()).toStdString(); + } Settings::values.large_screen_proportion = ui->large_screen_proportion->value(); - Settings::values.dump_textures = ui->toggle_dump_textures->isChecked(); - Settings::values.custom_textures = ui->toggle_custom_textures->isChecked(); - Settings::values.preload_textures = ui->toggle_preload_textures->isChecked(); + + ConfigurationShared::ApplyPerGameSetting(&Settings::values.filter_mode, + ui->toggle_linear_filter, linear_filter); + ConfigurationShared::ApplyPerGameSetting( + &Settings::values.texture_filter_name, ui->texture_filter_combobox, + [this](int index) { return ui->texture_filter_combobox->itemText(index).toStdString(); }); + ConfigurationShared::ApplyPerGameSetting(&Settings::values.layout_option, ui->layout_combobox); + ConfigurationShared::ApplyPerGameSetting(&Settings::values.swap_screen, ui->toggle_swap_screen, + swap_screen); + ConfigurationShared::ApplyPerGameSetting(&Settings::values.upright_screen, + ui->toggle_upright_screen, upright_screen); + ConfigurationShared::ApplyPerGameSetting(&Settings::values.dump_textures, + ui->toggle_dump_textures, dump_textures); + ConfigurationShared::ApplyPerGameSetting(&Settings::values.custom_textures, + ui->toggle_custom_textures, custom_textures); + ConfigurationShared::ApplyPerGameSetting(&Settings::values.preload_textures, + ui->toggle_preload_textures, preload_textures); + Settings::values.bg_red = static_cast(bg_color.redF()); Settings::values.bg_green = static_cast(bg_color.greenF()); Settings::values.bg_blue = static_cast(bg_color.blueF()); } -ConfigureEnhancements::~ConfigureEnhancements() {} +void ConfigureEnhancements::SetupPerGameUI() { + // Block the global settings if a game is currently running that overrides them + if (Settings::IsConfiguringGlobal()) { + ui->widget_resolution->setEnabled(Settings::values.resolution_factor.UsingGlobal()); + ui->widget_texture_filter->setEnabled(Settings::values.texture_filter_name.UsingGlobal()); + ui->toggle_linear_filter->setEnabled(Settings::values.filter_mode.UsingGlobal()); + ui->toggle_swap_screen->setEnabled(Settings::values.swap_screen.UsingGlobal()); + ui->toggle_upright_screen->setEnabled(Settings::values.upright_screen.UsingGlobal()); + ui->toggle_dump_textures->setEnabled(Settings::values.dump_textures.UsingGlobal()); + ui->toggle_custom_textures->setEnabled(Settings::values.custom_textures.UsingGlobal()); + ui->toggle_preload_textures->setEnabled(Settings::values.preload_textures.UsingGlobal()); + return; + } + + ui->stereo_group->setVisible(false); + ui->widget_shader->setVisible(false); + ui->bg_color_group->setVisible(false); + + ConfigurationShared::SetColoredTristate(ui->toggle_linear_filter, Settings::values.filter_mode, + linear_filter); + ConfigurationShared::SetColoredTristate(ui->toggle_swap_screen, Settings::values.swap_screen, + swap_screen); + ConfigurationShared::SetColoredTristate(ui->toggle_upright_screen, + Settings::values.upright_screen, upright_screen); + ConfigurationShared::SetColoredTristate(ui->toggle_dump_textures, + Settings::values.dump_textures, dump_textures); + ConfigurationShared::SetColoredTristate(ui->toggle_custom_textures, + Settings::values.custom_textures, custom_textures); + ConfigurationShared::SetColoredTristate(ui->toggle_preload_textures, + Settings::values.preload_textures, preload_textures); + + ConfigurationShared::SetColoredComboBox( + ui->resolution_factor_combobox, ui->widget_resolution, + static_cast(Settings::values.resolution_factor.GetValue(true))); + + ConfigurationShared::SetColoredComboBox(ui->texture_filter_combobox, ui->widget_texture_filter, + 0); + + ConfigurationShared::SetColoredComboBox( + ui->layout_combobox, ui->widget_layout, + static_cast(Settings::values.layout_option.GetValue(true))); +} diff --git a/src/citra_qt/configuration/configure_enhancements.h b/src/citra_qt/configuration/configure_enhancements.h index 59f9ba1ad..2bd5d644a 100644 --- a/src/citra_qt/configuration/configure_enhancements.h +++ b/src/citra_qt/configuration/configure_enhancements.h @@ -12,6 +12,10 @@ namespace Settings { enum class StereoRenderOption : u32; } +namespace ConfigurationShared { +enum class CheckState; +} + namespace Ui { class ConfigureEnhancements; } @@ -27,10 +31,18 @@ public: void RetranslateUI(); void SetConfiguration(); + void SetupPerGameUI(); + private: void updateShaders(Settings::StereoRenderOption stereo_option); void updateTextureFilter(int index); std::unique_ptr ui; + ConfigurationShared::CheckState linear_filter; + ConfigurationShared::CheckState swap_screen; + ConfigurationShared::CheckState upright_screen; + ConfigurationShared::CheckState dump_textures; + ConfigurationShared::CheckState custom_textures; + ConfigurationShared::CheckState preload_textures; QColor bg_color; }; diff --git a/src/citra_qt/configuration/configure_enhancements.ui b/src/citra_qt/configuration/configure_enhancements.ui index d8b31af86..be9aca92e 100644 --- a/src/citra_qt/configuration/configure_enhancements.ui +++ b/src/citra_qt/configuration/configure_enhancements.ui @@ -7,7 +7,7 @@ 0 0 400 - 634 + 657 @@ -27,74 +27,88 @@ - - - - - Internal Resolution - - - - - - + + + + 0 + + + 0 + + + 0 + + + 0 + + + - Auto (Window Size) + Internal Resolution - - - - Native (400x240) - - - - - 2x Native (800x480) - - - - - 3x Native (1200x720) - - - - - 4x Native (1600x960) - - - - - 5x Native (2000x1200) - - - - - 6x Native (2400x1440) - - - - - 7x Native (2800x1680) - - - - - 8x Native (3200x1920) - - - - - 9x Native (3600x2160) - - - - - 10x Native (4000x2400) - - - - - + + + + + + + Auto (Window Size) + + + + + Native (400x240) + + + + + 2x Native (800x480) + + + + + 3x Native (1200x720) + + + + + 4x Native (1600x960) + + + + + 5x Native (2000x1200) + + + + + 6x Native (2400x1440) + + + + + 7x Native (2800x1680) + + + + + 8x Native (3200x1920) + + + + + 9x Native (3600x2160) + + + + + 10x Native (4000x2400) + + + + + + @@ -104,38 +118,66 @@ - - - - - Post-Processing Shader - - - - - - - + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Post-Processing Shader + + + + + + + + - - - - - Texture Filter - - - - - - - + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Texture Filter + + + + + + + + - + Stereoscopy @@ -236,109 +278,151 @@ - + Layout - - - - - Screen Layout: - - - - - - + + + + 0 + + + 0 + + + 0 + + + 0 + + + - Default + Screen Layout: - - - - Single Screen - - - - - Large Screen - - - - - Side by Side - - - - - Separate Windows - - - - - + + + + + + + Default + + + + + Single Screen + + + + + Large Screen + + + + + Side by Side + + + + + Separate Windows + + + + + + - + Swap Screens - + Rotate Screens Upright - - - - - Large Screen Proportion: - - - - - - - 1 - - - 16 - - - 4 - - - + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Large Screen Proportion: + + + + + + + 1.000000000000000 + + + 16.000000000000000 + + + 4.000000000000000 + + + + - - - - - Background Color: - - - - - - - - 40 - 16777215 - - - - - + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Background Color: + + + + + + + + 40 + 16777215 + + + + + + @@ -406,8 +490,8 @@ factor_3d mono_rendering_eye layout_combobox - swap_screen - upright_screen + toggle_swap_screen + toggle_upright_screen large_screen_proportion bg_button toggle_custom_textures diff --git a/src/citra_qt/configuration/configure_per_game.cpp b/src/citra_qt/configuration/configure_per_game.cpp index 506b597ec..e41fa462a 100644 --- a/src/citra_qt/configuration/configure_per_game.cpp +++ b/src/citra_qt/configuration/configure_per_game.cpp @@ -10,6 +10,7 @@ #include "citra_qt/configuration/config.h" #include "citra_qt/configuration/configure_audio.h" #include "citra_qt/configuration/configure_debug.h" +#include "citra_qt/configuration/configure_enhancements.h" #include "citra_qt/configuration/configure_general.h" #include "citra_qt/configuration/configure_graphics.h" #include "citra_qt/configuration/configure_per_game.h" @@ -30,6 +31,7 @@ ConfigurePerGame::ConfigurePerGame(QWidget* parent, u64 title_id_, const QString audio_tab = std::make_unique(this); general_tab = std::make_unique(this); + enhancements_tab = std::make_unique(this); graphics_tab = std::make_unique(this); system_tab = std::make_unique(this); debug_tab = std::make_unique(this); @@ -38,6 +40,7 @@ ConfigurePerGame::ConfigurePerGame(QWidget* parent, u64 title_id_, const QString ui->tabWidget->addTab(general_tab.get(), tr("General")); ui->tabWidget->addTab(system_tab.get(), tr("System")); + ui->tabWidget->addTab(enhancements_tab.get(), tr("Enhancements")); ui->tabWidget->addTab(graphics_tab.get(), tr("Graphics")); ui->tabWidget->addTab(audio_tab.get(), tr("Audio")); ui->tabWidget->addTab(debug_tab.get(), tr("Debug")); @@ -81,10 +84,12 @@ void ConfigurePerGame::ResetDefaults() { void ConfigurePerGame::ApplyConfiguration() { general_tab->ApplyConfiguration(); system_tab->ApplyConfiguration(); + enhancements_tab->ApplyConfiguration(); graphics_tab->ApplyConfiguration(); audio_tab->ApplyConfiguration(); debug_tab->ApplyConfiguration(); + Settings::Apply(); Settings::LogSettings(); game_config->Save(); diff --git a/src/citra_qt/configuration/configure_per_game.h b/src/citra_qt/configuration/configure_per_game.h index 87addd6e7..d84b42c28 100644 --- a/src/citra_qt/configuration/configure_per_game.h +++ b/src/citra_qt/configuration/configure_per_game.h @@ -15,6 +15,7 @@ class System; class ConfigureAudio; class ConfigureGeneral; +class ConfigureEnhancements; class ConfigureGraphics; class ConfigureSystem; class ConfigureDebug; @@ -65,6 +66,7 @@ private: std::unique_ptr audio_tab; std::unique_ptr general_tab; + std::unique_ptr enhancements_tab; std::unique_ptr graphics_tab; std::unique_ptr system_tab; std::unique_ptr debug_tab; diff --git a/src/citra_qt/configuration/configure_system.cpp b/src/citra_qt/configuration/configure_system.cpp index 73afb482a..159106701 100644 --- a/src/citra_qt/configuration/configure_system.cpp +++ b/src/citra_qt/configuration/configure_system.cpp @@ -263,6 +263,8 @@ ConfigureSystem::ConfigureSystem(QWidget* parent) } else { ui->button_start_download->setEnabled(false); ui->combo_download_mode->setEnabled(false); + ui->label_nus_download->setTextInteractionFlags(Qt::TextBrowserInteraction); + ui->label_nus_download->setOpenExternalLinks(true); ui->label_nus_download->setText( tr("Citra is missing keys to download system files.
#endif +#ifdef __unix__ +#include +#include +#include +#endif #ifdef USE_DISCORD_PRESENCE #include "citra_qt/discord_impl.h" @@ -958,16 +963,72 @@ void GMainWindow::OnOpenUpdater() { updater->LaunchUI(); } +#if defined(__unix__) && !defined(__APPLE__) +static std::optional HoldWakeLockLinux(u32 window_id = 0) { + if (!QDBusConnection::sessionBus().isConnected()) { + return {}; + } + // reference: https://flatpak.github.io/xdg-desktop-portal/#gdbus-org.freedesktop.portal.Inhibit + QDBusInterface xdp(QStringLiteral("org.freedesktop.portal.Desktop"), + QStringLiteral("/org/freedesktop/portal/desktop"), + QStringLiteral("org.freedesktop.portal.Inhibit")); + if (!xdp.isValid()) { + LOG_WARNING(Frontend, "Couldn't connect to XDP D-Bus endpoint"); + return {}; + } + QVariantMap options = {}; + //: TRANSLATORS: This string is shown to the user to explain why Citra needs to prevent the + //: computer from sleeping + options.insert(QString::fromLatin1("reason"), + QCoreApplication::translate("GMainWindow", "Citra is running a game")); + // 0x4: Suspend lock; 0x8: Idle lock + QDBusReply reply = + xdp.call(QString::fromLatin1("Inhibit"), + QString::fromLatin1("x11:") + QString::number(window_id, 16), 12U, options); + + if (reply.isValid()) { + return reply.value(); + } + LOG_WARNING(Frontend, "Couldn't read Inhibit reply from XDP: {}", + reply.error().message().toStdString()); + return {}; +} + +static void ReleaseWakeLockLinux(const QDBusObjectPath& lock) { + if (!QDBusConnection::sessionBus().isConnected()) { + return; + } + QDBusInterface unlocker(QString::fromLatin1("org.freedesktop.portal.Desktop"), lock.path(), + QString::fromLatin1("org.freedesktop.portal.Request")); + unlocker.call(QString::fromLatin1("Close")); +} +#endif // __unix__ + void GMainWindow::PreventOSSleep() { #ifdef _WIN32 SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED); -#endif +#elif defined(HAVE_SDL2) + SDL_DisableScreenSaver(); +#ifdef __unix__ + auto reply = HoldWakeLockLinux(winId()); + if (reply) { + wake_lock = std::move(reply.value()); + } +#endif // __unix__ +#endif // _WIN32 } void GMainWindow::AllowOSSleep() { #ifdef _WIN32 SetThreadExecutionState(ES_CONTINUOUS); -#endif +#elif defined(HAVE_SDL2) + SDL_EnableScreenSaver(); +#ifdef __unix__ + if (!wake_lock.path().isEmpty()) { + ReleaseWakeLockLinux(wake_lock); + } +#endif // __unix__ +#endif // _WIN32 } bool GMainWindow::LoadROM(const QString& filename) { diff --git a/src/citra_qt/main.h b/src/citra_qt/main.h index 26798fa59..3bee04c75 100644 --- a/src/citra_qt/main.h +++ b/src/citra_qt/main.h @@ -17,6 +17,10 @@ #include "core/hle/service/am/am.h" #include "core/savestate.h" +#ifdef __unix__ +#include +#endif + class AboutDialog; class Config; class ClickableLabel; @@ -332,6 +336,10 @@ private: HotkeyRegistry hotkey_registry; +#ifdef __unix__ + QDBusObjectPath wake_lock{}; +#endif + protected: void dropEvent(QDropEvent* event) override; void dragEnterEvent(QDragEnterEvent* event) override; diff --git a/src/common/CMakeLists.txt b/src/common/CMakeLists.txt index a8695cc3f..f01e83f2e 100644 --- a/src/common/CMakeLists.txt +++ b/src/common/CMakeLists.txt @@ -57,7 +57,10 @@ 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 assert.h async_handle.h @@ -147,7 +150,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 spng::spng) 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_funcs.h b/src/common/common_funcs.h index f8d3438ab..ca3701061 100644 --- a/src/common/common_funcs.h +++ b/src/common/common_funcs.h @@ -35,16 +35,16 @@ #define CITRA_NO_INLINE __attribute__((noinline)) #endif -#ifndef _MSC_VER - -#if CITRA_ARCH(x86_64) -#define Crash() __asm__ __volatile__("int $3") +#ifdef _MSC_VER +extern "C" { +__declspec(dllimport) void __stdcall DebugBreak(void); +} +#define Crash() DebugBreak() #else -#define Crash() exit(1) +#define Crash() __builtin_trap() #endif -#else // _MSC_VER - +#ifdef _MSC_VER #if (_MSC_VER < 1900) // Function Cross-Compatibility #define snprintf _snprintf @@ -53,12 +53,7 @@ // Locale Cross-Compatibility #define locale_t _locale_t -extern "C" { -__declspec(dllimport) void __stdcall DebugBreak(void); -} -#define Crash() DebugBreak() - -#endif // _MSC_VER ndef +#endif // _MSC_VER #define DECLARE_ENUM_FLAG_OPERATORS(type) \ [[nodiscard]] constexpr type operator|(type a, type b) noexcept { \ 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 d9207b3cc..993ab3578 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(); } @@ -362,6 +371,7 @@ private: bool Open(); std::FILE* m_file = nullptr; + int m_fd = -1; bool m_good = true; std::string filename; @@ -386,6 +396,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/settings.cpp b/src/common/settings.cpp index 701bf4cfe..803add3dc 100644 --- a/src/common/settings.cpp +++ b/src/common/settings.cpp @@ -19,7 +19,7 @@ namespace Settings { -[[nodiscard]] std::string_view GetAPIName(GraphicsAPI api) { +std::string_view GetAPIName(GraphicsAPI api) { switch (api) { case GraphicsAPI::OpenGL: return "OpenGL"; @@ -30,6 +30,17 @@ namespace Settings { } } +std::string_view GetAudioEmulationName(AudioEmulation emulation) { + switch (emulation) { + case AudioEmulation::HLE: + return "HLE"; + case AudioEmulation::LLE: + return "LLE"; + case AudioEmulation::LLEMultithreaded: + return "LLE Multithreaded"; + } +}; + Values values = {}; static bool configuring_global = true; @@ -97,17 +108,6 @@ void LogSettings() { LOG_INFO(Config, "{}: {}", name, value); }; - const auto to_string = [](AudioEmulation emulation) -> std::string_view { - switch (emulation) { - case AudioEmulation::HLE: - return "HLE"; - case AudioEmulation::LLE: - return "LLE"; - case AudioEmulation::LLEMultithreaded: - return "LLE Multithreaded"; - } - }; - LOG_INFO(Config, "Citra Configuration:"); log_setting("Core_UseCpuJit", values.use_cpu_jit.GetValue()); log_setting("Core_CPUClockPercentage", values.cpu_clock_percentage.GetValue()); @@ -129,6 +129,9 @@ void LogSettings() { log_setting("Stereoscopy_Render3d", values.render_3d.GetValue()); log_setting("Stereoscopy_Factor3d", values.factor_3d.GetValue()); log_setting("Stereoscopy_MonoRenderOption", values.mono_render_option.GetValue()); + if (values.render_3d.GetValue() == StereoRenderOption::Anaglyph) { + log_setting("Renderer_AnaglyphShader", values.anaglyph_shader_name.GetValue()); + } log_setting("Layout_LayoutOption", values.layout_option.GetValue()); log_setting("Layout_SwapScreen", values.swap_screen.GetValue()); log_setting("Layout_UprightScreen", values.upright_screen.GetValue()); @@ -136,7 +139,7 @@ void LogSettings() { log_setting("Utility_DumpTextures", values.dump_textures.GetValue()); log_setting("Utility_CustomTextures", values.custom_textures.GetValue()); log_setting("Utility_UseDiskShaderCache", values.use_disk_shader_cache.GetValue()); - log_setting("Audio_Emulation", to_string(values.audio_emulation.GetValue())); + log_setting("Audio_Emulation", GetAudioEmulationName(values.audio_emulation.GetValue())); log_setting("Audio_OutputEngine", values.sink_id.GetValue()); log_setting("Audio_EnableAudioStretching", values.enable_audio_stretching.GetValue()); log_setting("Audio_OutputDevice", values.audio_device_id.GetValue()); @@ -220,6 +223,10 @@ void RestoreGlobalState(bool is_powered_on) { values.factor_3d.SetGlobal(true); values.filter_mode.SetGlobal(true); values.pp_shader_name.SetGlobal(true); + values.anaglyph_shader_name.SetGlobal(true); + values.dump_textures.SetGlobal(true); + values.custom_textures.SetGlobal(true); + values.preload_textures.SetGlobal(true); } void LoadProfile(int index) { diff --git a/src/common/settings.h b/src/common/settings.h index 45609cb6b..59a3ea24f 100644 --- a/src/common/settings.h +++ b/src/common/settings.h @@ -60,9 +60,16 @@ enum class StereoRenderOption : u32 { // Which eye to render when 3d is off. 800px wide mode could be added here in the future, when // implemented -enum class MonoRenderOption : u32 { LeftEye = 0, RightEye = 1 }; +enum class MonoRenderOption : u32 { + LeftEye = 0, + RightEye = 1, +}; -enum class AudioEmulation : u32 { HLE = 0, LLE = 1, LLEMultithreaded = 2 }; +enum class AudioEmulation : u32 { + HLE = 0, + LLE = 1, + LLEMultithreaded = 2, +}; namespace NativeButton { @@ -367,38 +374,6 @@ protected: Type custom{}; ///< The custom value of the setting }; -/** - * The InputSetting class allows for getting a reference to either the global or custom members. - * This is required as we cannot easily modify the values of user-defined types within containers - * using the SetValue() member function found in the Setting class. The primary purpose of this - * class is to store an array of 10 PlayerInput structs for both the global and custom setting and - * allows for easily accessing and modifying both settings. - */ -template -class InputSetting final { -public: - InputSetting() = default; - explicit InputSetting(Type val) : Setting(val) {} - ~InputSetting() = default; - void SetGlobal(bool to_global) { - use_global = to_global; - } - [[nodiscard]] bool UsingGlobal() const { - return use_global; - } - [[nodiscard]] Type& GetValue(bool need_global = false) { - if (use_global || need_global) { - return global; - } - return custom; - } - -private: - bool use_global{true}; ///< The setting's global state - Type global{}; ///< The setting - Type custom{}; ///< The custom setting value -}; - struct InputProfile { std::string name; std::array buttons; @@ -494,10 +469,11 @@ struct Values { SwitchableSetting filter_mode{true, "filter_mode"}; SwitchableSetting pp_shader_name{"none (builtin)", "pp_shader_name"}; + SwitchableSetting anaglyph_shader_name{"dubois (builtin)", "anaglyph_shader_name"}; - Setting dump_textures{false, "dump_textures"}; - Setting custom_textures{false, "custom_textures"}; - Setting preload_textures{false, "preload_textures"}; + SwitchableSetting dump_textures{false, "dump_textures"}; + SwitchableSetting custom_textures{false, "custom_textures"}; + SwitchableSetting preload_textures{false, "preload_textures"}; // Audio bool audio_muted; 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 4d32da95c..19a508190 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -473,7 +473,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/hle/kernel/svc.cpp b/src/core/hle/kernel/svc.cpp index 666fdecd1..c3d64874d 100644 --- a/src/core/hle/kernel/svc.cpp +++ b/src/core/hle/kernel/svc.cpp @@ -1245,7 +1245,7 @@ ResultCode SVC::CreateThread(Handle* out_handle, u32 entry_point, u32 arg, VAddr // processorid. If this is implemented, make sure to check process->no_thread_restrictions. break; default: - ASSERT_MSG(false, "Unsupported thread processor ID: {}", processor_id); + return ERR_OUT_OF_RANGE; break; } diff --git a/src/core/hle/kernel/thread.cpp b/src/core/hle/kernel/thread.cpp index 67bcdd4be..0e4c6e370 100644 --- a/src/core/hle/kernel/thread.cpp +++ b/src/core/hle/kernel/thread.cpp @@ -108,8 +108,9 @@ void Thread::Stop() { u32 tls_page = (tls_address - Memory::TLS_AREA_VADDR) / Memory::CITRA_PAGE_SIZE; u32 tls_slot = ((tls_address - Memory::TLS_AREA_VADDR) % Memory::CITRA_PAGE_SIZE) / Memory::TLS_ENTRY_SIZE; - ASSERT(owner_process.lock()); - owner_process.lock()->tls_slots[tls_page].reset(tls_slot); + if (auto process = owner_process.lock()) { + process->tls_slots[tls_page].reset(tls_slot); + } } void ThreadManager::SwitchContext(Thread* new_thread) { diff --git a/src/core/hle/service/apt/applet_manager.cpp b/src/core/hle/service/apt/applet_manager.cpp index b0755bfdb..8e21be2f2 100644 --- a/src/core/hle/service/apt/applet_manager.cpp +++ b/src/core/hle/service/apt/applet_manager.cpp @@ -882,7 +882,12 @@ ResultCode AppletManager::PrepareToCloseApplication(bool return_to_sys) { } if (application_close_target == AppletSlot::HomeMenu) { - EnsureHomeMenuLoaded(); + // Real APT would make sure home menu is loaded here. However, this is only really + // needed if the home menu wasn't loaded in the first place. Since we want to + // preserve normal behavior when the user loaded the game directly without going + // through home menu, we skip this. Then, later we just close to the game list + // when the application finishes closing. + // EnsureHomeMenuLoaded(); } return RESULT_SUCCESS; @@ -896,15 +901,21 @@ ResultCode AppletManager::CloseApplication(std::shared_ptr objec GetAppletSlot(AppletSlot::Application)->Reset(); if (application_close_target != AppletSlot::Error) { - active_slot = application_close_target; + // If exiting to the home menu and it is not loaded, exit to game list. + if (application_close_target == AppletSlot::HomeMenu && + !GetAppletSlot(application_close_target)->registered) { + system.RequestShutdown(); + } else { + active_slot = application_close_target; - CancelAndSendParameter({ - .sender_id = AppletId::Application, - .destination_id = GetAppletSlot(application_close_target)->applet_id, - .signal = SignalType::WakeupByExit, - .object = std::move(object), - .buffer = buffer, - }); + CancelAndSendParameter({ + .sender_id = AppletId::Application, + .destination_id = GetAppletSlot(application_close_target)->applet_id, + .signal = SignalType::WakeupByExit, + .object = std::move(object), + .buffer = buffer, + }); + } } // TODO: Terminate the application process. 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 ""; } diff --git a/src/video_core/renderer_opengl/renderer_opengl.cpp b/src/video_core/renderer_opengl/renderer_opengl.cpp index 69ed22a99..5851c1f5e 100644 --- a/src/video_core/renderer_opengl/renderer_opengl.cpp +++ b/src/video_core/renderer_opengl/renderer_opengl.cpp @@ -593,30 +593,37 @@ void RendererOpenGL::ReloadShader() { shader_data += fragment_shader_precision_OES; } - const auto LoadShader = [&shader_data](std::string_view name, std::string_view shader) { - if (Settings::values.pp_shader_name.GetValue() == name) { - shader_data += shader; + const Settings::StereoRenderOption render_3d = Settings::values.render_3d.GetValue(); + if (render_3d == Settings::StereoRenderOption::Anaglyph) { + if (Settings::values.anaglyph_shader_name.GetValue() == "dubois (builtin)") { + shader_data += HostShaders::OPENGL_PRESENT_ANAGLYPH_FRAG; } else { std::string shader_text = OpenGL::GetPostProcessingShaderCode( - true, Settings::values.pp_shader_name.GetValue()); + true, Settings::values.anaglyph_shader_name.GetValue()); if (shader_text.empty()) { - shader_data += shader; + // Should probably provide some information that the shader couldn't load + shader_data += HostShaders::OPENGL_PRESENT_ANAGLYPH_FRAG; } else { shader_data += shader_text; } } - }; - - const Settings::StereoRenderOption render_3d = Settings::values.render_3d.GetValue(); - if (render_3d == Settings::StereoRenderOption::Anaglyph) { - LoadShader("dubois (builtin)", HostShaders::OPENGL_PRESENT_ANAGLYPH_FRAG); } else if (render_3d == Settings::StereoRenderOption::Interlaced || render_3d == Settings::StereoRenderOption::ReverseInterlaced) { - LoadShader("horizontal (builtin)", HostShaders::OPENGL_PRESENT_INTERLACED_FRAG); + shader_data += HostShaders::OPENGL_PRESENT_INTERLACED_FRAG; } else { - LoadShader("none (builtin)", HostShaders::OPENGL_PRESENT_FRAG); + if (Settings::values.pp_shader_name.GetValue() == "none (builtin)") { + shader_data += HostShaders::OPENGL_PRESENT_INTERLACED_FRAG; + } else { + std::string shader_text = OpenGL::GetPostProcessingShaderCode( + false, Settings::values.pp_shader_name.GetValue()); + if (shader_text.empty()) { + // Should probably provide some information that the shader couldn't load + shader_data += HostShaders::OPENGL_PRESENT_INTERLACED_FRAG; + } else { + shader_data += shader_text; + } + } } - shader.Create(HostShaders::OPENGL_PRESENT_VERT, shader_data.c_str()); state.draw.shader_program = shader.handle; state.Apply();