Merge pull request #9318 from JosJuice/android-saf-games
Android: Use storage access framework for game list
This commit is contained in:
commit
c1d041b888
|
@ -45,6 +45,7 @@ import org.dolphinemu.dolphinemu.fragments.SaveLoadStateFragment;
|
|||
import org.dolphinemu.dolphinemu.overlay.InputOverlay;
|
||||
import org.dolphinemu.dolphinemu.overlay.InputOverlayPointer;
|
||||
import org.dolphinemu.dolphinemu.ui.main.MainActivity;
|
||||
import org.dolphinemu.dolphinemu.ui.main.MainPresenter;
|
||||
import org.dolphinemu.dolphinemu.ui.main.TvMainActivity;
|
||||
import org.dolphinemu.dolphinemu.utils.AfterDirectoryInitializationRunner;
|
||||
import org.dolphinemu.dolphinemu.utils.ControllerMappingHelper;
|
||||
|
@ -164,6 +165,11 @@ public final class EmulationActivity extends AppCompatActivity
|
|||
EmulationActivity.MENU_ACTION_MOTION_CONTROLS);
|
||||
}
|
||||
|
||||
public static void launch(FragmentActivity activity, String filePath)
|
||||
{
|
||||
launch(activity, new String[]{filePath});
|
||||
}
|
||||
|
||||
public static void launch(FragmentActivity activity, String[] filePaths)
|
||||
{
|
||||
if (sIgnoreLaunchRequests)
|
||||
|
@ -410,11 +416,7 @@ public final class EmulationActivity extends AppCompatActivity
|
|||
// If the user picked a file, as opposed to just backing out.
|
||||
if (resultCode == MainActivity.RESULT_OK)
|
||||
{
|
||||
String newDiscPath = FileBrowserHelper.getSelectedPath(result);
|
||||
if (!TextUtils.isEmpty(newDiscPath))
|
||||
{
|
||||
NativeLibrary.ChangeDisc(newDiscPath);
|
||||
}
|
||||
NativeLibrary.ChangeDisc(result.getData().toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -639,8 +641,10 @@ public final class EmulationActivity extends AppCompatActivity
|
|||
break;
|
||||
|
||||
case MENU_ACTION_CHANGE_DISC:
|
||||
FileBrowserHelper.openFilePicker(this, REQUEST_CHANGE_DISC, false,
|
||||
FileBrowserHelper.GAME_EXTENSIONS);
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("*/*");
|
||||
startActivityForResult(intent, REQUEST_CHANGE_DISC);
|
||||
break;
|
||||
|
||||
case MENU_SET_IR_SENSITIVITY:
|
||||
|
|
|
@ -25,6 +25,8 @@ import org.dolphinemu.dolphinemu.ui.main.TvMainActivity;
|
|||
import org.dolphinemu.dolphinemu.utils.FileBrowserHelper;
|
||||
import org.dolphinemu.dolphinemu.utils.TvUtil;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
public final class SettingsActivity extends AppCompatActivity implements SettingsActivityView
|
||||
{
|
||||
private static final String ARG_MENU_TAG = "menu_tag";
|
||||
|
@ -179,13 +181,19 @@ public final class SettingsActivity extends AppCompatActivity implements Setting
|
|||
// If the user picked a file, as opposed to just backing out.
|
||||
if (resultCode == MainActivity.RESULT_OK)
|
||||
{
|
||||
if (requestCode == MainPresenter.REQUEST_SD_FILE)
|
||||
if (requestCode != MainPresenter.REQUEST_DIRECTORY)
|
||||
{
|
||||
Uri uri = canonicalizeIfPossible(result.getData());
|
||||
int takeFlags = result.getFlags() &
|
||||
(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
|
||||
|
||||
FileBrowserHelper.runAfterExtensionCheck(this, uri, FileBrowserHelper.RAW_EXTENSION, () ->
|
||||
Set<String> validExtensions = requestCode == MainPresenter.REQUEST_GAME_FILE ?
|
||||
FileBrowserHelper.GAME_EXTENSIONS : FileBrowserHelper.RAW_EXTENSION;
|
||||
|
||||
int flags = Intent.FLAG_GRANT_READ_URI_PERMISSION;
|
||||
if (requestCode != MainPresenter.REQUEST_GAME_FILE)
|
||||
flags |= Intent.FLAG_GRANT_WRITE_URI_PERMISSION;
|
||||
int takeFlags = flags & result.getFlags();
|
||||
|
||||
FileBrowserHelper.runAfterExtensionCheck(this, uri, validExtensions, () ->
|
||||
{
|
||||
getContentResolver().takePersistableUriPermission(uri, takeFlags);
|
||||
getFragment().getAdapter().onFilePickerConfirmation(uri.toString());
|
||||
|
|
|
@ -306,28 +306,17 @@ public final class SettingsAdapter extends RecyclerView.Adapter<SettingViewHolde
|
|||
mClickedPosition = position;
|
||||
FilePicker filePicker = (FilePicker) item;
|
||||
|
||||
switch (filePicker.getRequestType())
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("*/*");
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
{
|
||||
case MainPresenter.REQUEST_SD_FILE:
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("*/*");
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
{
|
||||
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI,
|
||||
filePicker.getSelectedValue(mView.getSettings()));
|
||||
}
|
||||
|
||||
mView.getActivity().startActivityForResult(intent, filePicker.getRequestType());
|
||||
break;
|
||||
case MainPresenter.REQUEST_GAME_FILE:
|
||||
FileBrowserHelper.openFilePicker(mView.getActivity(), filePicker.getRequestType(), false,
|
||||
FileBrowserHelper.GAME_EXTENSIONS);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidParameterException("Unhandled request code");
|
||||
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI,
|
||||
filePicker.getSelectedValue(mView.getSettings()));
|
||||
}
|
||||
|
||||
mView.getActivity().startActivityForResult(intent, filePicker.getRequestType());
|
||||
}
|
||||
|
||||
public void onFilePickerConfirmation(String selectedFile)
|
||||
|
|
|
@ -16,6 +16,9 @@ import java.io.File;
|
|||
*/
|
||||
public final class SettingsFile
|
||||
{
|
||||
public static final String KEY_ISO_PATH_BASE = "ISOPath";
|
||||
public static final String KEY_ISO_PATHS = "ISOPaths";
|
||||
|
||||
public static final String KEY_GCPAD_TYPE = "SIDevice";
|
||||
public static final String KEY_GCPAD_PLAYER_1 = "SIDevice0";
|
||||
public static final String KEY_GCPAD_G_TYPE = "PadType";
|
||||
|
|
|
@ -1,22 +1,19 @@
|
|||
package org.dolphinemu.dolphinemu.model;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.preference.PreferenceManager;
|
||||
|
||||
import androidx.annotation.Keep;
|
||||
|
||||
import org.dolphinemu.dolphinemu.NativeLibrary;
|
||||
import org.dolphinemu.dolphinemu.features.settings.model.BooleanSetting;
|
||||
import org.dolphinemu.dolphinemu.features.settings.model.Settings;
|
||||
import org.dolphinemu.dolphinemu.features.settings.utils.SettingsFile;
|
||||
import org.dolphinemu.dolphinemu.utils.ContentHandler;
|
||||
import org.dolphinemu.dolphinemu.utils.IniFile;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.LinkedHashSet;
|
||||
|
||||
public class GameFileCache
|
||||
{
|
||||
private static final String GAME_FOLDER_PATHS_PREFERENCE = "gameFolderPaths";
|
||||
private static final Set<String> EMPTY_SET = new HashSet<>();
|
||||
|
||||
@Keep
|
||||
private long mPointer;
|
||||
|
||||
|
@ -30,50 +27,71 @@ public class GameFileCache
|
|||
@Override
|
||||
public native void finalize();
|
||||
|
||||
public static void addGameFolder(String path, Context context)
|
||||
public static void addGameFolder(String path)
|
||||
{
|
||||
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
Set<String> folderPaths = preferences.getStringSet(GAME_FOLDER_PATHS_PREFERENCE, EMPTY_SET);
|
||||
File dolphinFile = SettingsFile.getSettingsFile(Settings.FILE_DOLPHIN);
|
||||
IniFile dolphinIni = new IniFile(dolphinFile);
|
||||
LinkedHashSet<String> pathSet = getPathSet(false);
|
||||
int totalISOPaths =
|
||||
dolphinIni.getInt(Settings.SECTION_INI_GENERAL, SettingsFile.KEY_ISO_PATHS, 0);
|
||||
|
||||
if (folderPaths == null)
|
||||
if (!pathSet.contains(path))
|
||||
{
|
||||
return;
|
||||
dolphinIni.setInt(Settings.SECTION_INI_GENERAL, SettingsFile.KEY_ISO_PATHS,
|
||||
totalISOPaths + 1);
|
||||
dolphinIni.setString(Settings.SECTION_INI_GENERAL, SettingsFile.KEY_ISO_PATH_BASE +
|
||||
totalISOPaths, path);
|
||||
dolphinIni.save(dolphinFile);
|
||||
NativeLibrary.ReloadConfig();
|
||||
}
|
||||
|
||||
Set<String> newFolderPaths = new HashSet<>(folderPaths);
|
||||
newFolderPaths.add(path);
|
||||
SharedPreferences.Editor editor = preferences.edit();
|
||||
editor.putStringSet(GAME_FOLDER_PATHS_PREFERENCE, newFolderPaths);
|
||||
editor.apply();
|
||||
}
|
||||
|
||||
private void removeNonExistentGameFolders(Context context)
|
||||
private static LinkedHashSet<String> getPathSet(boolean removeNonExistentFolders)
|
||||
{
|
||||
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
Set<String> folderPaths = preferences.getStringSet(GAME_FOLDER_PATHS_PREFERENCE, EMPTY_SET);
|
||||
File dolphinFile = SettingsFile.getSettingsFile(Settings.FILE_DOLPHIN);
|
||||
IniFile dolphinIni = new IniFile(dolphinFile);
|
||||
LinkedHashSet<String> pathSet = new LinkedHashSet<>();
|
||||
int totalISOPaths =
|
||||
dolphinIni.getInt(Settings.SECTION_INI_GENERAL, SettingsFile.KEY_ISO_PATHS, 0);
|
||||
|
||||
if (folderPaths == null)
|
||||
for (int i = 0; i < totalISOPaths; i++)
|
||||
{
|
||||
return;
|
||||
}
|
||||
String path = dolphinIni.getString(Settings.SECTION_INI_GENERAL,
|
||||
SettingsFile.KEY_ISO_PATH_BASE + i, "");
|
||||
|
||||
Set<String> newFolderPaths = new HashSet<>();
|
||||
for (String folderPath : folderPaths)
|
||||
{
|
||||
File folder = new File(folderPath);
|
||||
if (folder.exists())
|
||||
if (path.startsWith("content://") ? ContentHandler.exists(path) : new File(path).exists())
|
||||
{
|
||||
newFolderPaths.add(folderPath);
|
||||
pathSet.add(path);
|
||||
}
|
||||
}
|
||||
|
||||
if (folderPaths.size() != newFolderPaths.size())
|
||||
if (removeNonExistentFolders && totalISOPaths > pathSet.size())
|
||||
{
|
||||
// One or more folders are being deleted
|
||||
SharedPreferences.Editor editor = preferences.edit();
|
||||
editor.putStringSet(GAME_FOLDER_PATHS_PREFERENCE, newFolderPaths);
|
||||
editor.apply();
|
||||
int setIndex = 0;
|
||||
|
||||
dolphinIni.setInt(Settings.SECTION_INI_GENERAL, SettingsFile.KEY_ISO_PATHS,
|
||||
pathSet.size());
|
||||
|
||||
// One or more folders have been removed.
|
||||
for (String entry : pathSet)
|
||||
{
|
||||
dolphinIni.setString(Settings.SECTION_INI_GENERAL, SettingsFile.KEY_ISO_PATH_BASE +
|
||||
setIndex, entry);
|
||||
|
||||
setIndex++;
|
||||
}
|
||||
|
||||
// Delete known unnecessary keys. Ignore i values beyond totalISOPaths.
|
||||
for (int i = setIndex; i < totalISOPaths; i++)
|
||||
{
|
||||
dolphinIni.deleteKey(Settings.SECTION_INI_GENERAL, SettingsFile.KEY_ISO_PATH_BASE + i);
|
||||
}
|
||||
|
||||
dolphinIni.save(dolphinFile);
|
||||
NativeLibrary.ReloadConfig();
|
||||
}
|
||||
|
||||
return pathSet;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -81,19 +99,11 @@ public class GameFileCache
|
|||
*
|
||||
* @return true if the cache was modified
|
||||
*/
|
||||
public boolean scanLibrary(Context context)
|
||||
public boolean scanLibrary()
|
||||
{
|
||||
boolean recursiveScan = BooleanSetting.MAIN_RECURSIVE_ISO_PATHS.getBooleanGlobal();
|
||||
|
||||
removeNonExistentGameFolders(context);
|
||||
|
||||
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
Set<String> folderPathsSet = preferences.getStringSet(GAME_FOLDER_PATHS_PREFERENCE, EMPTY_SET);
|
||||
|
||||
if (folderPathsSet == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
LinkedHashSet<String> folderPathsSet = getPathSet(true);
|
||||
|
||||
String[] folderPaths = folderPathsSet.toArray(new String[0]);
|
||||
|
||||
|
|
|
@ -29,9 +29,10 @@ public final class GameFileCacheService extends IntentService
|
|||
private static final String ACTION_RESCAN = "org.dolphinemu.dolphinemu.RESCAN_GAME_FILE_CACHE";
|
||||
|
||||
private static GameFileCache gameFileCache = null;
|
||||
private static AtomicReference<GameFile[]> gameFiles = new AtomicReference<>(new GameFile[]{});
|
||||
private static AtomicBoolean hasLoadedCache = new AtomicBoolean(false);
|
||||
private static AtomicBoolean hasScannedLibrary = new AtomicBoolean(false);
|
||||
private static final AtomicReference<GameFile[]> gameFiles =
|
||||
new AtomicReference<>(new GameFile[]{});
|
||||
private static final AtomicBoolean hasLoadedCache = new AtomicBoolean(false);
|
||||
private static final AtomicBoolean hasScannedLibrary = new AtomicBoolean(false);
|
||||
|
||||
public GameFileCacheService()
|
||||
{
|
||||
|
@ -166,7 +167,7 @@ public final class GameFileCacheService extends IntentService
|
|||
{
|
||||
synchronized (gameFileCache)
|
||||
{
|
||||
boolean changed = gameFileCache.scanLibrary(this);
|
||||
boolean changed = gameFileCache.scanLibrary();
|
||||
if (changed)
|
||||
updateGameFileArray();
|
||||
hasScannedLibrary.set(true);
|
||||
|
|
|
@ -2,6 +2,7 @@ package org.dolphinemu.dolphinemu.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;
|
||||
|
@ -45,7 +46,7 @@ public final class MainActivity extends AppCompatActivity implements MainView
|
|||
private FloatingActionButton mFab;
|
||||
private static boolean sShouldRescanLibrary = true;
|
||||
|
||||
private MainPresenter mPresenter = new MainPresenter(this, this);
|
||||
private final MainPresenter mPresenter = new MainPresenter(this, this);
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState)
|
||||
|
@ -85,7 +86,7 @@ public final class MainActivity extends AppCompatActivity implements MainView
|
|||
.run(this, false, this::setPlatformTabsAndStartGameFileCacheService);
|
||||
}
|
||||
|
||||
mPresenter.addDirIfNeeded(this);
|
||||
mPresenter.addDirIfNeeded();
|
||||
|
||||
// In case the user changed a setting that affects how games are displayed,
|
||||
// such as system language, cover downloading...
|
||||
|
@ -162,14 +163,17 @@ public final class MainActivity extends AppCompatActivity implements MainView
|
|||
@Override
|
||||
public void launchFileListActivity()
|
||||
{
|
||||
FileBrowserHelper.openDirectoryPicker(this, FileBrowserHelper.GAME_EXTENSIONS);
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
|
||||
startActivityForResult(intent, MainPresenter.REQUEST_DIRECTORY);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void launchOpenFileActivity()
|
||||
{
|
||||
FileBrowserHelper.openFilePicker(this, MainPresenter.REQUEST_GAME_FILE, false,
|
||||
FileBrowserHelper.GAME_EXTENSIONS);
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("*/*");
|
||||
startActivityForResult(intent, MainPresenter.REQUEST_GAME_FILE);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -194,19 +198,21 @@ public final class MainActivity extends AppCompatActivity implements MainView
|
|||
// If the user picked a file, as opposed to just backing out.
|
||||
if (resultCode == MainActivity.RESULT_OK)
|
||||
{
|
||||
Uri uri = result.getData();
|
||||
switch (requestCode)
|
||||
{
|
||||
case MainPresenter.REQUEST_DIRECTORY:
|
||||
mPresenter.onDirectorySelected(FileBrowserHelper.getSelectedPath(result));
|
||||
mPresenter.onDirectorySelected(result);
|
||||
break;
|
||||
|
||||
case MainPresenter.REQUEST_GAME_FILE:
|
||||
EmulationActivity.launch(this, FileBrowserHelper.getSelectedFiles(result));
|
||||
FileBrowserHelper.runAfterExtensionCheck(this, uri,
|
||||
FileBrowserHelper.GAME_LIKE_EXTENSIONS,
|
||||
() -> EmulationActivity.launch(this, result.getData().toString()));
|
||||
break;
|
||||
|
||||
case MainPresenter.REQUEST_WAD_FILE:
|
||||
FileBrowserHelper.runAfterExtensionCheck(this, result.getData(),
|
||||
FileBrowserHelper.WAD_EXTENSION,
|
||||
FileBrowserHelper.runAfterExtensionCheck(this, uri, FileBrowserHelper.WAD_EXTENSION,
|
||||
() -> mPresenter.installWAD(result.getData().toString()));
|
||||
break;
|
||||
}
|
||||
|
|
|
@ -2,9 +2,11 @@ package org.dolphinemu.dolphinemu.ui.main;
|
|||
|
||||
import android.app.Activity;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.net.Uri;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
@ -13,10 +15,16 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager;
|
|||
import org.dolphinemu.dolphinemu.BuildConfig;
|
||||
import org.dolphinemu.dolphinemu.NativeLibrary;
|
||||
import org.dolphinemu.dolphinemu.R;
|
||||
import org.dolphinemu.dolphinemu.features.settings.model.BooleanSetting;
|
||||
import org.dolphinemu.dolphinemu.features.settings.ui.MenuTag;
|
||||
import org.dolphinemu.dolphinemu.model.GameFileCache;
|
||||
import org.dolphinemu.dolphinemu.services.GameFileCacheService;
|
||||
import org.dolphinemu.dolphinemu.utils.AfterDirectoryInitializationRunner;
|
||||
import org.dolphinemu.dolphinemu.utils.ContentHandler;
|
||||
import org.dolphinemu.dolphinemu.utils.FileBrowserHelper;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
|
||||
public final class MainPresenter
|
||||
{
|
||||
|
@ -95,18 +103,40 @@ public final class MainPresenter
|
|||
return false;
|
||||
}
|
||||
|
||||
public void addDirIfNeeded(Context context)
|
||||
public void addDirIfNeeded()
|
||||
{
|
||||
if (mDirToAdd != null)
|
||||
{
|
||||
GameFileCache.addGameFolder(mDirToAdd, context);
|
||||
GameFileCache.addGameFolder(mDirToAdd);
|
||||
mDirToAdd = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void onDirectorySelected(String dir)
|
||||
public void onDirectorySelected(Intent result)
|
||||
{
|
||||
mDirToAdd = dir;
|
||||
Uri uri = result.getData();
|
||||
|
||||
boolean recursive = BooleanSetting.MAIN_RECURSIVE_ISO_PATHS.getBooleanGlobal();
|
||||
String[] childNames = ContentHandler.getChildNames(uri, recursive);
|
||||
if (Arrays.stream(childNames).noneMatch((name) -> FileBrowserHelper.GAME_EXTENSIONS.contains(
|
||||
FileBrowserHelper.getExtension(name, false))))
|
||||
{
|
||||
AlertDialog.Builder builder = new AlertDialog.Builder(mContext, R.style.DolphinDialogBase);
|
||||
builder.setMessage(mContext.getString(R.string.wrong_file_extension_in_directory,
|
||||
FileBrowserHelper.setToSortedDelimitedString(FileBrowserHelper.GAME_EXTENSIONS)));
|
||||
builder.setPositiveButton(R.string.ok, null);
|
||||
builder.show();
|
||||
}
|
||||
|
||||
ContentResolver contentResolver = mContext.getContentResolver();
|
||||
Uri canonicalizedUri = contentResolver.canonicalize(uri);
|
||||
if (canonicalizedUri != null)
|
||||
uri = canonicalizedUri;
|
||||
|
||||
int takeFlags = result.getFlags() & Intent.FLAG_GRANT_READ_URI_PERMISSION;
|
||||
mContext.getContentResolver().takePersistableUriPermission(uri, takeFlags);
|
||||
|
||||
mDirToAdd = uri.toString();
|
||||
}
|
||||
|
||||
public void installWAD(String file)
|
||||
|
|
|
@ -2,6 +2,7 @@ package org.dolphinemu.dolphinemu.ui.main;
|
|||
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.widget.Toast;
|
||||
|
||||
|
@ -39,11 +40,11 @@ public final class TvMainActivity extends FragmentActivity implements MainView
|
|||
{
|
||||
private static boolean sShouldRescanLibrary = true;
|
||||
|
||||
private MainPresenter mPresenter = new MainPresenter(this, this);
|
||||
private final MainPresenter mPresenter = new MainPresenter(this, this);
|
||||
|
||||
private BrowseSupportFragment mBrowseFragment;
|
||||
|
||||
private ArrayList<ArrayObjectAdapter> mGameRows = new ArrayList<>();
|
||||
private final ArrayList<ArrayObjectAdapter> mGameRows = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState)
|
||||
|
@ -73,7 +74,7 @@ public final class TvMainActivity extends FragmentActivity implements MainView
|
|||
GameFileCacheService.startLoad(this);
|
||||
}
|
||||
|
||||
mPresenter.addDirIfNeeded(this);
|
||||
mPresenter.addDirIfNeeded();
|
||||
|
||||
// In case the user changed a setting that affects how games are displayed,
|
||||
// such as system language, cover downloading...
|
||||
|
@ -167,14 +168,17 @@ public final class TvMainActivity extends FragmentActivity implements MainView
|
|||
@Override
|
||||
public void launchFileListActivity()
|
||||
{
|
||||
FileBrowserHelper.openDirectoryPicker(this, FileBrowserHelper.GAME_EXTENSIONS);
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
|
||||
startActivityForResult(intent, MainPresenter.REQUEST_DIRECTORY);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void launchOpenFileActivity()
|
||||
{
|
||||
FileBrowserHelper.openFilePicker(this, MainPresenter.REQUEST_GAME_FILE, false,
|
||||
FileBrowserHelper.GAME_EXTENSIONS);
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("*/*");
|
||||
startActivityForResult(intent, MainPresenter.REQUEST_GAME_FILE);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -218,19 +222,21 @@ public final class TvMainActivity extends FragmentActivity implements MainView
|
|||
// If the user picked a file, as opposed to just backing out.
|
||||
if (resultCode == MainActivity.RESULT_OK)
|
||||
{
|
||||
Uri uri = result.getData();
|
||||
switch (requestCode)
|
||||
{
|
||||
case MainPresenter.REQUEST_DIRECTORY:
|
||||
mPresenter.onDirectorySelected(FileBrowserHelper.getSelectedPath(result));
|
||||
mPresenter.onDirectorySelected(result);
|
||||
break;
|
||||
|
||||
case MainPresenter.REQUEST_GAME_FILE:
|
||||
EmulationActivity.launch(this, FileBrowserHelper.getSelectedFiles(result));
|
||||
FileBrowserHelper.runAfterExtensionCheck(this, uri,
|
||||
FileBrowserHelper.GAME_LIKE_EXTENSIONS,
|
||||
() -> EmulationActivity.launch(this, result.getData().toString()));
|
||||
break;
|
||||
|
||||
case MainPresenter.REQUEST_WAD_FILE:
|
||||
FileBrowserHelper.runAfterExtensionCheck(this, result.getData(),
|
||||
FileBrowserHelper.WAD_EXTENSION,
|
||||
FileBrowserHelper.runAfterExtensionCheck(this, uri, FileBrowserHelper.WAD_EXTENSION,
|
||||
() -> mPresenter.installWAD(result.getData().toString()));
|
||||
break;
|
||||
}
|
||||
|
|
|
@ -14,54 +14,76 @@ import androidx.annotation.Keep;
|
|||
import org.dolphinemu.dolphinemu.DolphinApplication;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
/*
|
||||
We use a lot of "catch (Exception e)" in this class. This is for two reasons:
|
||||
|
||||
1. We don't want any exceptions to escape to native code, as this leads to nasty crashes
|
||||
that often don't have stack traces that make sense.
|
||||
|
||||
2. The sheer number of different exceptions, both documented and undocumented. These include:
|
||||
- FileNotFoundException when a file doesn't exist
|
||||
- FileNotFoundException when using an invalid open mode (according to the documentation)
|
||||
- IllegalArgumentException when using an invalid open mode (in practice with FileProvider)
|
||||
- IllegalArgumentException when providing a tree where a document was expected and vice versa
|
||||
- SecurityException when trying to access something the user hasn't granted us permission to
|
||||
- UnsupportedOperationException when a URI specifies a storage provider that doesn't exist
|
||||
*/
|
||||
|
||||
public class ContentHandler
|
||||
{
|
||||
@Keep
|
||||
public static int openFd(String uri, String mode)
|
||||
public static int openFd(@NonNull String uri, @NonNull String mode)
|
||||
{
|
||||
try
|
||||
{
|
||||
return getContentResolver().openFileDescriptor(Uri.parse(uri), mode).detachFd();
|
||||
return getContentResolver().openFileDescriptor(unmangle(uri), mode).detachFd();
|
||||
}
|
||||
catch (SecurityException e)
|
||||
{
|
||||
Log.error("Tried to open " + uri + " without permission");
|
||||
return -1;
|
||||
}
|
||||
// Some content providers throw IllegalArgumentException for invalid modes,
|
||||
// despite the documentation saying that invalid modes result in a FileNotFoundException
|
||||
catch (FileNotFoundException | IllegalArgumentException | NullPointerException e)
|
||||
catch (Exception ignored)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static boolean delete(String uri)
|
||||
public static boolean delete(@NonNull String uri)
|
||||
{
|
||||
try
|
||||
{
|
||||
return DocumentsContract.deleteDocument(getContentResolver(), Uri.parse(uri));
|
||||
}
|
||||
catch (SecurityException e)
|
||||
{
|
||||
Log.error("Tried to delete " + uri + " without permission");
|
||||
return false;
|
||||
return DocumentsContract.deleteDocument(getContentResolver(), unmangle(uri));
|
||||
}
|
||||
catch (FileNotFoundException e)
|
||||
{
|
||||
// Return true because we care about the file not being there, not the actual delete.
|
||||
return true;
|
||||
}
|
||||
catch (SecurityException e)
|
||||
{
|
||||
Log.error("Tried to delete " + uri + " without permission");
|
||||
}
|
||||
catch (Exception ignored)
|
||||
{
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static boolean exists(@NonNull String uri)
|
||||
{
|
||||
try
|
||||
{
|
||||
Uri documentUri = treeToDocument(unmangle(uri));
|
||||
final String[] projection = new String[]{Document.COLUMN_MIME_TYPE, Document.COLUMN_SIZE};
|
||||
try (Cursor cursor = getContentResolver().query(Uri.parse(uri), projection, null, null, null))
|
||||
try (Cursor cursor = getContentResolver().query(documentUri, projection, null, null, null))
|
||||
{
|
||||
return cursor != null && cursor.getCount() > 0;
|
||||
}
|
||||
|
@ -70,15 +92,65 @@ public class ContentHandler
|
|||
{
|
||||
Log.error("Tried to check if " + uri + " exists without permission");
|
||||
}
|
||||
catch (Exception ignored)
|
||||
{
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return -1 if not found, -2 if directory, file size otherwise
|
||||
*/
|
||||
@Keep
|
||||
public static long getSizeAndIsDirectory(@NonNull String uri)
|
||||
{
|
||||
try
|
||||
{
|
||||
Uri documentUri = treeToDocument(unmangle(uri));
|
||||
final String[] projection = new String[]{Document.COLUMN_MIME_TYPE, Document.COLUMN_SIZE};
|
||||
try (Cursor cursor = getContentResolver().query(documentUri, projection, null, null, null))
|
||||
{
|
||||
if (cursor != null && cursor.moveToFirst())
|
||||
{
|
||||
if (Document.MIME_TYPE_DIR.equals(cursor.getString(0)))
|
||||
return -2;
|
||||
else
|
||||
return cursor.isNull(1) ? 0 : cursor.getLong(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (SecurityException e)
|
||||
{
|
||||
Log.error("Tried to get metadata for " + uri + " without permission");
|
||||
}
|
||||
catch (Exception ignored)
|
||||
{
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
@Nullable @Keep
|
||||
public static String getDisplayName(@NonNull String uri)
|
||||
{
|
||||
try
|
||||
{
|
||||
return getDisplayName(unmangle(uri));
|
||||
}
|
||||
catch (Exception ignored)
|
||||
{
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static String getDisplayName(@NonNull Uri uri)
|
||||
{
|
||||
final String[] projection = new String[]{Document.COLUMN_DISPLAY_NAME};
|
||||
try (Cursor cursor = getContentResolver().query(uri, projection, null, null, null))
|
||||
Uri documentUri = treeToDocument(uri);
|
||||
try (Cursor cursor = getContentResolver().query(documentUri, projection, null, null, null))
|
||||
{
|
||||
if (cursor != null && cursor.moveToFirst())
|
||||
{
|
||||
|
@ -89,10 +161,257 @@ public class ContentHandler
|
|||
{
|
||||
Log.error("Tried to get display name of " + uri + " without permission");
|
||||
}
|
||||
catch (Exception ignored)
|
||||
{
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@NonNull @Keep
|
||||
public static String[] getChildNames(@NonNull String uri, boolean recursive)
|
||||
{
|
||||
try
|
||||
{
|
||||
return getChildNames(unmangle(uri), recursive);
|
||||
}
|
||||
catch (Exception ignored)
|
||||
{
|
||||
}
|
||||
|
||||
return new String[0];
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static String[] getChildNames(@NonNull Uri uri, boolean recursive)
|
||||
{
|
||||
ArrayList<String> result = new ArrayList<>();
|
||||
|
||||
ForEachChildCallback callback = new ForEachChildCallback()
|
||||
{
|
||||
@Override
|
||||
public void run(String displayName, String documentId, boolean isDirectory)
|
||||
{
|
||||
if (recursive && isDirectory)
|
||||
{
|
||||
forEachChild(uri, documentId, this);
|
||||
}
|
||||
else
|
||||
{
|
||||
result.add(displayName);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
forEachChild(uri, DocumentsContract.getDocumentId(treeToDocument(uri)), callback);
|
||||
|
||||
return result.toArray(new String[0]);
|
||||
}
|
||||
|
||||
@NonNull @Keep
|
||||
public static String[] doFileSearch(@NonNull String directory, @NonNull String[] extensions,
|
||||
boolean recursive)
|
||||
{
|
||||
ArrayList<String> result = new ArrayList<>();
|
||||
|
||||
try
|
||||
{
|
||||
Uri uri = unmangle(directory);
|
||||
String documentId = DocumentsContract.getDocumentId(treeToDocument(uri));
|
||||
boolean acceptAll = extensions.length == 0;
|
||||
Predicate<String> extensionCheck = (displayName) ->
|
||||
{
|
||||
String extension = FileBrowserHelper.getExtension(displayName, true);
|
||||
return extension != null && Arrays.stream(extensions).anyMatch(extension::equalsIgnoreCase);
|
||||
};
|
||||
doFileSearch(uri, directory, documentId, recursive, result, acceptAll, extensionCheck);
|
||||
}
|
||||
catch (Exception ignored)
|
||||
{
|
||||
}
|
||||
|
||||
return result.toArray(new String[0]);
|
||||
}
|
||||
|
||||
private static void doFileSearch(@NonNull Uri baseUri, @NonNull String path,
|
||||
@NonNull String documentId, boolean recursive, @NonNull List<String> resultOut,
|
||||
boolean acceptAll, @NonNull Predicate<String> extensionCheck)
|
||||
{
|
||||
forEachChild(baseUri, documentId, (displayName, childDocumentId, isDirectory) ->
|
||||
{
|
||||
String childPath = path + '/' + displayName;
|
||||
if (acceptAll || (!isDirectory && extensionCheck.test(displayName)))
|
||||
{
|
||||
resultOut.add(childPath);
|
||||
}
|
||||
if (recursive && isDirectory)
|
||||
{
|
||||
doFileSearch(baseUri, childPath, childDocumentId, recursive, resultOut, acceptAll,
|
||||
extensionCheck);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private interface ForEachChildCallback
|
||||
{
|
||||
void run(String displayName, String documentId, boolean isDirectory);
|
||||
}
|
||||
|
||||
private static void forEachChild(@NonNull Uri uri, @NonNull String documentId,
|
||||
@NonNull ForEachChildCallback callback)
|
||||
{
|
||||
try
|
||||
{
|
||||
Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, documentId);
|
||||
|
||||
final String[] projection = new String[]{Document.COLUMN_DISPLAY_NAME,
|
||||
Document.COLUMN_MIME_TYPE, Document.COLUMN_DOCUMENT_ID};
|
||||
try (Cursor cursor = getContentResolver().query(childrenUri, projection, null, null, null))
|
||||
{
|
||||
if (cursor != null)
|
||||
{
|
||||
while (cursor.moveToNext())
|
||||
{
|
||||
callback.run(cursor.getString(0), cursor.getString(2),
|
||||
Document.MIME_TYPE_DIR.equals(cursor.getString(1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (SecurityException e)
|
||||
{
|
||||
Log.error("Tried to get children of " + uri + " without permission");
|
||||
}
|
||||
catch (Exception ignored)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private static Uri getChild(@NonNull Uri parentUri, @NonNull String childName)
|
||||
throws FileNotFoundException, SecurityException
|
||||
{
|
||||
String parentId = DocumentsContract.getDocumentId(treeToDocument(parentUri));
|
||||
Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(parentUri, parentId);
|
||||
|
||||
final String[] projection = new String[]{Document.COLUMN_DISPLAY_NAME,
|
||||
Document.COLUMN_DOCUMENT_ID};
|
||||
final String selection = Document.COLUMN_DISPLAY_NAME + "=?";
|
||||
final String[] selectionArgs = new String[]{childName};
|
||||
try (Cursor cursor = getContentResolver().query(childrenUri, projection, selection,
|
||||
selectionArgs, null))
|
||||
{
|
||||
if (cursor != null)
|
||||
{
|
||||
while (cursor.moveToNext())
|
||||
{
|
||||
// FileProvider seemingly doesn't support selections, so we have to manually filter here
|
||||
if (childName.equals(cursor.getString(0)))
|
||||
{
|
||||
return DocumentsContract.buildDocumentUriUsingTree(parentUri, cursor.getString(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (SecurityException e)
|
||||
{
|
||||
Log.error("Tried to get child " + childName + " of " + parentUri + " without permission");
|
||||
}
|
||||
catch (Exception ignored)
|
||||
{
|
||||
}
|
||||
|
||||
throw new FileNotFoundException(parentUri + "/" + childName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Since our C++ code was written under the assumption that it would be running under a filesystem
|
||||
* which supports normal paths, it appends a slash followed by a file name when it wants to access
|
||||
* a file in a directory. This function translates that into the type of URI that SAF requires.
|
||||
*
|
||||
* In order to detect whether a URI is mangled or not, we make the assumption that an
|
||||
* unmangled URI contains at least one % and does not contain any slashes after the last %.
|
||||
* This seems to hold for all common storage providers, but it is theoretically for a storage
|
||||
* provider to use URIs without any % characters.
|
||||
*/
|
||||
@NonNull
|
||||
private static Uri unmangle(@NonNull String uri) throws FileNotFoundException, SecurityException
|
||||
{
|
||||
int lastComponentEnd = getLastComponentEnd(uri);
|
||||
int lastComponentStart = getLastComponentStart(uri, lastComponentEnd);
|
||||
|
||||
if (lastComponentStart == 0)
|
||||
{
|
||||
return Uri.parse(uri.substring(0, lastComponentEnd));
|
||||
}
|
||||
else
|
||||
{
|
||||
Uri parentUri = unmangle(uri.substring(0, lastComponentStart));
|
||||
String childName = uri.substring(lastComponentStart, lastComponentEnd);
|
||||
return getChild(parentUri, childName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last character which is not a slash.
|
||||
*/
|
||||
private static int getLastComponentEnd(@NonNull String uri)
|
||||
{
|
||||
int i = uri.length();
|
||||
while (i > 0 && uri.charAt(i - 1) == '/')
|
||||
i--;
|
||||
return i;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans backwards starting from lastComponentEnd and returns the index after the first slash
|
||||
* it finds, but only if there is a % before that slash and there is no % after it.
|
||||
*/
|
||||
private static int getLastComponentStart(@NonNull String uri, int lastComponentEnd)
|
||||
{
|
||||
int i = lastComponentEnd;
|
||||
while (i > 0 && uri.charAt(i - 1) != '/')
|
||||
{
|
||||
i--;
|
||||
if (uri.charAt(i) == '%')
|
||||
return 0;
|
||||
}
|
||||
|
||||
int j = i;
|
||||
while (j > 0)
|
||||
{
|
||||
j--;
|
||||
if (uri.charAt(j) == '%')
|
||||
return i;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private static Uri treeToDocument(@NonNull Uri uri)
|
||||
{
|
||||
if (isTreeUri(uri))
|
||||
{
|
||||
String documentId = DocumentsContract.getTreeDocumentId(uri);
|
||||
return DocumentsContract.buildDocumentUriUsingTree(uri, documentId);
|
||||
}
|
||||
else
|
||||
{
|
||||
return uri;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is like DocumentsContract.isTreeUri, except it doesn't return true for URIs like
|
||||
* content://com.example/tree/12/document/24/. We want to treat those as documents, not trees.
|
||||
*/
|
||||
private static boolean isTreeUri(@NonNull Uri uri)
|
||||
{
|
||||
final List<String> pathSegments = uri.getPathSegments();
|
||||
return pathSegments.size() == 2 && "tree".equals(pathSegments.get(0));
|
||||
}
|
||||
|
||||
private static ContentResolver getContentResolver()
|
||||
{
|
||||
return DolphinApplication.getAppContext().getContentResolver();
|
||||
|
|
|
@ -28,7 +28,14 @@ import java.util.Set;
|
|||
public final class FileBrowserHelper
|
||||
{
|
||||
public static final HashSet<String> GAME_EXTENSIONS = new HashSet<>(Arrays.asList(
|
||||
"gcm", "tgc", "iso", "ciso", "gcz", "wbfs", "wia", "rvz", "wad", "dol", "elf", "dff"));
|
||||
"gcm", "tgc", "iso", "ciso", "gcz", "wbfs", "wia", "rvz", "wad", "dol", "elf"));
|
||||
|
||||
public static final HashSet<String> GAME_LIKE_EXTENSIONS = new HashSet<>(GAME_EXTENSIONS);
|
||||
|
||||
static
|
||||
{
|
||||
GAME_LIKE_EXTENSIONS.add("dff");
|
||||
}
|
||||
|
||||
public static final HashSet<String> RAW_EXTENSION = new HashSet<>(Collections.singletonList(
|
||||
"raw"));
|
||||
|
@ -50,21 +57,6 @@ public final class FileBrowserHelper
|
|||
activity.startActivityForResult(i, MainPresenter.REQUEST_DIRECTORY);
|
||||
}
|
||||
|
||||
public static void openFilePicker(FragmentActivity activity, int requestCode, boolean allowMulti,
|
||||
HashSet<String> extensions)
|
||||
{
|
||||
Intent i = new Intent(activity, CustomFilePickerActivity.class);
|
||||
|
||||
i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, allowMulti);
|
||||
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_EXTENSIONS, extensions);
|
||||
|
||||
activity.startActivityForResult(i, requestCode);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static String getSelectedPath(Intent result)
|
||||
{
|
||||
|
@ -79,22 +71,6 @@ public final class FileBrowserHelper
|
|||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static String[] getSelectedFiles(Intent result)
|
||||
{
|
||||
// Use the provided utility method to parse the result
|
||||
List<Uri> 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;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static boolean isPathEmptyOrValid(StringSetting path)
|
||||
{
|
||||
return isPathEmptyOrValid(path.getStringGlobal());
|
||||
|
@ -112,10 +88,10 @@ public final class FileBrowserHelper
|
|||
|
||||
String path = uri.getLastPathSegment();
|
||||
if (path != null)
|
||||
extension = getExtension(new File(path).getName());
|
||||
extension = getExtension(new File(path).getName(), false);
|
||||
|
||||
if (extension == null)
|
||||
extension = getExtension(ContentHandler.getDisplayName(uri));
|
||||
extension = getExtension(ContentHandler.getDisplayName(uri), false);
|
||||
|
||||
if (extension != null && validExtensions.contains(extension))
|
||||
{
|
||||
|
@ -133,10 +109,8 @@ public final class FileBrowserHelper
|
|||
int messageId = validExtensions.size() == 1 ?
|
||||
R.string.wrong_file_extension_single : R.string.wrong_file_extension_multiple;
|
||||
|
||||
ArrayList<String> extensionsList = new ArrayList<>(validExtensions);
|
||||
Collections.sort(extensionsList);
|
||||
|
||||
message = context.getString(messageId, extension, join(", ", extensionsList));
|
||||
message = context.getString(messageId, extension,
|
||||
setToSortedDelimitedString(validExtensions));
|
||||
}
|
||||
|
||||
new AlertDialog.Builder(context, R.style.DolphinDialogBase)
|
||||
|
@ -148,13 +122,22 @@ public final class FileBrowserHelper
|
|||
}
|
||||
|
||||
@Nullable
|
||||
private static String getExtension(@Nullable String fileName)
|
||||
public static String getExtension(@Nullable String fileName, boolean includeDot)
|
||||
{
|
||||
if (fileName == null)
|
||||
return null;
|
||||
|
||||
int dotIndex = fileName.lastIndexOf(".");
|
||||
return dotIndex != -1 ? fileName.substring(dotIndex + 1) : null;
|
||||
if (dotIndex == -1)
|
||||
return null;
|
||||
return fileName.substring(dotIndex + (includeDot ? 0 : 1));
|
||||
}
|
||||
|
||||
public static String setToSortedDelimitedString(Set<String> set)
|
||||
{
|
||||
ArrayList<String> list = new ArrayList<>(set);
|
||||
Collections.sort(list);
|
||||
return join(", ", list);
|
||||
}
|
||||
|
||||
// TODO: Replace this with String.join once we can use Java 8
|
||||
|
|
|
@ -438,6 +438,7 @@ It can efficiently compress both junk data and encrypted Wii data.
|
|||
<string name="no_file_extension">The selected file does not appear to have a file name extension.\n\nContinue anyway?</string>
|
||||
<string name="wrong_file_extension_single">The selected file has the file name extension \"%1$s\", but \"%2$s\" was expected.\n\nContinue anyway?</string>
|
||||
<string name="wrong_file_extension_multiple">The selected file has the file name extension \"%1$s\", but one of these extensions was expected: %2$s\n\nContinue anyway?</string>
|
||||
<string name="wrong_file_extension_in_directory">No compatible files were found in the selected location.\n\nThe supported formats are: %1$s</string>
|
||||
<string name="unavailable_paths">Dolphin does not have permission to access one or more configured paths. Would you like to fix this before starting?</string>
|
||||
|
||||
<!-- Misc -->
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
#include "jni/AndroidCommon/AndroidCommon.h"
|
||||
|
||||
#include <ios>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
@ -43,6 +44,14 @@ std::vector<std::string> JStringArrayToVector(JNIEnv* env, jobjectArray array)
|
|||
return result;
|
||||
}
|
||||
|
||||
jobjectArray JStringArrayFromVector(JNIEnv* env, std::vector<std::string> vector)
|
||||
{
|
||||
jobjectArray result = env->NewObjectArray(vector.size(), IDCache::GetStringClass(), nullptr);
|
||||
for (jsize i = 0; i < vector.size(); ++i)
|
||||
env->SetObjectArrayElement(result, i, ToJString(env, vector[i]));
|
||||
return result;
|
||||
}
|
||||
|
||||
bool IsPathAndroidContent(const std::string& uri)
|
||||
{
|
||||
return StringBeginsWith(uri, "content://");
|
||||
|
@ -66,6 +75,28 @@ std::string OpenModeToAndroid(std::string mode)
|
|||
return mode;
|
||||
}
|
||||
|
||||
std::string OpenModeToAndroid(std::ios_base::openmode mode)
|
||||
{
|
||||
std::string result;
|
||||
|
||||
if (mode & std::ios_base::in)
|
||||
result += 'r';
|
||||
|
||||
if (mode & (std::ios_base::out | std::ios_base::app))
|
||||
result += 'w';
|
||||
|
||||
if (mode & std::ios_base::app)
|
||||
result += 'a';
|
||||
|
||||
constexpr std::ios_base::openmode t = std::ios_base::in | std::ios_base::trunc;
|
||||
if ((mode & t) == t)
|
||||
result += 't';
|
||||
|
||||
// The 'b' specifier is not supported. Since we're on POSIX, it's fine to just skip it.
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
int OpenAndroidContent(const std::string& uri, const std::string& mode)
|
||||
{
|
||||
JNIEnv* env = IDCache::GetEnvForThread();
|
||||
|
@ -81,6 +112,43 @@ bool DeleteAndroidContent(const std::string& uri)
|
|||
IDCache::GetContentHandlerDelete(), ToJString(env, uri));
|
||||
}
|
||||
|
||||
jlong GetAndroidContentSizeAndIsDirectory(const std::string& uri)
|
||||
{
|
||||
JNIEnv* env = IDCache::GetEnvForThread();
|
||||
return env->CallStaticLongMethod(IDCache::GetContentHandlerClass(),
|
||||
IDCache::GetContentHandlerGetSizeAndIsDirectory(),
|
||||
ToJString(env, uri));
|
||||
}
|
||||
|
||||
std::string GetAndroidContentDisplayName(const std::string& uri)
|
||||
{
|
||||
JNIEnv* env = IDCache::GetEnvForThread();
|
||||
jobject display_name =
|
||||
env->CallStaticObjectMethod(IDCache::GetContentHandlerClass(),
|
||||
IDCache::GetContentHandlerGetDisplayName(), ToJString(env, uri));
|
||||
return display_name ? GetJString(env, reinterpret_cast<jstring>(display_name)) : "";
|
||||
}
|
||||
|
||||
std::vector<std::string> GetAndroidContentChildNames(const std::string& uri)
|
||||
{
|
||||
JNIEnv* env = IDCache::GetEnvForThread();
|
||||
jobject children = env->CallStaticObjectMethod(IDCache::GetContentHandlerClass(),
|
||||
IDCache::GetContentHandlerGetChildNames(),
|
||||
ToJString(env, uri), false);
|
||||
return JStringArrayToVector(env, reinterpret_cast<jobjectArray>(children));
|
||||
}
|
||||
|
||||
std::vector<std::string> DoFileSearchAndroidContent(const std::string& directory,
|
||||
const std::vector<std::string>& extensions,
|
||||
bool recursive)
|
||||
{
|
||||
JNIEnv* env = IDCache::GetEnvForThread();
|
||||
jobject result = env->CallStaticObjectMethod(
|
||||
IDCache::GetContentHandlerClass(), IDCache::GetContentHandlerDoFileSearch(),
|
||||
ToJString(env, directory), JStringArrayFromVector(env, extensions), recursive);
|
||||
return JStringArrayToVector(env, reinterpret_cast<jobjectArray>(result));
|
||||
}
|
||||
|
||||
int GetNetworkIpAddress()
|
||||
{
|
||||
JNIEnv* env = IDCache::GetEnvForThread();
|
||||
|
|
|
@ -4,7 +4,9 @@
|
|||
|
||||
#pragma once
|
||||
|
||||
#include <ios>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <jni.h>
|
||||
|
||||
|
@ -17,12 +19,29 @@ bool IsPathAndroidContent(const std::string& uri);
|
|||
|
||||
// Turns a C/C++ style mode (e.g. "rb") into one which can be used with OpenAndroidContent.
|
||||
std::string OpenModeToAndroid(std::string mode);
|
||||
std::string OpenModeToAndroid(std::ios_base::openmode mode);
|
||||
|
||||
// Opens a given file and returns a file descriptor.
|
||||
int OpenAndroidContent(const std::string& uri, const std::string& mode);
|
||||
|
||||
// Deletes a given file.
|
||||
bool DeleteAndroidContent(const std::string& uri);
|
||||
// Returns -1 if not found, -2 if directory, file size otherwise.
|
||||
jlong GetAndroidContentSizeAndIsDirectory(const std::string& uri);
|
||||
|
||||
// An unmangled URI (one which the C++ code has not appended anything to) can't be relied on
|
||||
// to contain a file name at all. If a file name is desired, this function is the most reliable
|
||||
// way to get it, but the display name is not guaranteed to always actually be like a file name.
|
||||
// An empty string will be returned for files which do not exist.
|
||||
std::string GetAndroidContentDisplayName(const std::string& uri);
|
||||
|
||||
// Returns the display names of all children of a directory, non-recursively.
|
||||
std::vector<std::string> GetAndroidContentChildNames(const std::string& uri);
|
||||
|
||||
std::vector<std::string> DoFileSearchAndroidContent(const std::string& directory,
|
||||
const std::vector<std::string>& extensions,
|
||||
bool recursive);
|
||||
|
||||
int GetNetworkIpAddress();
|
||||
int GetNetworkPrefixLength();
|
||||
int GetNetworkGateway();
|
||||
|
|
|
@ -10,6 +10,8 @@ static constexpr jint JNI_VERSION = JNI_VERSION_1_6;
|
|||
|
||||
static JavaVM* s_java_vm;
|
||||
|
||||
static jclass s_string_class;
|
||||
|
||||
static jclass s_native_library_class;
|
||||
static jmethodID s_display_alert_msg;
|
||||
static jmethodID s_do_rumble;
|
||||
|
@ -44,6 +46,10 @@ static jmethodID s_compress_cb_run;
|
|||
static jclass s_content_handler_class;
|
||||
static jmethodID s_content_handler_open_fd;
|
||||
static jmethodID s_content_handler_delete;
|
||||
static jmethodID s_content_handler_get_size_and_is_directory;
|
||||
static jmethodID s_content_handler_get_display_name;
|
||||
static jmethodID s_content_handler_get_child_names;
|
||||
static jmethodID s_content_handler_do_file_search;
|
||||
|
||||
static jclass s_network_helper_class;
|
||||
static jmethodID s_network_helper_get_network_ip_address;
|
||||
|
@ -75,6 +81,11 @@ JNIEnv* GetEnvForThread()
|
|||
return owned.env;
|
||||
}
|
||||
|
||||
jclass GetStringClass()
|
||||
{
|
||||
return s_string_class;
|
||||
}
|
||||
|
||||
jclass GetNativeLibraryClass()
|
||||
{
|
||||
return s_native_library_class;
|
||||
|
@ -210,6 +221,26 @@ jmethodID GetContentHandlerDelete()
|
|||
return s_content_handler_delete;
|
||||
}
|
||||
|
||||
jmethodID GetContentHandlerGetSizeAndIsDirectory()
|
||||
{
|
||||
return s_content_handler_get_size_and_is_directory;
|
||||
}
|
||||
|
||||
jmethodID GetContentHandlerGetDisplayName()
|
||||
{
|
||||
return s_content_handler_get_display_name;
|
||||
}
|
||||
|
||||
jmethodID GetContentHandlerGetChildNames()
|
||||
{
|
||||
return s_content_handler_get_child_names;
|
||||
}
|
||||
|
||||
jmethodID GetContentHandlerDoFileSearch()
|
||||
{
|
||||
return s_content_handler_do_file_search;
|
||||
}
|
||||
|
||||
jclass GetNetworkHelperClass()
|
||||
{
|
||||
return s_network_helper_class;
|
||||
|
@ -229,6 +260,7 @@ jmethodID GetNetworkHelperGetNetworkGateway()
|
|||
{
|
||||
return s_network_helper_get_network_gateway;
|
||||
}
|
||||
|
||||
} // namespace IDCache
|
||||
|
||||
#ifdef __cplusplus
|
||||
|
@ -243,6 +275,9 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved)
|
|||
if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION) != JNI_OK)
|
||||
return JNI_ERR;
|
||||
|
||||
const jclass string_class = env->FindClass("java/lang/String");
|
||||
s_string_class = reinterpret_cast<jclass>(env->NewGlobalRef(string_class));
|
||||
|
||||
const jclass native_library_class = env->FindClass("org/dolphinemu/dolphinemu/NativeLibrary");
|
||||
s_native_library_class = reinterpret_cast<jclass>(env->NewGlobalRef(native_library_class));
|
||||
s_display_alert_msg = env->GetStaticMethodID(s_native_library_class, "displayAlertMsg",
|
||||
|
@ -306,6 +341,15 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved)
|
|||
"(Ljava/lang/String;Ljava/lang/String;)I");
|
||||
s_content_handler_delete =
|
||||
env->GetStaticMethodID(s_content_handler_class, "delete", "(Ljava/lang/String;)Z");
|
||||
s_content_handler_get_size_and_is_directory = env->GetStaticMethodID(
|
||||
s_content_handler_class, "getSizeAndIsDirectory", "(Ljava/lang/String;)J");
|
||||
s_content_handler_get_display_name = env->GetStaticMethodID(
|
||||
s_content_handler_class, "getDisplayName", "(Ljava/lang/String;)Ljava/lang/String;");
|
||||
s_content_handler_get_child_names = env->GetStaticMethodID(
|
||||
s_content_handler_class, "getChildNames", "(Ljava/lang/String;Z)[Ljava/lang/String;");
|
||||
s_content_handler_do_file_search =
|
||||
env->GetStaticMethodID(s_content_handler_class, "doFileSearch",
|
||||
"(Ljava/lang/String;[Ljava/lang/String;Z)[Ljava/lang/String;");
|
||||
|
||||
const jclass network_helper_class =
|
||||
env->FindClass("org/dolphinemu/dolphinemu/utils/NetworkHelper");
|
||||
|
|
|
@ -10,6 +10,8 @@ namespace IDCache
|
|||
{
|
||||
JNIEnv* GetEnvForThread();
|
||||
|
||||
jclass GetStringClass();
|
||||
|
||||
jclass GetNativeLibraryClass();
|
||||
jmethodID GetDisplayAlertMsg();
|
||||
jmethodID GetDoRumble();
|
||||
|
@ -44,6 +46,10 @@ jmethodID GetCompressCallbackRun();
|
|||
jclass GetContentHandlerClass();
|
||||
jmethodID GetContentHandlerOpenFd();
|
||||
jmethodID GetContentHandlerDelete();
|
||||
jmethodID GetContentHandlerGetSizeAndIsDirectory();
|
||||
jmethodID GetContentHandlerGetDisplayName();
|
||||
jmethodID GetContentHandlerGetChildNames();
|
||||
jmethodID GetContentHandlerDoFileSearch();
|
||||
|
||||
jclass GetNetworkHelperClass();
|
||||
jmethodID GetNetworkHelperGetNetworkIpAddress();
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
#include <algorithm>
|
||||
#include <functional>
|
||||
#include <iterator>
|
||||
|
||||
#include "Common/CommonPaths.h"
|
||||
#include "Common/FileSearch.h"
|
||||
|
@ -15,6 +16,10 @@
|
|||
namespace fs = std::filesystem;
|
||||
#define HAS_STD_FILESYSTEM
|
||||
#else
|
||||
#ifdef ANDROID
|
||||
#include "jni/AndroidCommon/AndroidCommon.h"
|
||||
#endif
|
||||
|
||||
#include <cstring>
|
||||
#include "Common/CommonFuncs.h"
|
||||
#include "Common/FileUtil.h"
|
||||
|
@ -24,36 +29,30 @@ namespace Common
|
|||
{
|
||||
#ifndef HAS_STD_FILESYSTEM
|
||||
|
||||
static std::vector<std::string>
|
||||
FileSearchWithTest(const std::vector<std::string>& directories, bool recursive,
|
||||
std::function<bool(const File::FSTEntry&)> callback)
|
||||
static void FileSearchWithTest(const std::string& directory, bool recursive,
|
||||
std::vector<std::string>* result_out,
|
||||
std::function<bool(const File::FSTEntry&)> callback)
|
||||
{
|
||||
std::vector<std::string> result;
|
||||
for (const std::string& directory : directories)
|
||||
{
|
||||
File::FSTEntry top = File::ScanDirectoryTree(directory, recursive);
|
||||
File::FSTEntry top = File::ScanDirectoryTree(directory, recursive);
|
||||
|
||||
std::function<void(File::FSTEntry&)> DoEntry;
|
||||
DoEntry = [&](File::FSTEntry& entry) {
|
||||
if (callback(entry))
|
||||
result.push_back(entry.physicalName);
|
||||
for (auto& child : entry.children)
|
||||
DoEntry(child);
|
||||
};
|
||||
for (auto& child : top.children)
|
||||
const std::function<void(File::FSTEntry&)> DoEntry = [&](File::FSTEntry& entry) {
|
||||
if (callback(entry))
|
||||
result_out->push_back(entry.physicalName);
|
||||
for (auto& child : entry.children)
|
||||
DoEntry(child);
|
||||
}
|
||||
// remove duplicates
|
||||
std::sort(result.begin(), result.end());
|
||||
result.erase(std::unique(result.begin(), result.end()), result.end());
|
||||
return result;
|
||||
};
|
||||
|
||||
for (auto& child : top.children)
|
||||
DoEntry(child);
|
||||
}
|
||||
|
||||
std::vector<std::string> DoFileSearch(const std::vector<std::string>& directories,
|
||||
const std::vector<std::string>& exts, bool recursive)
|
||||
{
|
||||
std::vector<std::string> result;
|
||||
|
||||
bool accept_all = exts.empty();
|
||||
return FileSearchWithTest(directories, recursive, [&](const File::FSTEntry& entry) {
|
||||
const auto callback = [&exts, accept_all](const File::FSTEntry& entry) {
|
||||
if (accept_all)
|
||||
return true;
|
||||
if (entry.isDirectory)
|
||||
|
@ -63,7 +62,34 @@ std::vector<std::string> DoFileSearch(const std::vector<std::string>& directorie
|
|||
return name.length() >= ext.length() &&
|
||||
strcasecmp(name.c_str() + name.length() - ext.length(), ext.c_str()) == 0;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
for (const std::string& directory : directories)
|
||||
{
|
||||
#ifdef ANDROID
|
||||
// While File::ScanDirectoryTree (which is called in FileSearchWithTest) does handle Android
|
||||
// content correctly, having a specialized implementation of DoFileSearch for Android content
|
||||
// provides a much needed performance boost. Also, this specialized implementation will be
|
||||
// required if we in the future replace the use of File::ScanDirectoryTree with std::filesystem.
|
||||
if (IsPathAndroidContent(directory))
|
||||
{
|
||||
const std::vector<std::string> partial_result =
|
||||
DoFileSearchAndroidContent(directory, exts, recursive);
|
||||
|
||||
result.insert(result.end(), std::make_move_iterator(partial_result.begin()),
|
||||
std::make_move_iterator(partial_result.end()));
|
||||
}
|
||||
else
|
||||
#endif
|
||||
{
|
||||
FileSearchWithTest(directory, recursive, &result, callback);
|
||||
}
|
||||
}
|
||||
|
||||
// remove duplicates
|
||||
std::sort(result.begin(), result.end());
|
||||
result.erase(std::unique(result.begin(), result.end()), result.end());
|
||||
return result;
|
||||
}
|
||||
|
||||
#else
|
||||
|
|
|
@ -78,19 +78,40 @@ FileInfo::FileInfo(const char* path) : FileInfo(std::string(path))
|
|||
#else
|
||||
FileInfo::FileInfo(const std::string& path) : FileInfo(path.c_str())
|
||||
{
|
||||
#ifdef ANDROID
|
||||
if (IsPathAndroidContent(path))
|
||||
AndroidContentInit(path);
|
||||
else
|
||||
#endif
|
||||
m_exists = stat(path.c_str(), &m_stat) == 0;
|
||||
}
|
||||
|
||||
FileInfo::FileInfo(const char* path)
|
||||
{
|
||||
m_exists = stat(path, &m_stat) == 0;
|
||||
#ifdef ANDROID
|
||||
if (IsPathAndroidContent(path))
|
||||
AndroidContentInit(path);
|
||||
else
|
||||
#endif
|
||||
m_exists = stat(path, &m_stat) == 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
FileInfo::FileInfo(int fd)
|
||||
{
|
||||
m_exists = fstat(fd, &m_stat);
|
||||
m_exists = fstat(fd, &m_stat) == 0;
|
||||
}
|
||||
|
||||
#ifdef ANDROID
|
||||
void FileInfo::AndroidContentInit(const std::string& path)
|
||||
{
|
||||
const jlong result = GetAndroidContentSizeAndIsDirectory(path);
|
||||
m_exists = result != -1;
|
||||
m_stat.st_mode = result == -2 ? S_IFDIR : S_IFREG;
|
||||
m_stat.st_size = result >= 0 ? result : 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
bool FileInfo::Exists() const
|
||||
{
|
||||
return m_exists;
|
||||
|
@ -476,14 +497,47 @@ FSTEntry ScanDirectoryTree(const std::string& directory, bool recursive)
|
|||
{
|
||||
const std::string virtual_name(TStrToUTF8(ffd.cFileName));
|
||||
#else
|
||||
DIR* dirp = opendir(directory.c_str());
|
||||
if (!dirp)
|
||||
return parent_entry;
|
||||
DIR* dirp = nullptr;
|
||||
|
||||
#ifdef ANDROID
|
||||
std::vector<std::string> child_names;
|
||||
if (IsPathAndroidContent(directory))
|
||||
{
|
||||
child_names = GetAndroidContentChildNames(directory);
|
||||
}
|
||||
else
|
||||
#endif
|
||||
{
|
||||
dirp = opendir(directory.c_str());
|
||||
if (!dirp)
|
||||
return parent_entry;
|
||||
}
|
||||
|
||||
#ifdef ANDROID
|
||||
auto it = child_names.cbegin();
|
||||
#endif
|
||||
|
||||
// non Windows loop
|
||||
while (dirent* result = readdir(dirp))
|
||||
while (true)
|
||||
{
|
||||
const std::string virtual_name(result->d_name);
|
||||
std::string virtual_name;
|
||||
|
||||
#ifdef ANDROID
|
||||
if (!dirp)
|
||||
{
|
||||
if (it == child_names.cend())
|
||||
break;
|
||||
virtual_name = *it;
|
||||
++it;
|
||||
}
|
||||
else
|
||||
#endif
|
||||
{
|
||||
dirent* result = readdir(dirp);
|
||||
if (!result)
|
||||
break;
|
||||
virtual_name = result->d_name;
|
||||
}
|
||||
#endif
|
||||
if (virtual_name == "." || virtual_name == "..")
|
||||
continue;
|
||||
|
@ -514,7 +568,8 @@ FSTEntry ScanDirectoryTree(const std::string& directory, bool recursive)
|
|||
FindClose(hFind);
|
||||
#else
|
||||
}
|
||||
closedir(dirp);
|
||||
if (dirp)
|
||||
closedir(dirp);
|
||||
#endif
|
||||
|
||||
return parent_entry;
|
||||
|
|
|
@ -18,6 +18,11 @@
|
|||
#include "Common/StringUtil.h"
|
||||
#endif
|
||||
|
||||
#ifdef ANDROID
|
||||
#include "Common/StringUtil.h"
|
||||
#include "jni/AndroidCommon/AndroidCommon.h"
|
||||
#endif
|
||||
|
||||
// User directory indices for GetUserPath
|
||||
enum
|
||||
{
|
||||
|
@ -109,6 +114,10 @@ public:
|
|||
u64 GetSize() const;
|
||||
|
||||
private:
|
||||
#ifdef ANDROID
|
||||
void AndroidContentInit(const std::string& path);
|
||||
#endif
|
||||
|
||||
struct stat m_stat;
|
||||
bool m_exists;
|
||||
};
|
||||
|
@ -214,14 +223,20 @@ std::string GetExeDirectory();
|
|||
bool WriteStringToFile(const std::string& filename, std::string_view str);
|
||||
bool ReadFileToString(const std::string& filename, std::string& str);
|
||||
|
||||
// To deal with Windows being dumb at unicode:
|
||||
// To deal with Windows not fully supporting UTF-8 and Android not fully supporting paths.
|
||||
template <typename T>
|
||||
void OpenFStream(T& fstream, const std::string& filename, std::ios_base::openmode openmode)
|
||||
{
|
||||
#ifdef _WIN32
|
||||
fstream.open(UTF8ToTStr(filename).c_str(), openmode);
|
||||
#else
|
||||
fstream.open(filename.c_str(), openmode);
|
||||
#ifdef ANDROID
|
||||
// Unfortunately it seems like the non-standard __open is the only way to use a file descriptor
|
||||
if (IsPathAndroidContent(filename))
|
||||
fstream.__open(OpenAndroidContent(filename, OpenModeToAndroid(openmode)), openmode);
|
||||
else
|
||||
#endif
|
||||
fstream.open(filename.c_str(), openmode);
|
||||
#endif
|
||||
}
|
||||
|
||||
|
|
|
@ -158,6 +158,15 @@ BootParameters::GenerateFromFile(std::vector<std::string> paths,
|
|||
if (paths.size() == 1)
|
||||
paths.clear();
|
||||
|
||||
#ifdef ANDROID
|
||||
if (extension.empty() && IsPathAndroidContent(path))
|
||||
{
|
||||
const std::string display_name = GetAndroidContentDisplayName(path);
|
||||
SplitPath(display_name, nullptr, nullptr, &extension);
|
||||
std::transform(extension.begin(), extension.end(), extension.begin(), ::tolower);
|
||||
}
|
||||
#endif
|
||||
|
||||
static const std::unordered_set<std::string> disc_image_extensions = {
|
||||
{".gcm", ".iso", ".tgc", ".wbfs", ".ciso", ".gcz", ".wia", ".rvz", ".dol", ".elf"}};
|
||||
if (disc_image_extensions.find(extension) != disc_image_extensions.end() || is_drive)
|
||||
|
|
Loading…
Reference in New Issue