diff --git a/CMakeLists.txt b/CMakeLists.txt
index 6e575323b..3f5f7d4b1 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -386,10 +386,11 @@ endforeach()
# Boost
if (USE_SYSTEM_BOOST)
- find_package(Boost 1.70.0 COMPONENTS serialization REQUIRED)
+ find_package(Boost 1.70.0 COMPONENTS serialization iostreams REQUIRED)
else()
add_library(Boost::boost ALIAS boost)
add_library(Boost::serialization ALIAS boost_serialization)
+ add_library(Boost::iostreams ALIAS boost_iostreams)
endif()
# SDL2
diff --git a/externals/CMakeLists.txt b/externals/CMakeLists.txt
index 48bd2ac34..35f3fe364 100644
--- a/externals/CMakeLists.txt
+++ b/externals/CMakeLists.txt
@@ -21,6 +21,15 @@ file(GLOB boost_serialization_SRC "${CMAKE_SOURCE_DIR}/externals/boost/libs/seri
add_library(boost_serialization STATIC ${boost_serialization_SRC})
target_link_libraries(boost_serialization PUBLIC boost)
+# Boost::iostreams
+add_library(
+ boost_iostreams
+ STATIC
+ ${CMAKE_SOURCE_DIR}/externals/boost/libs/iostreams/src/file_descriptor.cpp
+ ${CMAKE_SOURCE_DIR}/externals/boost/libs/iostreams/src/mapped_file.cpp
+)
+target_link_libraries(boost_iostreams PUBLIC boost)
+
# Add additional boost libs here; remember to ALIAS them in the root CMakeLists!
# Catch2
diff --git a/externals/boost b/externals/boost
index 66937ea62..80a171a17 160000
--- a/externals/boost
+++ b/externals/boost
@@ -1 +1 @@
-Subproject commit 66937ea62d126a92b5057e3fd9ceac7c44daf4f5
+Subproject commit 80a171a179c1f901e4f8dfc8962417f44865ceec
diff --git a/src/android/app/build.gradle b/src/android/app/build.gradle
index 7f4c64e55..feb0dfa44 100644
--- a/src/android/app/build.gradle
+++ b/src/android/app/build.gradle
@@ -32,7 +32,7 @@ android {
// TODO If this is ever modified, change application_id in strings.xml
applicationId "org.citra.citra_emu"
minSdkVersion 28
- targetSdkVersion 29
+ targetSdkVersion 31
versionCode autoVersion
versionName getVersion()
ndk.abiFilters abiFilter
@@ -117,22 +117,25 @@ android {
}
dependencies {
+ implementation "androidx.activity:activity:1.5.1"
+ implementation "androidx.fragment:fragment:1.5.5"
implementation 'androidx.appcompat:appcompat:1.5.1'
implementation 'androidx.exifinterface:exifinterface:1.3.4'
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.recyclerview:recyclerview:1.2.1'
+ implementation "androidx.documentfile:documentfile:1.0.1"
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.lifecycle:lifecycle-viewmodel:2.5.1'
implementation 'androidx.fragment:fragment:1.5.3'
implementation "androidx.slidingpanelayout:slidingpanelayout:1.2.0"
implementation 'com.google.android.material:material:1.6.1'
+ implementation 'androidx.core:core-splashscreen:1.0.0'
// For loading huge screenshots from the disk.
implementation 'com.squareup.picasso:picasso:2.71828'
// Allows FRP-style asynchronous operations in Android.
implementation 'io.reactivex:rxandroid:1.2.1'
- implementation 'com.nononsenseapps:filepicker:4.2.1'
implementation 'org.ini4j:ini4j:0.5.4'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0'
@@ -140,6 +143,10 @@ dependencies {
// Please don't upgrade the billing library as the newer version is not GPL-compatible
implementation 'com.android.billingclient:billing:2.0.3'
+
+ // To use the androidx.test.core APIs
+ androidTestImplementation "androidx.test:core:1.5.0"
+ androidTestImplementation "androidx.test.ext:junit:1.1.5"
}
def getVersion() {
diff --git a/src/android/app/src/androidTest/java/org/citra/citra_emu/ExampleInstrumentedTest.java b/src/android/app/src/androidTest/java/org/citra/citra_emu/ExampleInstrumentedTest.java
index 671fb4b30..b7c32a2f1 100644
--- a/src/android/app/src/androidTest/java/org/citra/citra_emu/ExampleInstrumentedTest.java
+++ b/src/android/app/src/androidTest/java/org/citra/citra_emu/ExampleInstrumentedTest.java
@@ -1,8 +1,8 @@
package org.citra.citra_emu;
import android.content.Context;
-import android.support.test.InstrumentationRegistry;
-import android.support.test.runner.AndroidJUnit4;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
@@ -19,7 +19,7 @@ public class ExampleInstrumentedTest {
@Test
public void useAppContext() {
// Context of the app under test.
- Context appContext = InstrumentationRegistry.getTargetContext();
+ Context appContext = ApplicationProvider.getApplicationContext();
assertEquals("org.citra.citra_emu", appContext.getPackageName());
}
diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml
index 7d35fe910..ec83e022e 100644
--- a/src/android/app/src/main/AndroidManifest.xml
+++ b/src/android/app/src/main/AndroidManifest.xml
@@ -26,7 +26,6 @@
-
@@ -44,7 +43,8 @@
@@ -69,23 +69,12 @@
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.java b/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.java
index 41ac7e27c..638e1ebaa 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.java
@@ -12,10 +12,12 @@ import android.os.Build;
import org.citra.citra_emu.model.GameDatabase;
import org.citra.citra_emu.utils.DirectoryInitialization;
+import org.citra.citra_emu.utils.DocumentsTree;
import org.citra.citra_emu.utils.PermissionsHandler;
public class CitraApplication extends Application {
public static GameDatabase databaseHelper;
+ public static DocumentsTree documentsTree;
private static CitraApplication application;
private void createNotificationChannel() {
@@ -39,6 +41,7 @@ public class CitraApplication extends Application {
public void onCreate() {
super.onCreate();
application = this;
+ documentsTree = new DocumentsTree();
if (PermissionsHandler.hasWriteAccess(getApplicationContext())) {
DirectoryInitialization.start(getApplicationContext());
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.java b/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.java
index 5899d62a6..0b683d575 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.java
@@ -28,6 +28,7 @@ import androidx.fragment.app.DialogFragment;
import org.citra.citra_emu.activities.EmulationActivity;
import org.citra.citra_emu.applets.SoftwareKeyboard;
import org.citra.citra_emu.utils.EmulationMenuSettings;
+import org.citra.citra_emu.utils.FileUtil;
import org.citra.citra_emu.utils.Log;
import org.citra.citra_emu.utils.PermissionsHandler;
@@ -164,6 +165,10 @@ public final class NativeLibrary {
// Create the config.ini file.
public static native void CreateConfigFile();
+ public static native void CreateLogFile();
+
+ public static native void LogUserDirectory(String directory);
+
public static native int DefaultCPUCore();
/**
@@ -262,11 +267,11 @@ public final class NativeLibrary {
coreErrorAlertLock.notify();
}
}).setOnDismissListener(dialog -> {
- coreErrorAlertResult = true;
- synchronized (coreErrorAlertLock) {
- coreErrorAlertLock.notify();
- }
- }).create();
+ coreErrorAlertResult = true;
+ synchronized (coreErrorAlertLock) {
+ coreErrorAlertLock.notify();
+ }
+ }).create();
}
}
@@ -665,4 +670,73 @@ public final class NativeLibrary {
public static final int RELEASED = 0;
public static final int PRESSED = 1;
}
+ public static boolean createFile(String directory, String filename) {
+ if (FileUtil.isNativePath(directory)) {
+ return CitraApplication.documentsTree.createFile(directory, filename);
+ }
+ return FileUtil.createFile(CitraApplication.getAppContext(), directory, filename) != null;
+ }
+
+ public static boolean createDir(String directory, String directoryName) {
+ if (FileUtil.isNativePath(directory)) {
+ return CitraApplication.documentsTree.createDir(directory, directoryName);
+ }
+ return FileUtil.createDir(CitraApplication.getAppContext(), directory, directoryName) != null;
+ }
+
+ public static int openContentUri(String path, String openMode) {
+ if (FileUtil.isNativePath(path)) {
+ return CitraApplication.documentsTree.openContentUri(path, openMode);
+ }
+ return FileUtil.openContentUri(CitraApplication.getAppContext(), path, openMode);
+ }
+
+ public static String[] getFilesName(String path) {
+ if (FileUtil.isNativePath(path)) {
+ return CitraApplication.documentsTree.getFilesName(path);
+ }
+ return FileUtil.getFilesName(CitraApplication.getAppContext(), path);
+ }
+
+ public static long getSize(String path) {
+ if (FileUtil.isNativePath(path)) {
+ return CitraApplication.documentsTree.getFileSize(path);
+ }
+ return FileUtil.getFileSize(CitraApplication.getAppContext(), path);
+ }
+
+ public static boolean fileExists(String path) {
+ if (FileUtil.isNativePath(path)) {
+ return CitraApplication.documentsTree.Exists(path);
+ }
+ return FileUtil.Exists(CitraApplication.getAppContext(), path);
+ }
+
+ public static boolean isDirectory(String path) {
+ if (FileUtil.isNativePath(path)) {
+ return CitraApplication.documentsTree.isDirectory(path);
+ }
+ return FileUtil.isDirectory(CitraApplication.getAppContext(), path);
+ }
+
+ public static boolean copyFile(String sourcePath, String destinationParentPath, String destinationFilename) {
+ if (FileUtil.isNativePath(sourcePath) && FileUtil.isNativePath(destinationParentPath)) {
+ return CitraApplication.documentsTree.copyFile(sourcePath, destinationParentPath, destinationFilename);
+ }
+ return FileUtil.copyFile(CitraApplication.getAppContext(), sourcePath, destinationParentPath, destinationFilename);
+ }
+
+ public static boolean renameFile(String path, String destinationFilename) {
+ if (FileUtil.isNativePath(path)) {
+ return CitraApplication.documentsTree.renameFile(path, destinationFilename);
+ }
+ return FileUtil.renameFile(CitraApplication.getAppContext(), path, destinationFilename);
+ }
+
+ public static boolean deleteDocument(String path) {
+ if (FileUtil.isNativePath(path)) {
+ return CitraApplication.documentsTree.deleteDocument(path);
+ }
+ return FileUtil.deleteDocument(CitraApplication.getAppContext(), path);
+ }
}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/activities/CustomFilePickerActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/activities/CustomFilePickerActivity.java
deleted file mode 100644
index 3083286e2..000000000
--- a/src/android/app/src/main/java/org/citra/citra_emu/activities/CustomFilePickerActivity.java
+++ /dev/null
@@ -1,38 +0,0 @@
-package org.citra.citra_emu.activities;
-
-import android.content.Intent;
-import android.os.Environment;
-
-import androidx.annotation.Nullable;
-
-import com.nononsenseapps.filepicker.AbstractFilePickerFragment;
-import com.nononsenseapps.filepicker.FilePickerActivity;
-
-import org.citra.citra_emu.fragments.CustomFilePickerFragment;
-
-import java.io.File;
-
-public class CustomFilePickerActivity extends FilePickerActivity {
- public static final String EXTRA_TITLE = "filepicker.intent.TITLE";
- public static final String EXTRA_EXTENSIONS = "filepicker.intent.EXTENSIONS";
-
- @Override
- protected AbstractFilePickerFragment getFragment(
- @Nullable final String startPath, final int mode, final boolean allowMultiple,
- final boolean allowCreateDir, final boolean allowExistingFile,
- final boolean singleClick) {
- CustomFilePickerFragment fragment = new CustomFilePickerFragment();
- // startPath is allowed to be null. In that case, default folder should be SD-card and not "/"
- fragment.setArgs(
- startPath != null ? startPath : Environment.getExternalStorageDirectory().getPath(),
- mode, allowMultiple, allowCreateDir, allowExistingFile, singleClick);
-
- Intent intent = getIntent();
- int title = intent == null ? 0 : intent.getIntExtra(EXTRA_TITLE, 0);
- fragment.setTitle(title);
- String allowedExtensions = intent == null ? "*" : intent.getStringExtra(EXTRA_EXTENSIONS);
- fragment.setAllowedExtensions(allowedExtensions);
-
- return fragment;
- }
-}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java
index 114f1e427..8eca11b95 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java
@@ -6,6 +6,7 @@ import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.preference.PreferenceManager;
+import android.util.Pair;
import android.util.SparseIntArray;
import android.view.InputDevice;
import android.view.KeyEvent;
@@ -20,6 +21,8 @@ import android.widget.CheckBox;
import android.widget.TextView;
import android.widget.Toast;
+import androidx.activity.result.ActivityResultCallback;
+import androidx.activity.result.ActivityResultLauncher;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
@@ -30,6 +33,7 @@ import androidx.fragment.app.FragmentActivity;
import org.citra.citra_emu.CitraApplication;
import org.citra.citra_emu.NativeLibrary;
import org.citra.citra_emu.R;
+import org.citra.citra_emu.contracts.OpenFileResultContract;
import org.citra.citra_emu.features.cheats.ui.CheatsActivity;
import org.citra.citra_emu.features.settings.model.view.InputBindingSetting;
import org.citra.citra_emu.features.settings.ui.SettingsActivity;
@@ -84,6 +88,18 @@ public final class EmulationActivity extends AppCompatActivity {
private static final int EMULATION_RUNNING_NOTIFICATION = 0x1000;
private static SparseIntArray buttonsActionsMap = new SparseIntArray();
+ private final ActivityResultLauncher mOpenFileLauncher =
+ registerForActivityResult(new OpenFileResultContract(), result -> {
+ if (result == null)
+ return;
+ String[] selectedFiles = FileBrowserHelper.getSelectedFiles(
+ result, getApplicationContext(), Collections.singletonList("bin"));
+ if (selectedFiles == null)
+ return;
+
+ onAmiiboSelected(selectedFiles[0]);
+ });
+
static {
buttonsActionsMap.append(R.id.menu_emulation_edit_layout,
EmulationActivity.MENU_ACTION_EDIT_CONTROLS_PLACEMENT);
@@ -453,9 +469,7 @@ public final class EmulationActivity extends AppCompatActivity {
break;
case MENU_ACTION_LOAD_AMIIBO:
- FileBrowserHelper.openFilePicker(this, REQUEST_SELECT_AMIIBO,
- R.string.select_amiibo,
- Collections.singletonList("bin"), false);
+ mOpenFileLauncher.launch(false);
break;
case MENU_ACTION_REMOVE_AMIIBO:
@@ -548,20 +562,8 @@ public final class EmulationActivity extends AppCompatActivity {
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent result) {
super.onActivityResult(requestCode, resultCode, result);
- switch (requestCode) {
- case StillImageCameraHelper.REQUEST_CAMERA_FILE_PICKER:
- StillImageCameraHelper.OnFilePickerResult(resultCode == RESULT_OK ? result : null);
- break;
- case REQUEST_SELECT_AMIIBO:
- // If the user picked a file, as opposed to just backing out.
- if (resultCode == MainActivity.RESULT_OK) {
- String[] selectedFiles = FileBrowserHelper.getSelectedFiles(result);
- if (selectedFiles == null)
- return;
-
- onAmiiboSelected(selectedFiles[0]);
- }
- break;
+ if (requestCode == StillImageCameraHelper.REQUEST_CAMERA_FILE_PICKER) {
+ StillImageCameraHelper.OnFilePickerResult(resultCode == RESULT_OK ? result : null);
}
}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.java b/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.java
index ca66c3b9b..1facecaa3 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.java
@@ -15,15 +15,15 @@ import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.color.MaterialColors;
+import org.citra.citra_emu.CitraApplication;
import org.citra.citra_emu.R;
import org.citra.citra_emu.activities.EmulationActivity;
import org.citra.citra_emu.model.GameDatabase;
+import org.citra.citra_emu.utils.FileUtil;
import org.citra.citra_emu.utils.Log;
import org.citra.citra_emu.utils.PicassoUtils;
import org.citra.citra_emu.viewholders.GameViewHolder;
-import java.nio.file.Path;
-import java.nio.file.Paths;
import java.util.stream.Stream;
/**
@@ -86,8 +86,14 @@ public final class GameAdapter extends RecyclerView.Adapter impl
holder.textGameTitle.setText(mCursor.getString(GameDatabase.GAME_COLUMN_TITLE).replaceAll("[\\t\\n\\r]+", " "));
holder.textCompany.setText(mCursor.getString(GameDatabase.GAME_COLUMN_COMPANY));
- final Path gamePath = Paths.get(mCursor.getString(GameDatabase.GAME_COLUMN_PATH));
- holder.textFileName.setText(gamePath.getFileName().toString());
+ String filepath = mCursor.getString(GameDatabase.GAME_COLUMN_PATH);
+ String filename;
+ if (FileUtil.isNativePath(filepath)) {
+ filename = CitraApplication.documentsTree.getFilename(filepath);
+ } else {
+ filename = FileUtil.getFilename(CitraApplication.getAppContext(), filepath);
+ }
+ holder.textFileName.setText(filename);
// TODO These shouldn't be necessary once the move to a DB-based model is complete.
holder.gameId = mCursor.getString(GameDatabase.GAME_COLUMN_GAME_ID);
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/contracts/OpenFileResultContract.java b/src/android/app/src/main/java/org/citra/citra_emu/contracts/OpenFileResultContract.java
new file mode 100644
index 000000000..cc29088ce
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/contracts/OpenFileResultContract.java
@@ -0,0 +1,24 @@
+package org.citra.citra_emu.contracts;
+
+import android.content.Context;
+import android.content.Intent;
+import android.util.Pair;
+
+import androidx.activity.result.contract.ActivityResultContract;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+public class OpenFileResultContract extends ActivityResultContract {
+ @NonNull
+ @Override
+ public Intent createIntent(@NonNull Context context, Boolean allowMultiple) {
+ return new Intent(Intent.ACTION_OPEN_DOCUMENT)
+ .setType("application/octet-stream")
+ .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, allowMultiple);
+ }
+
+ @Override
+ public Intent parseResult(int i, @Nullable Intent intent) {
+ return intent;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/dialogs/CitraDirectoryDialog.java b/src/android/app/src/main/java/org/citra/citra_emu/dialogs/CitraDirectoryDialog.java
new file mode 100644
index 000000000..7d70e94b4
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/dialogs/CitraDirectoryDialog.java
@@ -0,0 +1,91 @@
+package org.citra.citra_emu.dialogs;
+
+import android.app.Dialog;
+import android.content.SharedPreferences;
+import android.net.Uri;
+import android.os.Bundle;
+import android.preference.PreferenceManager;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.CheckBox;
+import android.widget.TextView;
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.DialogFragment;
+import androidx.fragment.app.FragmentActivity;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import java.util.Objects;
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.utils.FileUtil;
+import org.citra.citra_emu.utils.PermissionsHandler;
+
+public class CitraDirectoryDialog extends DialogFragment {
+ public static final String TAG = "citra_directory_dialog_fragment";
+
+ private static final String MOVE_DATE_ENABLE = "IS_MODE_DATA_ENABLE";
+
+ TextView pathView;
+
+ TextView spaceView;
+
+ CheckBox checkBox;
+
+ AlertDialog dialog;
+
+ Listener listener;
+
+ public interface Listener {
+ void onPressPositiveButton(boolean moveData, Uri path);
+ }
+
+ public static CitraDirectoryDialog newInstance(String path, Listener listener) {
+ CitraDirectoryDialog frag = new CitraDirectoryDialog();
+ frag.listener = listener;
+ Bundle args = new Bundle();
+ args.putString("path", path);
+ frag.setArguments(args);
+ return frag;
+ }
+
+ @NonNull
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ final FragmentActivity activity = requireActivity();
+ final Uri path = Uri.parse(Objects.requireNonNull(requireArguments().getString("path")));
+ SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(activity);
+ String freeSpaceText =
+ getResources().getString(R.string.free_space, FileUtil.getFreeSpace(activity, path));
+
+ LayoutInflater inflater = getLayoutInflater();
+ View view = inflater.inflate(R.layout.dialog_citra_directory, null);
+
+ checkBox = view.findViewById(R.id.checkBox);
+ pathView = view.findViewById(R.id.path);
+ spaceView = view.findViewById(R.id.space);
+
+ checkBox.setChecked(mPreferences.getBoolean(MOVE_DATE_ENABLE, true));
+ if (!PermissionsHandler.hasWriteAccess(activity)) {
+ checkBox.setVisibility(View.GONE);
+ }
+ checkBox.setOnCheckedChangeListener(
+ (v, isChecked)
+ // record move data selection with SharedPreferences
+ -> mPreferences.edit().putBoolean(MOVE_DATE_ENABLE, checkBox.isChecked()).apply());
+
+ pathView.setText(path.getPath());
+ spaceView.setText(freeSpaceText);
+
+ setCancelable(false);
+
+ dialog = new MaterialAlertDialogBuilder(activity)
+ .setView(view)
+ .setIcon(R.mipmap.ic_launcher)
+ .setTitle(R.string.app_name)
+ .setPositiveButton(
+ android.R.string.ok,
+ (d, v) -> listener.onPressPositiveButton(checkBox.isChecked(), path))
+ .setNegativeButton(android.R.string.cancel, null)
+ .create();
+ return dialog;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/dialogs/CopyDirProgressDialog.java b/src/android/app/src/main/java/org/citra/citra_emu/dialogs/CopyDirProgressDialog.java
new file mode 100644
index 000000000..f13e626ee
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/dialogs/CopyDirProgressDialog.java
@@ -0,0 +1,61 @@
+package org.citra.citra_emu.dialogs;
+
+import android.app.Dialog;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.DialogFragment;
+import androidx.fragment.app.FragmentActivity;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import org.citra.citra_emu.R;
+
+public class CopyDirProgressDialog extends DialogFragment {
+ public static final String TAG = "copy_dir_progress_dialog";
+ ProgressBar progressBar;
+
+ TextView progressText;
+
+ AlertDialog dialog;
+
+ @NonNull
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ final FragmentActivity activity = requireActivity();
+
+ LayoutInflater inflater = getLayoutInflater();
+ View view = inflater.inflate(R.layout.dialog_progress_bar, null);
+
+ progressBar = view.findViewById(R.id.progress_bar);
+ progressText = view.findViewById(R.id.progress_text);
+ progressText.setText("");
+
+ setCancelable(false);
+
+ dialog = new MaterialAlertDialogBuilder(activity)
+ .setView(view)
+ .setIcon(R.mipmap.ic_launcher)
+ .setTitle(R.string.move_data)
+ .setMessage("")
+ .create();
+ return dialog;
+ }
+
+ public void onUpdateSearchProgress(String msg) {
+ requireActivity().runOnUiThread(() -> {
+ dialog.setMessage(getResources().getString(R.string.searching_direcotry, msg));
+ });
+ }
+
+ public void onUpdateCopyProgress(String msg, int progress, int max) {
+ requireActivity().runOnUiThread(() -> {
+ progressBar.setProgress(progress);
+ progressBar.setMax(max);
+ progressText.setText(String.format("%d/%d", progress, max));
+ dialog.setMessage(getResources().getString(R.string.copy_file_name, msg));
+ });
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityPresenter.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityPresenter.java
index e288bf934..b4f7c22d1 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityPresenter.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityPresenter.java
@@ -3,9 +3,9 @@ package org.citra.citra_emu.features.settings.ui;
import android.content.IntentFilter;
import android.os.Bundle;
import android.text.TextUtils;
-
import androidx.appcompat.app.AppCompatActivity;
-
+import androidx.documentfile.provider.DocumentFile;
+import java.io.File;
import org.citra.citra_emu.NativeLibrary;
import org.citra.citra_emu.features.settings.model.Settings;
import org.citra.citra_emu.features.settings.utils.SettingsFile;
@@ -15,8 +15,6 @@ import org.citra.citra_emu.utils.DirectoryStateReceiver;
import org.citra.citra_emu.utils.Log;
import org.citra.citra_emu.utils.ThemeUtil;
-import java.io.File;
-
public final class SettingsActivityPresenter {
private static final String KEY_SHOULD_SAVE = "should_save";
@@ -62,8 +60,8 @@ public final class SettingsActivityPresenter {
}
private void prepareCitraDirectoriesIfNeeded() {
- File configFile = new File(DirectoryInitialization.getUserDirectory() + "/config/" + SettingsFile.FILE_NAME_CONFIG + ".ini");
- if (!configFile.exists()) {
+ DocumentFile configFile = SettingsFile.getSettingsFile(SettingsFile.FILE_NAME_CONFIG);
+ if (configFile == null || !configFile.exists()) {
Log.error("Citra config file could not be found!");
}
if (DirectoryInitialization.areCitraDirectoriesReady()) {
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java
index c73f45d2e..4246b50f6 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java
@@ -1,6 +1,10 @@
package org.citra.citra_emu.features.settings.utils;
+import android.content.Context;
+import android.net.Uri;
+
import androidx.annotation.NonNull;
+import androidx.documentfile.provider.DocumentFile;
import org.citra.citra_emu.CitraApplication;
import org.citra.citra_emu.NativeLibrary;
@@ -18,10 +22,11 @@ import org.citra.citra_emu.utils.Log;
import org.ini4j.Wini;
import java.io.BufferedReader;
-import java.io.File;
import java.io.FileNotFoundException;
-import java.io.FileReader;
import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
import java.util.HashMap;
import java.util.Set;
import java.util.TreeMap;
@@ -145,13 +150,15 @@ public final class SettingsFile {
* @param view The current view.
* @return An Observable that emits a HashMap of the file's contents, then completes.
*/
- static HashMap readFile(final File ini, boolean isCustomGame, SettingsActivityView view) {
+ static HashMap readFile(final DocumentFile ini, boolean isCustomGame, SettingsActivityView view) {
HashMap sections = new Settings.SettingsSectionMap();
BufferedReader reader = null;
try {
- reader = new BufferedReader(new FileReader(ini));
+ Context context = CitraApplication.getAppContext();
+ InputStream inputStream = context.getContentResolver().openInputStream(ini.getUri());
+ reader = new BufferedReader(new InputStreamReader(inputStream));
SettingSection current = null;
for (String line; (line = reader.readLine()) != null; ) {
@@ -166,11 +173,11 @@ public final class SettingsFile {
}
}
} catch (FileNotFoundException e) {
- Log.error("[SettingsFile] File not found: " + ini.getAbsolutePath() + e.getMessage());
+ Log.error("[SettingsFile] File not found: " + ini.getUri() + e.getMessage());
if (view != null)
view.onSettingsFileNotFound();
} catch (IOException e) {
- Log.error("[SettingsFile] Error reading from: " + ini.getAbsolutePath() + e.getMessage());
+ Log.error("[SettingsFile] Error reading from: " + ini.getUri() + e.getMessage());
if (view != null)
view.onSettingsFileNotFound();
} finally {
@@ -178,7 +185,7 @@ public final class SettingsFile {
try {
reader.close();
} catch (IOException e) {
- Log.error("[SettingsFile] Error closing: " + ini.getAbsolutePath() + e.getMessage());
+ Log.error("[SettingsFile] Error closing: " + ini.getUri() + e.getMessage());
}
}
}
@@ -212,17 +219,23 @@ public final class SettingsFile {
*/
public static void saveFile(final String fileName, TreeMap sections,
SettingsActivityView view) {
- File ini = getSettingsFile(fileName);
+ DocumentFile ini = getSettingsFile(fileName);
try {
- Wini writer = new Wini(ini);
+ Context context = CitraApplication.getAppContext();
+ InputStream inputStream = context.getContentResolver().openInputStream(ini.getUri());
+ Wini writer = new Wini(inputStream);
Set keySet = sections.keySet();
for (String key : keySet) {
SettingSection section = sections.get(key);
writeSection(writer, section);
}
- writer.store();
+ inputStream.close();
+ OutputStream outputStream = context.getContentResolver().openOutputStream(ini.getUri());
+ writer.store(outputStream);
+ outputStream.flush();
+ outputStream.close();
} catch (IOException e) {
Log.error("[SettingsFile] File not found: " + fileName + ".ini: " + e.getMessage());
view.showToastMessage(CitraApplication.getAppContext().getString(R.string.error_saving, fileName, e.getMessage()), false);
@@ -262,14 +275,16 @@ public final class SettingsFile {
return generalSectionName;
}
- @NonNull
- private static File getSettingsFile(String fileName) {
- return new File(
- DirectoryInitialization.getUserDirectory() + "/config/" + fileName + ".ini");
+ public static DocumentFile getSettingsFile(String fileName) {
+ DocumentFile root = DocumentFile.fromTreeUri(CitraApplication.getAppContext(), Uri.parse(DirectoryInitialization.getUserDirectory()));
+ DocumentFile configDirectory = root.findFile("config");
+ return configDirectory.findFile(fileName + ".ini");
}
- private static File getCustomGameSettingsFile(String gameId) {
- return new File(DirectoryInitialization.getUserDirectory() + "/GameSettings/" + gameId + ".ini");
+ private static DocumentFile getCustomGameSettingsFile(String gameId) {
+ DocumentFile root = DocumentFile.fromTreeUri(CitraApplication.getAppContext(), Uri.parse(DirectoryInitialization.getUserDirectory()));
+ DocumentFile configDirectory = root.findFile("GameSettings");
+ return configDirectory.findFile(gameId + ".ini");
}
private static SettingSection sectionFromLine(String line, boolean isCustomGame) {
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/CustomFilePickerFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/fragments/CustomFilePickerFragment.java
deleted file mode 100644
index c18ecd4c3..000000000
--- a/src/android/app/src/main/java/org/citra/citra_emu/fragments/CustomFilePickerFragment.java
+++ /dev/null
@@ -1,120 +0,0 @@
-package org.citra.citra_emu.fragments;
-
-import android.net.Uri;
-import android.os.Bundle;
-import android.os.Environment;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.TextView;
-
-import androidx.annotation.NonNull;
-import androidx.appcompat.widget.Toolbar;
-import androidx.core.content.FileProvider;
-
-import com.nononsenseapps.filepicker.FilePickerFragment;
-
-import org.citra.citra_emu.R;
-
-import java.io.File;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
-
-public class CustomFilePickerFragment extends FilePickerFragment {
- private static String ALL_FILES = "*";
- private int mTitle;
- private static List extensions = Collections.singletonList(ALL_FILES);
-
- @NonNull
- @Override
- public Uri toUri(@NonNull final File file) {
- return FileProvider
- .getUriForFile(getContext(),
- getContext().getApplicationContext().getPackageName() + ".filesprovider",
- file);
- }
-
- @Override
- public void onActivityCreated(Bundle savedInstanceState) {
- super.onActivityCreated(savedInstanceState);
-
- if (mode == MODE_DIR) {
- TextView ok = getActivity().findViewById(R.id.nnf_button_ok);
- ok.setText(R.string.select_dir);
-
- TextView cancel = getActivity().findViewById(R.id.nnf_button_cancel);
- cancel.setVisibility(View.GONE);
- }
- }
-
- @Override
- protected View inflateRootView(LayoutInflater inflater, ViewGroup container) {
- View view = super.inflateRootView(inflater, container);
- if (mTitle != 0) {
- Toolbar toolbar = view.findViewById(com.nononsenseapps.filepicker.R.id.nnf_picker_toolbar);
- ViewGroup parent = (ViewGroup) toolbar.getParent();
- int index = parent.indexOfChild(toolbar);
- View newToolbar = inflater.inflate(R.layout.filepicker_toolbar, toolbar, false);
- TextView title = newToolbar.findViewById(R.id.filepicker_title);
- title.setText(mTitle);
- parent.removeView(toolbar);
- parent.addView(newToolbar, index);
- }
- return view;
- }
-
- public void setTitle(int title) {
- mTitle = title;
- }
-
- public void setAllowedExtensions(String allowedExtensions) {
- if (allowedExtensions == null)
- return;
-
- extensions = Arrays.asList(allowedExtensions.split(","));
- }
-
- @Override
- protected boolean isItemVisible(@NonNull final File file) {
- // Some users jump to the conclusion that Dolphin isn't able to detect their
- // files if the files don't show up in the file picker when mode == MODE_DIR.
- // To avoid this, show files even when the user needs to select a directory.
- return (showHiddenItems || !file.isHidden()) &&
- (file.isDirectory() || extensions.contains(ALL_FILES) ||
- extensions.contains(fileExtension(file.getName()).toLowerCase()));
- }
-
- @Override
- public boolean isCheckable(@NonNull final File file) {
- // We need to make a small correction to the isCheckable logic due to
- // overriding isItemVisible to show files when mode == MODE_DIR.
- // AbstractFilePickerFragment always treats files as checkable when
- // allowExistingFile == true, but we don't want files to be checkable when mode == MODE_DIR.
- return super.isCheckable(file) && !(mode == MODE_DIR && file.isFile());
- }
-
- @Override
- public void goUp() {
- if (Environment.getExternalStorageDirectory().getPath().equals(mCurrentPath.getPath())) {
- goToDir(new File("/storage/"));
- return;
- }
- if (mCurrentPath.equals(new File("/storage/"))){
- return;
- }
- super.goUp();
- }
-
- @Override
- public void onClickDir(@NonNull View view, @NonNull DirViewHolder viewHolder) {
- if(viewHolder.file.equals(new File("/storage/emulated/")))
- viewHolder.file = new File("/storage/emulated/0/");
- super.onClickDir(view, viewHolder);
- }
-
- private static String fileExtension(@NonNull String filename) {
- int i = filename.lastIndexOf('.');
- return i < 0 ? "" : filename.substring(i + 1);
- }
-}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/CheapDocument.java b/src/android/app/src/main/java/org/citra/citra_emu/model/CheapDocument.java
new file mode 100644
index 000000000..743e1d842
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/model/CheapDocument.java
@@ -0,0 +1,36 @@
+package org.citra.citra_emu.model;
+
+import android.net.Uri;
+import android.provider.DocumentsContract;
+
+/**
+ * A struct that is much more "cheaper" than DocumentFile.
+ * Only contains the information we needed.
+ */
+public class CheapDocument {
+ private final String filename;
+ private final Uri uri;
+ private final String mimeType;
+
+ public CheapDocument(String filename, String mimeType, Uri uri) {
+ this.filename = filename;
+ this.mimeType = mimeType;
+ this.uri = uri;
+ }
+
+ public String getFilename() {
+ return filename;
+ }
+
+ public Uri getUri() {
+ return uri;
+ }
+
+ public String getMimeType() {
+ return mimeType;
+ }
+
+ public boolean isDirectory() {
+ return mimeType.equals(DocumentsContract.Document.MIME_TYPE_DIR);
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/GameDatabase.java b/src/android/app/src/main/java/org/citra/citra_emu/model/GameDatabase.java
index 215528541..f4086386f 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/model/GameDatabase.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/model/GameDatabase.java
@@ -5,8 +5,10 @@ import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
+import android.net.Uri;
import org.citra.citra_emu.NativeLibrary;
+import org.citra.citra_emu.utils.FileUtil;
import org.citra.citra_emu.utils.Log;
import java.io.File;
@@ -64,10 +66,12 @@ public final class GameDatabase extends SQLiteOpenHelper {
private static final String SQL_DELETE_FOLDERS = "DROP TABLE IF EXISTS " + TABLE_NAME_FOLDERS;
private static final String SQL_DELETE_GAMES = "DROP TABLE IF EXISTS " + TABLE_NAME_GAMES;
+ private final Context mContext;
public GameDatabase(Context context) {
// Superclass constructor builds a database or uses an existing one.
super(context, "games.db", null, DB_VERSION);
+ mContext = context;
}
@Override
@@ -151,9 +155,10 @@ public final class GameDatabase extends SQLiteOpenHelper {
while (folderCursor.moveToNext()) {
String folderPath = folderCursor.getString(FOLDER_COLUMN_PATH);
- File folder = new File(folderPath);
+ Uri folder = Uri.parse(folderPath);
// If the folder is empty because it no longer exists, remove it from the library.
- if (!folder.exists()) {
+ CheapDocument[] files = FileUtil.listFiles(mContext, folder);
+ if (files.length == 0) {
Log.error(
"[GameDatabase] Folder no longer exists. Removing from the library: " + folderPath);
database.delete(TABLE_NAME_FOLDERS,
@@ -161,7 +166,7 @@ public final class GameDatabase extends SQLiteOpenHelper {
new String[]{Long.toString(folderCursor.getLong(COLUMN_DB_ID))});
}
- addGamesRecursive(database, folder, allowedExtensions, 3);
+ addGamesRecursive(database, files, allowedExtensions, 3);
}
fileCursor.close();
@@ -173,33 +178,28 @@ public final class GameDatabase extends SQLiteOpenHelper {
database.close();
}
- private static void addGamesRecursive(SQLiteDatabase database, File parent, Set allowedExtensions, int depth) {
+ private void addGamesRecursive(SQLiteDatabase database, CheapDocument[] files,
+ Set allowedExtensions, int depth) {
if (depth <= 0) {
return;
}
- File[] children = parent.listFiles();
- if (children != null) {
- for (File file : children) {
- if (file.isHidden()) {
- continue;
- }
+ for (CheapDocument file : files) {
+ if (file.isDirectory()) {
+ Set newExtensions = new HashSet<>(Arrays.asList(
+ ".3ds", ".3dsx", ".elf", ".axf", ".cci", ".cxi", ".app"));
+ CheapDocument[] children = FileUtil.listFiles(mContext, file.getUri());
+ this.addGamesRecursive(database, children, newExtensions, depth - 1);
+ } else {
+ String filename = file.getUri().toString();
- if (file.isDirectory()) {
- Set newExtensions = new HashSet<>(Arrays.asList(
- ".3ds", ".3dsx", ".elf", ".axf", ".cci", ".cxi", ".app"));
- addGamesRecursive(database, file, newExtensions, depth - 1);
- } else {
- String filePath = file.getPath();
+ int extensionStart = filename.lastIndexOf('.');
+ if (extensionStart > 0) {
+ String fileExtension = filename.substring(extensionStart);
- int extensionStart = filePath.lastIndexOf('.');
- if (extensionStart > 0) {
- String fileExtension = filePath.substring(extensionStart);
-
- // Check that the file has an extension we care about before trying to read out of it.
- if (allowedExtensions.contains(fileExtension.toLowerCase())) {
- attemptToAddGame(database, filePath);
- }
+ // Check that the file has an extension we care about before trying to read out of it.
+ if (allowedExtensions.contains(fileExtension.toLowerCase())) {
+ attemptToAddGame(database, filename);
}
}
}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java
index 75e25c4b1..99bf494b2 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java
@@ -1,25 +1,30 @@
package org.citra.citra_emu.ui.main;
import android.content.Intent;
-import android.content.pm.PackageManager;
+import android.net.Uri;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.widget.Toast;
-
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.Toolbar;
-
+import androidx.core.splashscreen.SplashScreen;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import java.util.Collections;
import org.citra.citra_emu.NativeLibrary;
import org.citra.citra_emu.R;
import org.citra.citra_emu.activities.EmulationActivity;
+import org.citra.citra_emu.contracts.OpenFileResultContract;
import org.citra.citra_emu.features.settings.ui.SettingsActivity;
import org.citra.citra_emu.model.GameProvider;
import org.citra.citra_emu.ui.platform.PlatformGamesFragment;
import org.citra.citra_emu.utils.AddDirectoryHelper;
import org.citra.citra_emu.utils.BillingManager;
+import org.citra.citra_emu.utils.CitraDirectoryHelper;
import org.citra.citra_emu.utils.DirectoryInitialization;
import org.citra.citra_emu.utils.FileBrowserHelper;
import org.citra.citra_emu.utils.PermissionsHandler;
@@ -27,9 +32,6 @@ import org.citra.citra_emu.utils.PicassoUtils;
import org.citra.citra_emu.utils.StartupHandler;
import org.citra.citra_emu.utils.ThemeUtil;
-import java.util.Arrays;
-import java.util.Collections;
-
/**
* The main Activity of the Lollipop style UI. Manages several PlatformGamesFragments, which
* individually display a grid of available games for each Fragment, in a tabbed layout.
@@ -46,8 +48,65 @@ public final class MainActivity extends AppCompatActivity implements MainView {
private static MenuItem mPremiumButton;
+ private final CitraDirectoryHelper citraDirectoryHelper = new CitraDirectoryHelper(this, () -> {
+ // If mPlatformGamesFragment is null means game directory have not been set yet.
+ if (mPlatformGamesFragment == null) {
+ mPlatformGamesFragment = new PlatformGamesFragment();
+ getSupportFragmentManager()
+ .beginTransaction()
+ .add(mFrameLayoutId, mPlatformGamesFragment)
+ .commit();
+ showGameInstallDialog();
+ }
+ });
+
+ private final ActivityResultLauncher mOpenCitraDirectory =
+ registerForActivityResult(new ActivityResultContracts.OpenDocumentTree(), result -> {
+ if (result == null)
+ return;
+ citraDirectoryHelper.showCitraDirectoryDialog(result);
+ });
+
+ private final ActivityResultLauncher mOpenGameListLauncher =
+ registerForActivityResult(new ActivityResultContracts.OpenDocumentTree(), result -> {
+ if (result == null)
+ return;
+ int takeFlags =
+ (Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ getContentResolver().takePersistableUriPermission(result, takeFlags);
+ // When a new directory is picked, we currently will reset the existing games
+ // database. This effectively means that only one game directory is supported.
+ // TODO(bunnei): Consider fixing this in the future, or removing code for this.
+ getContentResolver().insert(GameProvider.URI_RESET, null);
+ // Add the new directory
+ mPresenter.onDirectorySelected(result.toString());
+ });
+
+ private final ActivityResultLauncher mOpenFileLauncher =
+ registerForActivityResult(new OpenFileResultContract(), result -> {
+ if (result == null)
+ return;
+ String[] selectedFiles = FileBrowserHelper.getSelectedFiles(
+ result, getApplicationContext(), Collections.singletonList("cia"));
+ if (selectedFiles == null) {
+ Toast
+ .makeText(getApplicationContext(), R.string.cia_file_not_found,
+ Toast.LENGTH_LONG)
+ .show();
+ return;
+ }
+ NativeLibrary.InstallCIAS(selectedFiles);
+ mPresenter.refreshGameList();
+ });
+
@Override
protected void onCreate(Bundle savedInstanceState) {
+ SplashScreen splashScreen = SplashScreen.installSplashScreen(this);
+ splashScreen.setKeepOnScreenCondition(
+ ()
+ -> (PermissionsHandler.hasWriteAccess(this) &&
+ !DirectoryInitialization.areCitraDirectoriesReady()));
+
ThemeUtil.applyTheme(this);
super.onCreate(savedInstanceState);
@@ -61,7 +120,7 @@ public final class MainActivity extends AppCompatActivity implements MainView {
mPresenter.onCreate();
if (savedInstanceState == null) {
- StartupHandler.HandleInit(this);
+ StartupHandler.HandleInit(this, mOpenCitraDirectory);
if (PermissionsHandler.hasWriteAccess(this)) {
mPlatformGamesFragment = new PlatformGamesFragment();
getSupportFragmentManager().beginTransaction().add(mFrameLayoutId, mPlatformGamesFragment)
@@ -144,7 +203,7 @@ public final class MainActivity extends AppCompatActivity implements MainView {
if (PermissionsHandler.hasWriteAccess(this)) {
SettingsActivity.launch(this, menuTag, "");
} else {
- PermissionsHandler.checkWritePermission(this);
+ PermissionsHandler.checkWritePermission(this, mOpenCitraDirectory);
}
}
@@ -152,79 +211,18 @@ public final class MainActivity extends AppCompatActivity implements MainView {
public void launchFileListActivity(int request) {
if (PermissionsHandler.hasWriteAccess(this)) {
switch (request) {
+ case MainPresenter.REQUEST_SELECT_CITRA_DIRECTORY:
+ mOpenCitraDirectory.launch(null);
+ break;
case MainPresenter.REQUEST_ADD_DIRECTORY:
- FileBrowserHelper.openDirectoryPicker(this,
- MainPresenter.REQUEST_ADD_DIRECTORY,
- R.string.select_game_folder,
- Arrays.asList("elf", "axf", "cci", "3ds",
- "cxi", "app", "3dsx", "cia",
- "rar", "zip", "7z", "torrent",
- "tar", "gz"));
- break;
+ mOpenGameListLauncher.launch(null);
+ break;
case MainPresenter.REQUEST_INSTALL_CIA:
- FileBrowserHelper.openFilePicker(this, MainPresenter.REQUEST_INSTALL_CIA,
- R.string.install_cia_title,
- Collections.singletonList("cia"), true);
- break;
+ mOpenFileLauncher.launch(true);
+ break;
}
} else {
- PermissionsHandler.checkWritePermission(this);
- }
- }
-
- /**
- * @param requestCode An int describing whether the Activity that is returning did so successfully.
- * @param resultCode An int describing what Activity is giving us this callback.
- * @param result The information the returning Activity is providing us.
- */
- @Override
- protected void onActivityResult(int requestCode, int resultCode, Intent result) {
- super.onActivityResult(requestCode, resultCode, result);
- switch (requestCode) {
- case MainPresenter.REQUEST_ADD_DIRECTORY:
- // If the user picked a file, as opposed to just backing out.
- if (resultCode == MainActivity.RESULT_OK) {
- // When a new directory is picked, we currently will reset the existing games
- // database. This effectively means that only one game directory is supported.
- // TODO(bunnei): Consider fixing this in the future, or removing code for this.
- getContentResolver().insert(GameProvider.URI_RESET, null);
- // Add the new directory
- mPresenter.onDirectorySelected(FileBrowserHelper.getSelectedDirectory(result));
- }
- break;
- case MainPresenter.REQUEST_INSTALL_CIA:
- // If the user picked a file, as opposed to just backing out.
- if (resultCode == MainActivity.RESULT_OK) {
- NativeLibrary.InstallCIAS(FileBrowserHelper.getSelectedFiles(result));
- mPresenter.refeshGameList();
- }
- break;
- }
- }
-
- @Override
- public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
- switch (requestCode) {
- case PermissionsHandler.REQUEST_CODE_WRITE_PERMISSION:
- if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
- DirectoryInitialization.start(this);
-
- mPlatformGamesFragment = new PlatformGamesFragment();
- getSupportFragmentManager().beginTransaction().add(mFrameLayoutId, mPlatformGamesFragment)
- .commit();
-
- // Immediately prompt user to select a game directory on first boot
- if (mPresenter != null) {
- mPresenter.launchFileListActivity(MainPresenter.REQUEST_ADD_DIRECTORY);
- }
- } else {
- Toast.makeText(this, R.string.write_permission_needed, Toast.LENGTH_SHORT)
- .show();
- }
- break;
- default:
- super.onRequestPermissionsResult(requestCode, permissions, grantResults);
- break;
+ PermissionsHandler.checkWritePermission(this, mOpenCitraDirectory);
}
}
@@ -245,6 +243,18 @@ public final class MainActivity extends AppCompatActivity implements MainView {
}
}
+ private void showGameInstallDialog() {
+ new MaterialAlertDialogBuilder(this)
+ .setIcon(R.mipmap.ic_launcher)
+ .setTitle(R.string.app_name)
+ .setMessage(R.string.app_game_install_description)
+ .setCancelable(false)
+ .setNegativeButton(android.R.string.cancel, null)
+ .setPositiveButton(android.R.string.ok,
+ (d, v) -> mOpenGameListLauncher.launch(null))
+ .show();
+ }
+
@Override
protected void onDestroy() {
EmulationActivity.tryDismissRunningNotification(this);
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java
index 4e9994c2a..b25cbe53fe 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java
@@ -1,5 +1,6 @@
package org.citra.citra_emu.ui.main;
+import android.content.Context;
import android.os.SystemClock;
import org.citra.citra_emu.BuildConfig;
@@ -9,10 +10,12 @@ import org.citra.citra_emu.features.settings.model.Settings;
import org.citra.citra_emu.features.settings.utils.SettingsFile;
import org.citra.citra_emu.model.GameDatabase;
import org.citra.citra_emu.utils.AddDirectoryHelper;
+import org.citra.citra_emu.utils.PermissionsHandler;
public final class MainPresenter {
public static final int REQUEST_ADD_DIRECTORY = 1;
public static final int REQUEST_INSTALL_CIA = 2;
+ public static final int REQUEST_SELECT_CITRA_DIRECTORY = 3;
private final MainView mView;
private String mDirToAdd;
@@ -25,7 +28,7 @@ public final class MainPresenter {
public void onCreate() {
String versionName = BuildConfig.VERSION_NAME;
mView.setVersionString(versionName);
- refeshGameList();
+ refreshGameList();
}
public void launchFileListActivity(int request) {
@@ -46,6 +49,10 @@ public final class MainPresenter {
mView.launchSettingsActivity(SettingsFile.FILE_NAME_CONFIG);
return true;
+ case R.id.button_select_root:
+ mView.launchFileListActivity(REQUEST_SELECT_CITRA_DIRECTORY);
+ return true;
+
case R.id.button_add_directory:
launchFileListActivity(REQUEST_ADD_DIRECTORY);
return true;
@@ -74,9 +81,12 @@ public final class MainPresenter {
mDirToAdd = dir;
}
- public void refeshGameList() {
- GameDatabase databaseHelper = CitraApplication.databaseHelper;
- databaseHelper.scanLibrary(databaseHelper.getWritableDatabase());
- mView.refresh();
+ public void refreshGameList() {
+ Context context = CitraApplication.getAppContext();
+ if (PermissionsHandler.hasWriteAccess(context)) {
+ GameDatabase databaseHelper = CitraApplication.databaseHelper;
+ databaseHelper.scanLibrary(databaseHelper.getWritableDatabase());
+ mView.refresh();
+ }
}
}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/CitraDirectoryHelper.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/CitraDirectoryHelper.java
new file mode 100644
index 000000000..5a3ff6119
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/CitraDirectoryHelper.java
@@ -0,0 +1,87 @@
+package org.citra.citra_emu.utils;
+
+import android.content.Intent;
+import android.net.Uri;
+import androidx.fragment.app.FragmentActivity;
+import java.util.concurrent.Executors;
+import org.citra.citra_emu.dialogs.CitraDirectoryDialog;
+import org.citra.citra_emu.dialogs.CopyDirProgressDialog;
+
+/**
+ * Citra directory initialization ui flow controller.
+ */
+public class CitraDirectoryHelper {
+ public interface Listener {
+ void onDirectoryInitialized();
+ }
+
+ private final FragmentActivity mFragmentActivity;
+ private final Listener mListener;
+
+ public CitraDirectoryHelper(FragmentActivity mFragmentActivity, Listener mListener) {
+ this.mFragmentActivity = mFragmentActivity;
+ this.mListener = mListener;
+ }
+
+ public void showCitraDirectoryDialog(Uri result) {
+ CitraDirectoryDialog citraDirectoryDialog = CitraDirectoryDialog.newInstance(
+ result.toString(), ((moveData, path) -> {
+ Uri previous = PermissionsHandler.getCitraDirectory();
+ // Do noting if user select the previous path.
+ if (path.equals(previous)) {
+ return;
+ }
+ int takeFlags = (Intent.FLAG_GRANT_WRITE_URI_PERMISSION |
+ Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ mFragmentActivity.getContentResolver().takePersistableUriPermission(path,
+ takeFlags);
+ if (!moveData || previous == null) {
+ initializeCitraDirectory(path);
+ mListener.onDirectoryInitialized();
+ return;
+ }
+
+ // If user check move data, show copy progress dialog.
+ showCopyDialog(previous, path);
+ }));
+ citraDirectoryDialog.show(mFragmentActivity.getSupportFragmentManager(),
+ CitraDirectoryDialog.TAG);
+ }
+
+ private void showCopyDialog(Uri previous, Uri path) {
+ CopyDirProgressDialog copyDirProgressDialog = new CopyDirProgressDialog();
+ copyDirProgressDialog.showNow(mFragmentActivity.getSupportFragmentManager(),
+ CopyDirProgressDialog.TAG);
+
+ // Run copy dir in background
+ Executors.newSingleThreadExecutor().execute(() -> {
+ FileUtil.copyDir(
+ mFragmentActivity, previous.toString(), path.toString(),
+ new FileUtil.CopyDirListener() {
+ @Override
+ public void onSearchProgress(String directoryName) {
+ copyDirProgressDialog.onUpdateSearchProgress(directoryName);
+ }
+
+ @Override
+ public void onCopyProgress(String filename, int progress, int max) {
+ copyDirProgressDialog.onUpdateCopyProgress(filename, progress, max);
+ }
+
+ @Override
+ public void onComplete() {
+ initializeCitraDirectory(path);
+ copyDirProgressDialog.dismissAllowingStateLoss();
+ mListener.onDirectoryInitialized();
+ }
+ });
+ });
+ }
+
+ private void initializeCitraDirectory(Uri path) {
+ if (!PermissionsHandler.setCitraDirectory(path.toString()))
+ return;
+ DirectoryInitialization.resetCitraDirectoryState();
+ DirectoryInitialization.start(mFragmentActivity);
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.java
index 58e552f5e..5de5d9a74 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.java
@@ -9,19 +9,18 @@ package org.citra.citra_emu.utils;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
+import android.net.Uri;
import android.os.Environment;
import android.preference.PreferenceManager;
-
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
-
-import org.citra.citra_emu.NativeLibrary;
-
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.concurrent.atomic.AtomicBoolean;
+import org.citra.citra_emu.CitraApplication;
+import org.citra.citra_emu.NativeLibrary;
/**
* A service that spawns its own thread in order to copy several binary and shader files
@@ -49,6 +48,9 @@ public final class DirectoryInitialization {
if (PermissionsHandler.hasWriteAccess(context)) {
if (setCitraUserDirectory()) {
initializeInternalStorage(context);
+ CitraApplication.documentsTree.setRoot(Uri.parse(userPath));
+ NativeLibrary.CreateLogFile();
+ NativeLibrary.LogUserDirectory(userPath);
NativeLibrary.CreateConfigFile();
directoryState = DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED;
} else {
@@ -75,6 +77,11 @@ public final class DirectoryInitialization {
return directoryState == DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED;
}
+ public static void resetCitraDirectoryState() {
+ directoryState = null;
+ isCitraDirectoryInitializationRunning.compareAndSet(true, false);
+ }
+
public static String getUserDirectory() {
if (directoryState == null) {
throw new IllegalStateException("DirectoryInitialization has to run at least once!");
@@ -88,15 +95,11 @@ public final class DirectoryInitialization {
private static native void SetSysDirectory(String path);
private static boolean setCitraUserDirectory() {
- if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) {
- File externalPath = Environment.getExternalStorageDirectory();
- if (externalPath != null) {
- userPath = externalPath.getAbsolutePath() + "/citra-emu";
- Log.debug("[DirectoryInitialization] User Dir: " + userPath);
- // NativeLibrary.SetUserDirectory(userPath);
- return true;
- }
-
+ Uri dataPath = PermissionsHandler.getCitraDirectory();
+ if (dataPath != null) {
+ userPath = dataPath.toString();
+ Log.debug("[DirectoryInitialization] User Dir: " + userPath);
+ return true;
}
return false;
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/DocumentsTree.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/DocumentsTree.java
new file mode 100644
index 000000000..22e4baf60
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/DocumentsTree.java
@@ -0,0 +1,271 @@
+package org.citra.citra_emu.utils;
+
+import android.content.Context;
+import android.net.Uri;
+import android.provider.DocumentsContract;
+
+import androidx.annotation.Nullable;
+import androidx.documentfile.provider.DocumentFile;
+
+import org.citra.citra_emu.CitraApplication;
+import org.citra.citra_emu.model.CheapDocument;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URLDecoder;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.StringTokenizer;
+
+/**
+ * A cached document tree for citra user directory.
+ * For every filepath which is not startsWith "content://" will need to use this class to traverse.
+ * For example:
+ * C++ citra log file directory will be /log/citra_log.txt.
+ * After DocumentsTree.resolvePath() it will become content URI.
+ */
+public class DocumentsTree {
+ private DocumentsNode root;
+ private final Context context;
+ public static final String DELIMITER = "/";
+
+ public DocumentsTree() {
+ context = CitraApplication.getAppContext();
+ }
+
+ public void setRoot(Uri rootUri) {
+ root = null;
+ root = new DocumentsNode();
+ root.uri = rootUri;
+ root.isDirectory = true;
+ }
+
+ public boolean createFile(String filepath, String name) {
+ DocumentsNode node = resolvePath(filepath);
+ if (node == null) return false;
+ if (!node.isDirectory) return false;
+ if (!node.loaded) structTree(node);
+ Uri mUri = node.uri;
+ try {
+ String filename = URLDecoder.decode(name, FileUtil.DECODE_METHOD);
+ if (node.children.get(filename) != null) return true;
+ DocumentFile createdFile = FileUtil.createFile(context, mUri.toString(), name);
+ if (createdFile == null) return false;
+ DocumentsNode document = new DocumentsNode(createdFile, false);
+ document.parent = node;
+ node.children.put(document.key, document);
+ return true;
+ } catch (Exception e) {
+ Log.error("[DocumentsTree]: Cannot create file, error: " + e.getMessage());
+ }
+ return false;
+ }
+
+ public boolean createDir(String filepath, String name) {
+ DocumentsNode node = resolvePath(filepath);
+ if (node == null) return false;
+ if (!node.isDirectory) return false;
+ if (!node.loaded) structTree(node);
+ Uri mUri = node.uri;
+ try {
+ String filename = URLDecoder.decode(name, FileUtil.DECODE_METHOD);
+ if (node.children.get(filename) != null) return true;
+ DocumentFile createdDirectory = FileUtil.createDir(context, mUri.toString(), name);
+ if (createdDirectory == null) return false;
+ DocumentsNode document = new DocumentsNode(createdDirectory, true);
+ document.parent = node;
+ node.children.put(document.key, document);
+ return true;
+ } catch (Exception e) {
+ Log.error("[DocumentsTree]: Cannot create file, error: " + e.getMessage());
+ }
+ return false;
+ }
+
+ public int openContentUri(String filepath, String openmode) {
+ DocumentsNode node = resolvePath(filepath);
+ if (node == null) {
+ return -1;
+ }
+ return FileUtil.openContentUri(context, node.uri.toString(), openmode);
+ }
+
+ public String getFilename(String filepath) {
+ DocumentsNode node = resolvePath(filepath);
+ if (node == null) {
+ return "";
+ }
+ return node.name;
+ }
+
+ public String[] getFilesName(String filepath) {
+ DocumentsNode node = resolvePath(filepath);
+ if (node == null || !node.isDirectory) {
+ return new String[0];
+ }
+ // If this directory have not been iterate struct it.
+ if (!node.loaded) structTree(node);
+ return node.children.keySet().toArray(new String[0]);
+ }
+
+ public long getFileSize(String filepath) {
+ DocumentsNode node = resolvePath(filepath);
+ if (node == null || node.isDirectory) {
+ return 0;
+ }
+ return FileUtil.getFileSize(context, node.uri.toString());
+ }
+
+ public boolean isDirectory(String filepath) {
+ DocumentsNode node = resolvePath(filepath);
+ if (node == null) return false;
+ return node.isDirectory;
+ }
+
+ public boolean Exists(String filepath) {
+ return resolvePath(filepath) != null;
+ }
+
+ public boolean copyFile(String sourcePath, String destinationParentPath, String destinationFilename) {
+ DocumentsNode sourceNode = resolvePath(sourcePath);
+ if (sourceNode == null) return false;
+ DocumentsNode destinationNode = resolvePath(destinationParentPath);
+ if (destinationNode == null) return false;
+ try {
+ DocumentFile destinationParent = DocumentFile.fromTreeUri(context, destinationNode.uri);
+ if (destinationParent == null) return false;
+ String filename = URLDecoder.decode(destinationFilename, "UTF-8");
+ DocumentFile destination = destinationParent.createFile("application/octet-stream", filename);
+ if (destination == null) return false;
+ DocumentsNode document = new DocumentsNode();
+ document.uri = destination.getUri();
+ document.parent = destinationNode;
+ document.name = destination.getName();
+ document.isDirectory = destination.isDirectory();
+ document.loaded = true;
+ InputStream input = context.getContentResolver().openInputStream(sourceNode.uri);
+ OutputStream output = context.getContentResolver().openOutputStream(destination.getUri());
+ byte[] buffer = new byte[1024];
+ int len;
+ while ((len = input.read(buffer)) != -1) {
+ output.write(buffer, 0, len);
+ }
+ input.close();
+ output.flush();
+ output.close();
+ destinationNode.children.put(document.key, document);
+ return true;
+ } catch (Exception e) {
+ Log.error("[DocumentsTree]: Cannot copy file, error: " + e.getMessage());
+ }
+ return false;
+ }
+
+ public boolean renameFile(String filepath, String destinationFilename) {
+ DocumentsNode node = resolvePath(filepath);
+ if (node == null) return false;
+ try {
+ Uri mUri = node.uri;
+ String filename = URLDecoder.decode(destinationFilename, FileUtil.DECODE_METHOD);
+ DocumentsContract.renameDocument(context.getContentResolver(), mUri, filename);
+ node.rename(filename);
+ return true;
+ } catch (Exception e) {
+ Log.error("[DocumentsTree]: Cannot rename file, error: " + e.getMessage());
+ }
+ return false;
+ }
+
+ public boolean deleteDocument(String filepath) {
+ DocumentsNode node = resolvePath(filepath);
+ if (node == null) return false;
+ try {
+ Uri mUri = node.uri;
+ if (!DocumentsContract.deleteDocument(context.getContentResolver(), mUri)) {
+ return false;
+ }
+ if (node.parent != null) {
+ node.parent.children.remove(node.key);
+ }
+ return true;
+ } catch (Exception e) {
+ Log.error("[DocumentsTree]: Cannot rename file, error: " + e.getMessage());
+ }
+ return false;
+ }
+
+ @Nullable
+ private DocumentsNode resolvePath(String filepath) {
+ if (root == null)
+ return null;
+ StringTokenizer tokens = new StringTokenizer(filepath, DELIMITER, false);
+ DocumentsNode iterator = root;
+ while (tokens.hasMoreTokens()) {
+ String token = tokens.nextToken();
+ if (token.isEmpty()) continue;
+ iterator = find(iterator, token);
+ if (iterator == null) return null;
+ }
+ return iterator;
+ }
+
+ @Nullable
+ private DocumentsNode find(DocumentsNode parent, String filename) {
+ if (parent.isDirectory && !parent.loaded) {
+ structTree(parent);
+ }
+ return parent.children.get(filename);
+ }
+
+ /**
+ * Construct current level directory tree
+ *
+ * @param parent parent node of this level
+ */
+ private void structTree(DocumentsNode parent) {
+ CheapDocument[] documents = FileUtil.listFiles(context, parent.uri);
+ for (CheapDocument document : documents) {
+ DocumentsNode node = new DocumentsNode(document);
+ node.parent = parent;
+ parent.children.put(node.key, node);
+ }
+ parent.loaded = true;
+ }
+
+ private static class DocumentsNode {
+ private DocumentsNode parent;
+ private final Map children = new HashMap<>();
+ private String key;
+ private String name;
+ private Uri uri;
+ private boolean loaded = false;
+ private boolean isDirectory = false;
+
+ private DocumentsNode() {}
+
+ private DocumentsNode(CheapDocument document) {
+ name = document.getFilename();
+ uri = document.getUri();
+ key = FileUtil.getFilenameWithExtensions(uri);
+ isDirectory = document.isDirectory();
+ loaded = !isDirectory;
+ }
+
+ private DocumentsNode(DocumentFile document, boolean isCreateDir) {
+ name = document.getName();
+ uri = document.getUri();
+ key = FileUtil.getFilenameWithExtensions(uri);
+ isDirectory = isCreateDir;
+ loaded = true;
+ }
+
+ private void rename(String key) {
+ if (parent == null) {
+ return;
+ }
+ parent.children.remove(this.key);
+ this.name = key;
+ parent.children.put(key, this);
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/FileBrowserHelper.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/FileBrowserHelper.java
index baf691f5c..cbdc0742c 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/utils/FileBrowserHelper.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/FileBrowserHelper.java
@@ -1,71 +1,48 @@
package org.citra.citra_emu.utils;
+import android.content.ClipData;
+import android.content.Context;
import android.content.Intent;
import android.net.Uri;
-import android.os.Environment;
import androidx.annotation.Nullable;
-import androidx.fragment.app.FragmentActivity;
+import androidx.documentfile.provider.DocumentFile;
-import com.nononsenseapps.filepicker.FilePickerActivity;
-import com.nononsenseapps.filepicker.Utils;
-
-import org.citra.citra_emu.activities.CustomFilePickerActivity;
-
-import java.io.File;
+import java.util.ArrayList;
import java.util.List;
public final class FileBrowserHelper {
- public static void openDirectoryPicker(FragmentActivity activity, int requestCode, int title, List extensions) {
- Intent i = new Intent(activity, CustomFilePickerActivity.class);
-
- i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, false);
- i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, false);
- i.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_DIR);
- i.putExtra(FilePickerActivity.EXTRA_START_PATH,
- Environment.getExternalStorageDirectory().getPath());
- i.putExtra(CustomFilePickerActivity.EXTRA_TITLE, title);
- i.putExtra(CustomFilePickerActivity.EXTRA_EXTENSIONS, String.join(",", extensions));
-
- activity.startActivityForResult(i, requestCode);
- }
-
- public static void openFilePicker(FragmentActivity activity, int requestCode, int title,
- List extensions, boolean allowMultiple) {
- Intent i = new Intent(activity, CustomFilePickerActivity.class);
-
- i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, allowMultiple);
- i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, false);
- i.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_FILE);
- i.putExtra(FilePickerActivity.EXTRA_START_PATH,
- Environment.getExternalStorageDirectory().getPath());
- i.putExtra(CustomFilePickerActivity.EXTRA_TITLE, title);
- i.putExtra(CustomFilePickerActivity.EXTRA_EXTENSIONS, String.join(",", extensions));
-
- activity.startActivityForResult(i, requestCode);
- }
@Nullable
- public static String getSelectedDirectory(Intent result) {
- // Use the provided utility method to parse the result
- List files = Utils.getSelectedFilesFromResult(result);
- if (!files.isEmpty()) {
- File file = Utils.getFileForUri(files.get(0));
- return file.getAbsolutePath();
+ public static String[] getSelectedFiles(Intent result, Context context, List extension) {
+ ClipData clipData = result.getClipData();
+ List files = new ArrayList<>();
+ if (clipData == null) {
+ files.add(DocumentFile.fromSingleUri(context, result.getData()));
+ } else {
+ for (int i = 0; i < clipData.getItemCount(); i++) {
+ ClipData.Item item = clipData.getItemAt(i);
+ Uri uri = item.getUri();
+ files.add(DocumentFile.fromSingleUri(context, uri));
+ }
}
-
- return null;
- }
-
- @Nullable
- public static String[] getSelectedFiles(Intent result) {
- // Use the provided utility method to parse the result
- List files = Utils.getSelectedFilesFromResult(result);
if (!files.isEmpty()) {
- String[] paths = new String[files.size()];
- for (int i = 0; i < files.size(); i++)
- paths[i] = Utils.getFileForUri(files.get(i)).getAbsolutePath();
- return paths;
+ List filePaths = new ArrayList<>();
+ for (int i = 0; i < files.size(); i++) {
+ DocumentFile file = files.get(i);
+ String filename = file.getName();
+ int extensionStart = filename.lastIndexOf('.');
+ if (extensionStart > 0) {
+ String fileExtension = filename.substring(extensionStart + 1);
+ if (extension.contains(fileExtension)) {
+ filePaths.add(file.getUri().toString());
+ }
+ }
+ }
+ if (filePaths.isEmpty()) {
+ return null;
+ }
+ return filePaths.toArray(new String[0]);
}
return null;
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.java
index f9025171b..202621e11 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.java
@@ -1,11 +1,385 @@
package org.citra.citra_emu.utils;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.ParcelFileDescriptor;
+import android.provider.DocumentsContract;
+import android.system.Os;
+import android.system.StructStatVfs;
+import android.util.Pair;
+import androidx.annotation.Nullable;
+import androidx.documentfile.provider.DocumentFile;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URLDecoder;
+import java.util.ArrayList;
+import java.util.List;
+import org.citra.citra_emu.model.CheapDocument;
public class FileUtil {
+ static final String PATH_TREE = "tree";
+ static final String DECODE_METHOD = "UTF-8";
+ static final String APPLICATION_OCTET_STREAM = "application/octet-stream";
+ static final String TEXT_PLAIN = "text/plain";
+
+ public interface CopyDirListener {
+ void onSearchProgress(String directoryName);
+ void onCopyProgress(String filename, int progress, int max);
+
+ void onComplete();
+ }
+
+ /**
+ * Create a file from directory with filename.
+ *
+ * @param context Application context
+ * @param directory parent path for file.
+ * @param filename file display name.
+ * @return boolean
+ */
+ @Nullable
+ public static DocumentFile createFile(Context context, String directory, String filename) {
+ try {
+ Uri directoryUri = Uri.parse(directory);
+ DocumentFile parent;
+ parent = DocumentFile.fromTreeUri(context, directoryUri);
+ if (parent == null) return null;
+ filename = URLDecoder.decode(filename, DECODE_METHOD);
+ int extensionPosition = filename.lastIndexOf('.');
+ String extension = "";
+ if (extensionPosition > 0) {
+ extension = filename.substring(extensionPosition);
+ }
+ String mimeType = APPLICATION_OCTET_STREAM;
+ if (extension.equals(".txt")) {
+ mimeType = TEXT_PLAIN;
+ }
+ DocumentFile isExist = parent.findFile(filename);
+ if (isExist != null) return isExist;
+ return parent.createFile(mimeType, filename);
+ } catch (Exception e) {
+ Log.error("[FileUtil]: Cannot create file, error: " + e.getMessage());
+ }
+ return null;
+ }
+
+ /**
+ * Create a directory from directory with filename.
+ *
+ * @param context Application context
+ * @param directory parent path for directory.
+ * @param directoryName directory display name.
+ * @return boolean
+ */
+ @Nullable
+ public static DocumentFile createDir(Context context, String directory, String directoryName) {
+ try {
+ Uri directoryUri = Uri.parse(directory);
+ DocumentFile parent;
+ parent = DocumentFile.fromTreeUri(context, directoryUri);
+ if (parent == null) return null;
+ directoryName = URLDecoder.decode(directoryName, DECODE_METHOD);
+ DocumentFile isExist = parent.findFile(directoryName);
+ if (isExist != null) return isExist;
+ return parent.createDirectory(directoryName);
+ } catch (Exception e) {
+ Log.error("[FileUtil]: Cannot create file, error: " + e.getMessage());
+ }
+ return null;
+ }
+
+ /**
+ * Open content uri and return file descriptor to JNI.
+ *
+ * @param context Application context
+ * @param path Native content uri path
+ * @param openmode will be one of "r", "r", "rw", "wa", "rwa"
+ * @return file descriptor
+ */
+ public static int openContentUri(Context context, String path, String openmode) {
+ try (ParcelFileDescriptor parcelFileDescriptor =
+ context.getContentResolver().openFileDescriptor(Uri.parse(path), openmode)) {
+ if (parcelFileDescriptor == null) {
+ Log.error("[FileUtil]: Cannot get the file descriptor from uri: " + path);
+ return -1;
+ }
+ return parcelFileDescriptor.detachFd();
+ } catch (Exception e) {
+ Log.error("[FileUtil]: Cannot open content uri, error: " + e.getMessage());
+ }
+ return -1;
+ }
+
+ /**
+ * Reference: https://stackoverflow.com/questions/42186820/documentfile-is-very-slow
+ * This function will be faster than DocumentFile.listFiles
+ *
+ * @param context Application context
+ * @param uri Directory uri.
+ * @return CheapDocument lists.
+ */
+ public static CheapDocument[] listFiles(Context context, Uri uri) {
+ final ContentResolver resolver = context.getContentResolver();
+ final String[] columns = new String[]{
+ DocumentsContract.Document.COLUMN_DOCUMENT_ID,
+ DocumentsContract.Document.COLUMN_DISPLAY_NAME,
+ DocumentsContract.Document.COLUMN_MIME_TYPE,
+ };
+ Cursor c = null;
+ final List results = new ArrayList<>();
+ try {
+ String docId;
+ if (isRootTreeUri(uri)) {
+ docId = DocumentsContract.getTreeDocumentId(uri);
+ } else {
+ docId = DocumentsContract.getDocumentId(uri);
+ }
+ final Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, docId);
+ c = resolver.query(childrenUri, columns, null, null, null);
+ while (c.moveToNext()) {
+ final String documentId = c.getString(0);
+ final String documentName = c.getString(1);
+ final String documentMimeType = c.getString(2);
+ final Uri documentUri = DocumentsContract.buildDocumentUriUsingTree(uri, documentId);
+ CheapDocument document = new CheapDocument(documentName, documentMimeType, documentUri);
+ results.add(document);
+ }
+ } catch (Exception e) {
+ Log.error("[FileUtil]: Cannot list file error: " + e.getMessage());
+ } finally {
+ closeQuietly(c);
+ }
+ return results.toArray(new CheapDocument[0]);
+ }
+
+ /**
+ * Check whether given path exists.
+ *
+ * @param path Native content uri path
+ * @return bool
+ */
+ public static boolean Exists(Context context, String path) {
+ Cursor c = null;
+ try {
+ Uri mUri = Uri.parse(path);
+ final String[] columns = new String[] {DocumentsContract.Document.COLUMN_DOCUMENT_ID};
+ c = context.getContentResolver().query(mUri, columns, null, null, null);
+ return c.getCount() > 0;
+ } catch (Exception e) {
+ Log.info("[FileUtil] Cannot find file from given path, error: " + e.getMessage());
+ } finally {
+ closeQuietly(c);
+ }
+ return false;
+ }
+
+ /**
+ * Check whether given path is a directory
+ *
+ * @param path content uri path
+ * @return bool
+ */
+ public static boolean isDirectory(Context context, String path) {
+ final ContentResolver resolver = context.getContentResolver();
+ final String[] columns = new String[] {DocumentsContract.Document.COLUMN_MIME_TYPE};
+ boolean isDirectory = false;
+ Cursor c = null;
+ try {
+ Uri mUri = Uri.parse(path);
+ c = resolver.query(mUri, columns, null, null, null);
+ c.moveToNext();
+ final String mimeType = c.getString(0);
+ isDirectory = mimeType.equals(DocumentsContract.Document.MIME_TYPE_DIR);
+ } catch (Exception e) {
+ Log.error("[FileUtil]: Cannot list files, error: " + e.getMessage());
+ } finally {
+ closeQuietly(c);
+ }
+ return isDirectory;
+ }
+
+ /**
+ * Get file display name from given path
+ *
+ * @param path content uri path
+ * @return String display name
+ */
+ public static String getFilename(Context context, String path) {
+ final ContentResolver resolver = context.getContentResolver();
+ final String[] columns = new String[] {DocumentsContract.Document.COLUMN_DISPLAY_NAME};
+ String filename = "";
+ Cursor c = null;
+ try {
+ Uri mUri = Uri.parse(path);
+ c = resolver.query(mUri, columns, null, null, null);
+ c.moveToNext();
+ filename = c.getString(0);
+ } catch (Exception e) {
+ Log.error("[FileUtil]: Cannot get file size, error: " + e.getMessage());
+ } finally {
+ closeQuietly(c);
+ }
+ return filename;
+ }
+
+ public static String[] getFilesName(Context context, String path) {
+ Uri uri = Uri.parse(path);
+ List files = new ArrayList<>();
+ for (CheapDocument file : FileUtil.listFiles(context, uri)) {
+ files.add(file.getFilename());
+ }
+ return files.toArray(new String[0]);
+ }
+
+ /**
+ * Get file size from given path.
+ *
+ * @param path content uri path
+ * @return long file size
+ */
+ public static long getFileSize(Context context, String path) {
+ final ContentResolver resolver = context.getContentResolver();
+ final String[] columns = new String[] {DocumentsContract.Document.COLUMN_SIZE};
+ long size = 0;
+ Cursor c = null;
+ try {
+ Uri mUri = Uri.parse(path);
+ c = resolver.query(mUri, columns, null, null, null);
+ c.moveToNext();
+ size = c.getLong(0);
+ } catch (Exception e) {
+ Log.error("[FileUtil]: Cannot get file size, error: " + e.getMessage());
+ } finally {
+ closeQuietly(c);
+ }
+ return size;
+ }
+
+ public static boolean copyFile(Context context, String sourcePath, String destinationParentPath, String destinationFilename) {
+ try {
+ Uri sourceUri = Uri.parse(sourcePath);
+ Uri destinationUri = Uri.parse(destinationParentPath);
+ DocumentFile destinationParent = DocumentFile.fromTreeUri(context, destinationUri);
+ if (destinationParent == null) return false;
+ String filename = URLDecoder.decode(destinationFilename, "UTF-8");
+ DocumentFile destination = destinationParent.findFile(filename);
+ if (destination == null) {
+ destination = destinationParent.createFile("application/octet-stream", filename);
+ }
+ if (destination == null) return false;
+ InputStream input = context.getContentResolver().openInputStream(sourceUri);
+ OutputStream output = context.getContentResolver().openOutputStream(destination.getUri());
+ byte[] buffer = new byte[1024];
+ int len;
+ while ((len = input.read(buffer)) != -1) {
+ output.write(buffer, 0, len);
+ }
+ input.close();
+ output.flush();
+ output.close();
+ return true;
+ } catch (Exception e) {
+ Log.error("[FileUtil]: Cannot copy file, error: " + e.getMessage());
+ }
+ return false;
+ }
+
+ public static void copyDir(Context context, String sourcePath, String destinationPath,
+ CopyDirListener listener) {
+ try {
+ Uri sourceUri = Uri.parse(sourcePath);
+ Uri destinationUri = Uri.parse(destinationPath);
+ final List> files = new ArrayList<>();
+ final List> dirs = new ArrayList<>();
+ dirs.add(new Pair<>(sourceUri, destinationUri));
+ // Searching all files which need to be copied and struct the directory in destination.
+ while (!dirs.isEmpty()) {
+ DocumentFile fromDir = DocumentFile.fromTreeUri(context, dirs.get(0).first);
+ DocumentFile toDir = DocumentFile.fromTreeUri(context, dirs.get(0).second);
+ if (fromDir == null || toDir == null)
+ continue;
+ Uri fromUri = fromDir.getUri();
+ if (listener != null) {
+ listener.onSearchProgress(fromUri.getPath());
+ }
+ CheapDocument[] documents = FileUtil.listFiles(context, fromUri);
+ for (CheapDocument document : documents) {
+ String filename = document.getFilename();
+ if (document.isDirectory()) {
+ DocumentFile target = toDir.findFile(filename);
+ if (target == null || !target.exists()) {
+ target = toDir.createDirectory(filename);
+ }
+ if (target == null)
+ continue;
+ dirs.add(new Pair<>(document.getUri(), target.getUri()));
+ } else {
+ DocumentFile target = toDir.findFile(filename);
+ if (target == null || !target.exists()) {
+ target =
+ toDir.createFile(document.getMimeType(), document.getFilename());
+ }
+ if (target == null)
+ continue;
+ files.add(new Pair<>(document, target));
+ }
+ }
+
+ dirs.remove(0);
+ }
+
+ int total = files.size();
+ int progress = 0;
+ for (Pair file : files) {
+ DocumentFile to = file.second;
+ Uri toUri = to.getUri();
+ String filename = getFilenameWithExtensions(toUri);
+ String toPath = toUri.getPath();
+ DocumentFile toParent = to.getParentFile();
+ if (toParent == null)
+ continue;
+ FileUtil.copyFile(context, file.first.getUri().toString(),
+ toParent.getUri().toString(), filename);
+ progress++;
+ if (listener != null) {
+ listener.onCopyProgress(toPath, progress, total);
+ }
+ }
+ if (listener != null) {
+ listener.onComplete();
+ }
+ } catch (Exception e) {
+ Log.error("[FileUtil]: Cannot copy directory, error: " + e.getMessage());
+ }
+ }
+
+ public static boolean renameFile(Context context, String path, String destinationFilename) {
+ try {
+ Uri uri = Uri.parse(path);
+ DocumentsContract.renameDocument(context.getContentResolver(), uri, destinationFilename);
+ return true;
+ } catch (Exception e) {
+ Log.error("[FileUtil]: Cannot rename file, error: " + e.getMessage());
+ }
+ return false;
+ }
+
+ public static boolean deleteDocument(Context context, String path) {
+ try {
+ Uri uri = Uri.parse(path);
+ DocumentsContract.deleteDocument(context.getContentResolver(), uri);
+ return true;
+ } catch (Exception e) {
+ Log.error("[FileUtil]: Cannot delete document, error: " + e.getMessage());
+ }
+ return false;
+ }
+
public static byte[] getBytesFromFile(File file) throws IOException {
final long length = file.length();
@@ -21,8 +395,8 @@ public class FileUtil {
int numRead;
try (InputStream is = new FileInputStream(file)) {
- while (offset < bytes.length
- && (numRead = is.read(bytes, offset, bytes.length - offset)) >= 0) {
+ while (offset < bytes.length &&
+ (numRead = is.read(bytes, offset, bytes.length - offset)) >= 0) {
offset += numRead;
}
}
@@ -34,4 +408,53 @@ public class FileUtil {
return bytes;
}
+
+ public static boolean isRootTreeUri(Uri uri) {
+ final List paths = uri.getPathSegments();
+ return paths.size() == 2 && PATH_TREE.equals(paths.get(0));
+ }
+
+ public static boolean isNativePath(String path) {
+ try {
+ return path.charAt(0) == '/';
+ } catch (StringIndexOutOfBoundsException e) {
+ Log.error("[FileUtil] Cannot determine the string is native path or not.");
+ }
+ return false;
+ }
+
+ public static String getFilenameWithExtensions(Uri uri) {
+ final String path = uri.getPath();
+ final int index = path.lastIndexOf('/');
+ return path.substring(index + 1);
+ }
+
+ public static double getFreeSpace(Context context, Uri uri) {
+ try {
+ Uri docTreeUri = DocumentsContract.buildDocumentUriUsingTree(
+ uri, DocumentsContract.getTreeDocumentId(uri));
+ ParcelFileDescriptor pfd =
+ context.getContentResolver().openFileDescriptor(docTreeUri, "r");
+ assert pfd != null;
+ StructStatVfs stats = Os.fstatvfs(pfd.getFileDescriptor());
+ double spaceInGigaBytes = stats.f_bavail * stats.f_bsize / 1024.0 / 1024 / 1024;
+ pfd.close();
+ return spaceInGigaBytes;
+ } catch (Exception e) {
+ Log.error("[FileUtil] Cannot get storage size.");
+ }
+
+ return 0;
+ }
+
+ public static void closeQuietly(AutoCloseable closeable) {
+ if (closeable != null) {
+ try {
+ closeable.close();
+ } catch (RuntimeException rethrown) {
+ throw rethrown;
+ } catch (Exception ignored) {
+ }
+ }
+ }
}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/ForegroundService.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/ForegroundService.java
index bc256877b..021179ab1 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/utils/ForegroundService.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/ForegroundService.java
@@ -27,7 +27,7 @@ public class ForegroundService extends Service {
private void showRunningNotification() {
// Intent is used to resume emulation if the notification is clicked
PendingIntent contentIntent = PendingIntent.getActivity(this, 0,
- new Intent(this, EmulationActivity.class), PendingIntent.FLAG_UPDATE_CURRENT);
+ new Intent(this, EmulationActivity.class), PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, getString(R.string.app_notification_channel_id))
.setSmallIcon(R.drawable.ic_stat_notification_logo)
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconRequestHandler.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconRequestHandler.java
index b790c2480..6ebe70161 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconRequestHandler.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconRequestHandler.java
@@ -13,12 +13,12 @@ import java.nio.IntBuffer;
public class GameIconRequestHandler extends RequestHandler {
@Override
public boolean canHandleRequest(Request data) {
- return "iso".equals(data.uri.getScheme());
+ return "content".equals(data.uri.getScheme()) || data.uri.getScheme() == null;
}
@Override
public Result load(Request request, int networkPolicy) {
- String url = request.uri.getHost() + request.uri.getPath();
+ String url = request.uri.toString();
int[] vector = NativeLibrary.GetIcon(url);
Bitmap bitmap = Bitmap.createBitmap(48, 48, Bitmap.Config.RGB_565);
bitmap.copyPixelsFromBuffer(IntBuffer.wrap(vector));
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.java
index a29e23e8d..6cbe19b76 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.java
@@ -1,28 +1,32 @@
package org.citra.citra_emu.utils;
-import android.annotation.TargetApi;
import android.content.Context;
-import android.content.pm.PackageManager;
-import android.os.Build;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.net.Uri;
+import android.preference.PreferenceManager;
-import androidx.core.content.ContextCompat;
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.annotation.Nullable;
+import androidx.documentfile.provider.DocumentFile;
import androidx.fragment.app.FragmentActivity;
-import static android.Manifest.permission.WRITE_EXTERNAL_STORAGE;
+import org.citra.citra_emu.CitraApplication;
+import org.citra.citra_emu.R;
public class PermissionsHandler {
- public static final int REQUEST_CODE_WRITE_PERMISSION = 500;
+ public static final String CITRA_DIRECTORY = "CITRA_DIRECTORY";
+ public static final SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext());
// We use permissions acceptance as an indicator if this is a first boot for the user.
- public static boolean isFirstBoot(final FragmentActivity activity) {
- return ContextCompat.checkSelfPermission(activity, WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED;
+ public static boolean isFirstBoot(FragmentActivity activity) {
+ return !hasWriteAccess(activity.getApplicationContext());
}
- @TargetApi(Build.VERSION_CODES.M)
- public static boolean checkWritePermission(final FragmentActivity activity) {
+ public static boolean checkWritePermission(FragmentActivity activity,
+ ActivityResultLauncher launcher) {
if (isFirstBoot(activity)) {
- activity.requestPermissions(new String[]{WRITE_EXTERNAL_STORAGE},
- REQUEST_CODE_WRITE_PERMISSION);
+ launcher.launch(null);
return false;
}
@@ -30,6 +34,31 @@ public class PermissionsHandler {
}
public static boolean hasWriteAccess(Context context) {
- return ContextCompat.checkSelfPermission(context, WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED;
+ try {
+ Uri uri = getCitraDirectory();
+ if (uri == null)
+ return false;
+ int takeFlags = (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
+ context.getContentResolver().takePersistableUriPermission(uri, takeFlags);
+ DocumentFile root = DocumentFile.fromTreeUri(context, uri);
+ if (root != null && root.exists()) return true;
+ context.getContentResolver().releasePersistableUriPermission(uri, takeFlags);
+ } catch (Exception e) {
+ Log.error("[PermissionsHandler]: Cannot check citra data directory permission, error: " + e.getMessage());
+ }
+ return false;
+ }
+
+ @Nullable
+ public static Uri getCitraDirectory() {
+ String directoryString = mPreferences.getString(CITRA_DIRECTORY, "");
+ if (directoryString.isEmpty()) {
+ return null;
+ }
+ return Uri.parse(directoryString);
+ }
+
+ public static boolean setCitraDirectory(String uriString) {
+ return mPreferences.edit().putString(CITRA_DIRECTORY, uriString).commit();
}
}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoUtils.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoUtils.java
index c99726685..65d6d4a88 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoUtils.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoUtils.java
@@ -31,7 +31,7 @@ public class PicassoUtils {
public static void loadGameIcon(ImageView imageView, String gamePath) {
Picasso
.get()
- .load(Uri.parse("iso:/" + gamePath))
+ .load(Uri.parse(gamePath))
.fit()
.centerInside()
.config(Bitmap.Config.RGB_565)
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/StartupHandler.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/StartupHandler.java
index 56820eb33..5e52529d3 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/utils/StartupHandler.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/StartupHandler.java
@@ -1,21 +1,23 @@
package org.citra.citra_emu.utils;
import android.content.Intent;
+import android.net.Uri;
import android.os.Bundle;
import android.text.TextUtils;
-
+import android.text.method.LinkMovementMethod;
+import android.widget.TextView;
+import androidx.activity.result.ActivityResultLauncher;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.FragmentActivity;
-
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
-
import org.citra.citra_emu.R;
import org.citra.citra_emu.activities.EmulationActivity;
public final class StartupHandler {
- private static void handlePermissionsCheck(FragmentActivity parent) {
+ private static void handlePermissionsCheck(FragmentActivity parent,
+ ActivityResultLauncher launcher) {
// Ask the user to grant write permission if it's not already granted
- PermissionsHandler.checkWritePermission(parent);
+ PermissionsHandler.checkWritePermission(parent, launcher);
String start_file = "";
Bundle extras = parent.getIntent().getExtras();
@@ -32,16 +34,23 @@ public final class StartupHandler {
}
}
- public static void HandleInit(FragmentActivity parent) {
+ public static void HandleInit(FragmentActivity parent, ActivityResultLauncher launcher) {
if (PermissionsHandler.isFirstBoot(parent)) {
// Prompt user with standard first boot disclaimer
- new MaterialAlertDialogBuilder(parent)
+ AlertDialog dialog =
+ new MaterialAlertDialogBuilder(parent)
.setTitle(R.string.app_name)
.setIcon(R.mipmap.ic_launcher)
- .setMessage(parent.getResources().getString(R.string.app_disclaimer))
+ .setMessage(R.string.app_disclaimer)
.setPositiveButton(android.R.string.ok, null)
- .setOnDismissListener(dialogInterface -> handlePermissionsCheck(parent))
+ .setCancelable(false)
+ .setOnDismissListener(
+ dialogInterface -> handlePermissionsCheck(parent, launcher))
.show();
+ TextView textView = dialog.findViewById(android.R.id.message);
+ if (textView == null)
+ return;
+ textView.setMovementMethod(LinkMovementMethod.getInstance());
}
}
}
diff --git a/src/android/app/src/main/jni/config.cpp b/src/android/app/src/main/jni/config.cpp
index 768152c77..019dcee5d 100644
--- a/src/android/app/src/main/jni/config.cpp
+++ b/src/android/app/src/main/jni/config.cpp
@@ -26,7 +26,11 @@
Config::Config() {
// TODO: Don't hardcode the path; let the frontend decide where to put the config files.
sdl2_config_loc = FileUtil::GetUserPath(FileUtil::UserPath::ConfigDir) + "config.ini";
- sdl2_config = std::make_unique(sdl2_config_loc);
+ std::string ini_buffer;
+ FileUtil::ReadFileToString(true, sdl2_config_loc, ini_buffer);
+ if (!ini_buffer.empty()) {
+ sdl2_config = std::make_unique(ini_buffer.c_str(), ini_buffer.size());
+ }
Reload();
}
@@ -35,12 +39,15 @@ Config::~Config() = default;
bool Config::LoadINI(const std::string& default_contents, bool retry) {
const std::string& location = this->sdl2_config_loc;
- if (sdl2_config->ParseError() < 0) {
+ if (sdl2_config == nullptr || sdl2_config->ParseError() < 0) {
if (retry) {
LOG_WARNING(Config, "Failed to load {}. Creating file from defaults...", location);
FileUtil::CreateFullPath(location);
FileUtil::WriteStringToFile(true, location, default_contents);
- sdl2_config = std::make_unique(location); // Reopen file
+ std::string ini_buffer;
+ FileUtil::ReadFileToString(true, location, ini_buffer);
+ sdl2_config =
+ std::make_unique(ini_buffer.c_str(), ini_buffer.size()); // Reopen file
return LoadINI(default_contents, false);
}
diff --git a/src/android/app/src/main/jni/id_cache.cpp b/src/android/app/src/main/jni/id_cache.cpp
index b1b372923..255e1ae0a 100644
--- a/src/android/app/src/main/jni/id_cache.cpp
+++ b/src/android/app/src/main/jni/id_cache.cpp
@@ -2,6 +2,7 @@
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
+#include "common/android_storage.h"
#include "common/common_paths.h"
#include "common/logging/backend.h"
#include "common/logging/filter.h"
@@ -159,10 +160,6 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) {
log_filter.ParseFilterString(Settings::values.log_filter.GetValue());
Log::SetGlobalFilter(log_filter);
Log::AddBackend(std::make_unique());
- FileUtil::CreateFullPath(FileUtil::GetUserPath(FileUtil::UserPath::LogDir));
- Log::AddBackend(std::make_unique(
- FileUtil::GetUserPath(FileUtil::UserPath::LogDir) + LOG_FILE));
- LOG_INFO(Frontend, "Logging backend initialised");
// Initialize misc classes
s_savestate_info_class = reinterpret_cast(
@@ -230,6 +227,7 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) {
MiiSelector::InitJNI(env);
SoftwareKeyboard::InitJNI(env);
Camera::StillImage::InitJNI(env);
+ AndroidStorage::InitJNI(env, s_native_library_class);
return JNI_VERSION;
}
@@ -254,6 +252,7 @@ void JNI_OnUnload(JavaVM* vm, void* reserved) {
MiiSelector::CleanupJNI(env);
SoftwareKeyboard::CleanupJNI(env);
Camera::StillImage::CleanupJNI(env);
+ AndroidStorage::CleanupJNI();
}
#ifdef __cplusplus
diff --git a/src/android/app/src/main/jni/lodepng_image_interface.cpp b/src/android/app/src/main/jni/lodepng_image_interface.cpp
index e42c3a82c..6bfcb81f3 100644
--- a/src/android/app/src/main/jni/lodepng_image_interface.cpp
+++ b/src/android/app/src/main/jni/lodepng_image_interface.cpp
@@ -3,12 +3,19 @@
// Refer to the license.txt file included.
#include
+#include "common/file_util.h"
#include "common/logging/log.h"
#include "jni/lodepng_image_interface.h"
bool LodePNGImageInterface::DecodePNG(std::vector& dst, u32& width, u32& height,
const std::string& path) {
- u32 lodepng_ret = lodepng::decode(dst, width, height, path);
+ FileUtil::IOFile file(path, "rb");
+ size_t read_size = file.GetSize();
+ std::vector in(read_size);
+ if (file.ReadBytes(&in[0], read_size) != read_size) {
+ LOG_CRITICAL(Frontend, "Failed to decode {}", path);
+ }
+ u32 lodepng_ret = lodepng::decode(dst, width, height, in);
if (lodepng_ret) {
LOG_CRITICAL(Frontend, "Failed to decode {} because {}", path,
lodepng_error_text(lodepng_ret));
@@ -19,11 +26,19 @@ bool LodePNGImageInterface::DecodePNG(std::vector& dst, u32& width, u32& hei
bool LodePNGImageInterface::EncodePNG(const std::string& path, const std::vector& src,
u32 width, u32 height) {
- u32 lodepng_ret = lodepng::encode(path, src, width, height);
+ std::vector out;
+ u32 lodepng_ret = lodepng::encode(out, src, width, height);
if (lodepng_ret) {
LOG_CRITICAL(Frontend, "Failed to encode {} because {}", path,
lodepng_error_text(lodepng_ret));
return false;
}
+
+ FileUtil::IOFile file(path, "wb");
+ if (file.WriteBytes(&out[0], out.size()) != out.size()) {
+ LOG_CRITICAL(Frontend, "Failed to save encode to path={}", path);
+ return false;
+ }
+
return true;
}
diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp
index 73e019e46..a91971a15 100644
--- a/src/android/app/src/main/jni/native.cpp
+++ b/src/android/app/src/main/jni/native.cpp
@@ -12,7 +12,9 @@
#include "audio_core/dsp_interface.h"
#include "common/aarch64/cpu_detect.h"
+#include "common/common_paths.h"
#include "common/file_util.h"
+#include "common/logging/backend.h"
#include "common/logging/log.h"
#include "common/microprofile.h"
#include "common/scm_rev.h"
@@ -319,6 +321,8 @@ jobjectArray Java_org_citra_citra_1emu_NativeLibrary_GetInstalledGamePaths(
path += '/';
FileUtil::ForeachDirectoryEntry(nullptr, path, ScanDir);
} else {
+ if (!FileUtil::Exists(path))
+ return false;
auto loader = Loader::GetLoader(path);
if (loader) {
bool executable{};
@@ -492,6 +496,23 @@ void Java_org_citra_citra_1emu_NativeLibrary_CreateConfigFile(JNIEnv* env,
Config{};
}
+void Java_org_citra_citra_1emu_NativeLibrary_CreateLogFile(JNIEnv* env,
+ [[maybe_unused]] jclass clazz) {
+ Log::RemoveBackend(Log::FileBackend::Name());
+ FileUtil::CreateFullPath(FileUtil::GetUserPath(FileUtil::UserPath::LogDir));
+ Log::AddBackend(std::make_unique(
+ FileUtil::GetUserPath(FileUtil::UserPath::LogDir) + LOG_FILE));
+ LOG_INFO(Frontend, "Logging backend initialised");
+}
+
+void Java_org_citra_citra_1emu_NativeLibrary_LogUserDirectory(JNIEnv* env,
+ [[maybe_unused]] jclass clazz,
+ jstring j_path) {
+ std::string_view path = env->GetStringUTFChars(j_path, 0);
+ LOG_INFO(Frontend, "User directory path: {}", path);
+ env->ReleaseStringUTFChars(j_path, path.data());
+}
+
jint Java_org_citra_citra_1emu_NativeLibrary_DefaultCPUCore(JNIEnv* env,
[[maybe_unused]] jclass clazz) {
return 0;
diff --git a/src/android/app/src/main/jni/native.h b/src/android/app/src/main/jni/native.h
index 52bfeeecb..184b46145 100644
--- a/src/android/app/src/main/jni/native.h
+++ b/src/android/app/src/main/jni/native.h
@@ -83,6 +83,13 @@ JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_SetSysDirectory(J
JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_CreateConfigFile(JNIEnv* env,
jclass clazz);
+JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_CreateLogFile(JNIEnv* env,
+ jclass clazz);
+
+JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_LogUserDirectory(JNIEnv* env,
+ jclass clazz,
+ jstring path);
+
JNIEXPORT jint JNICALL Java_org_citra_citra_1emu_NativeLibrary_DefaultCPUCore(JNIEnv* env,
jclass clazz);
JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_SetProfiling(JNIEnv* env,
diff --git a/src/android/app/src/main/res/layout/dialog_citra_directory.xml b/src/android/app/src/main/res/layout/dialog_citra_directory.xml
new file mode 100644
index 000000000..d2f251e44
--- /dev/null
+++ b/src/android/app/src/main/res/layout/dialog_citra_directory.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/layout/filepicker_toolbar.xml b/src/android/app/src/main/res/layout/filepicker_toolbar.xml
deleted file mode 100644
index 644934171..000000000
--- a/src/android/app/src/main/res/layout/filepicker_toolbar.xml
+++ /dev/null
@@ -1,32 +0,0 @@
-
-
-
-
-
-
-
-
-
-
diff --git a/src/android/app/src/main/res/menu/menu_game_grid.xml b/src/android/app/src/main/res/menu/menu_game_grid.xml
index 1a4bee6df..e7fe944d6 100644
--- a/src/android/app/src/main/res/menu/menu_game_grid.xml
+++ b/src/android/app/src/main/res/menu/menu_game_grid.xml
@@ -13,6 +13,11 @@
android:title="@string/select_game_folder"
app:showAsAction="ifRoom">