Merge pull request #9318 from JosJuice/android-saf-games

Android: Use storage access framework for game list
This commit is contained in:
JosJuice 2020-12-30 11:10:35 +01:00 committed by GitHub
commit c1d041b888
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 795 additions and 193 deletions

View File

@ -45,6 +45,7 @@ import org.dolphinemu.dolphinemu.fragments.SaveLoadStateFragment;
import org.dolphinemu.dolphinemu.overlay.InputOverlay; import org.dolphinemu.dolphinemu.overlay.InputOverlay;
import org.dolphinemu.dolphinemu.overlay.InputOverlayPointer; import org.dolphinemu.dolphinemu.overlay.InputOverlayPointer;
import org.dolphinemu.dolphinemu.ui.main.MainActivity; 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.ui.main.TvMainActivity;
import org.dolphinemu.dolphinemu.utils.AfterDirectoryInitializationRunner; import org.dolphinemu.dolphinemu.utils.AfterDirectoryInitializationRunner;
import org.dolphinemu.dolphinemu.utils.ControllerMappingHelper; import org.dolphinemu.dolphinemu.utils.ControllerMappingHelper;
@ -164,6 +165,11 @@ public final class EmulationActivity extends AppCompatActivity
EmulationActivity.MENU_ACTION_MOTION_CONTROLS); 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) public static void launch(FragmentActivity activity, String[] filePaths)
{ {
if (sIgnoreLaunchRequests) 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 the user picked a file, as opposed to just backing out.
if (resultCode == MainActivity.RESULT_OK) if (resultCode == MainActivity.RESULT_OK)
{ {
String newDiscPath = FileBrowserHelper.getSelectedPath(result); NativeLibrary.ChangeDisc(result.getData().toString());
if (!TextUtils.isEmpty(newDiscPath))
{
NativeLibrary.ChangeDisc(newDiscPath);
}
} }
} }
} }
@ -639,8 +641,10 @@ public final class EmulationActivity extends AppCompatActivity
break; break;
case MENU_ACTION_CHANGE_DISC: case MENU_ACTION_CHANGE_DISC:
FileBrowserHelper.openFilePicker(this, REQUEST_CHANGE_DISC, false, Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
FileBrowserHelper.GAME_EXTENSIONS); intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
startActivityForResult(intent, REQUEST_CHANGE_DISC);
break; break;
case MENU_SET_IR_SENSITIVITY: case MENU_SET_IR_SENSITIVITY:

View File

@ -25,6 +25,8 @@ import org.dolphinemu.dolphinemu.ui.main.TvMainActivity;
import org.dolphinemu.dolphinemu.utils.FileBrowserHelper; import org.dolphinemu.dolphinemu.utils.FileBrowserHelper;
import org.dolphinemu.dolphinemu.utils.TvUtil; import org.dolphinemu.dolphinemu.utils.TvUtil;
import java.util.Set;
public final class SettingsActivity extends AppCompatActivity implements SettingsActivityView public final class SettingsActivity extends AppCompatActivity implements SettingsActivityView
{ {
private static final String ARG_MENU_TAG = "menu_tag"; 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 the user picked a file, as opposed to just backing out.
if (resultCode == MainActivity.RESULT_OK) if (resultCode == MainActivity.RESULT_OK)
{ {
if (requestCode == MainPresenter.REQUEST_SD_FILE) if (requestCode != MainPresenter.REQUEST_DIRECTORY)
{ {
Uri uri = canonicalizeIfPossible(result.getData()); 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); getContentResolver().takePersistableUriPermission(uri, takeFlags);
getFragment().getAdapter().onFilePickerConfirmation(uri.toString()); getFragment().getAdapter().onFilePickerConfirmation(uri.toString());

View File

@ -306,28 +306,17 @@ public final class SettingsAdapter extends RecyclerView.Adapter<SettingViewHolde
mClickedPosition = position; mClickedPosition = position;
FilePicker filePicker = (FilePicker) item; 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.putExtra(DocumentsContract.EXTRA_INITIAL_URI,
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); filePicker.getSelectedValue(mView.getSettings()));
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");
} }
mView.getActivity().startActivityForResult(intent, filePicker.getRequestType());
} }
public void onFilePickerConfirmation(String selectedFile) public void onFilePickerConfirmation(String selectedFile)

View File

@ -16,6 +16,9 @@ import java.io.File;
*/ */
public final class SettingsFile 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_TYPE = "SIDevice";
public static final String KEY_GCPAD_PLAYER_1 = "SIDevice0"; public static final String KEY_GCPAD_PLAYER_1 = "SIDevice0";
public static final String KEY_GCPAD_G_TYPE = "PadType"; public static final String KEY_GCPAD_G_TYPE = "PadType";

View File

@ -1,22 +1,19 @@
package org.dolphinemu.dolphinemu.model; package org.dolphinemu.dolphinemu.model;
import android.content.Context;
import android.content.SharedPreferences;
import android.preference.PreferenceManager;
import androidx.annotation.Keep; import androidx.annotation.Keep;
import org.dolphinemu.dolphinemu.NativeLibrary;
import org.dolphinemu.dolphinemu.features.settings.model.BooleanSetting; 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.io.File;
import java.util.HashSet; import java.util.LinkedHashSet;
import java.util.Set;
public class GameFileCache public class GameFileCache
{ {
private static final String GAME_FOLDER_PATHS_PREFERENCE = "gameFolderPaths";
private static final Set<String> EMPTY_SET = new HashSet<>();
@Keep @Keep
private long mPointer; private long mPointer;
@ -30,50 +27,71 @@ public class GameFileCache
@Override @Override
public native void finalize(); public native void finalize();
public static void addGameFolder(String path, Context context) public static void addGameFolder(String path)
{ {
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); File dolphinFile = SettingsFile.getSettingsFile(Settings.FILE_DOLPHIN);
Set<String> folderPaths = preferences.getStringSet(GAME_FOLDER_PATHS_PREFERENCE, EMPTY_SET); 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); File dolphinFile = SettingsFile.getSettingsFile(Settings.FILE_DOLPHIN);
Set<String> folderPaths = preferences.getStringSet(GAME_FOLDER_PATHS_PREFERENCE, EMPTY_SET); 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<>(); if (path.startsWith("content://") ? ContentHandler.exists(path) : new File(path).exists())
for (String folderPath : folderPaths)
{
File folder = new File(folderPath);
if (folder.exists())
{ {
newFolderPaths.add(folderPath); pathSet.add(path);
} }
} }
if (folderPaths.size() != newFolderPaths.size()) if (removeNonExistentFolders && totalISOPaths > pathSet.size())
{ {
// One or more folders are being deleted int setIndex = 0;
SharedPreferences.Editor editor = preferences.edit();
editor.putStringSet(GAME_FOLDER_PATHS_PREFERENCE, newFolderPaths); dolphinIni.setInt(Settings.SECTION_INI_GENERAL, SettingsFile.KEY_ISO_PATHS,
editor.apply(); 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 * @return true if the cache was modified
*/ */
public boolean scanLibrary(Context context) public boolean scanLibrary()
{ {
boolean recursiveScan = BooleanSetting.MAIN_RECURSIVE_ISO_PATHS.getBooleanGlobal(); boolean recursiveScan = BooleanSetting.MAIN_RECURSIVE_ISO_PATHS.getBooleanGlobal();
removeNonExistentGameFolders(context); LinkedHashSet<String> folderPathsSet = getPathSet(true);
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context);
Set<String> folderPathsSet = preferences.getStringSet(GAME_FOLDER_PATHS_PREFERENCE, EMPTY_SET);
if (folderPathsSet == null)
{
return false;
}
String[] folderPaths = folderPathsSet.toArray(new String[0]); String[] folderPaths = folderPathsSet.toArray(new String[0]);

View File

@ -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 final String ACTION_RESCAN = "org.dolphinemu.dolphinemu.RESCAN_GAME_FILE_CACHE";
private static GameFileCache gameFileCache = null; private static GameFileCache gameFileCache = null;
private static AtomicReference<GameFile[]> gameFiles = new AtomicReference<>(new GameFile[]{}); private static final AtomicReference<GameFile[]> gameFiles =
private static AtomicBoolean hasLoadedCache = new AtomicBoolean(false); new AtomicReference<>(new GameFile[]{});
private static AtomicBoolean hasScannedLibrary = new AtomicBoolean(false); private static final AtomicBoolean hasLoadedCache = new AtomicBoolean(false);
private static final AtomicBoolean hasScannedLibrary = new AtomicBoolean(false);
public GameFileCacheService() public GameFileCacheService()
{ {
@ -166,7 +167,7 @@ public final class GameFileCacheService extends IntentService
{ {
synchronized (gameFileCache) synchronized (gameFileCache)
{ {
boolean changed = gameFileCache.scanLibrary(this); boolean changed = gameFileCache.scanLibrary();
if (changed) if (changed)
updateGameFileArray(); updateGameFileArray();
hasScannedLibrary.set(true); hasScannedLibrary.set(true);

View File

@ -2,6 +2,7 @@ package org.dolphinemu.dolphinemu.ui.main;
import android.content.Intent; import android.content.Intent;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.view.Menu; import android.view.Menu;
import android.view.MenuInflater; import android.view.MenuInflater;
@ -45,7 +46,7 @@ public final class MainActivity extends AppCompatActivity implements MainView
private FloatingActionButton mFab; private FloatingActionButton mFab;
private static boolean sShouldRescanLibrary = true; private static boolean sShouldRescanLibrary = true;
private MainPresenter mPresenter = new MainPresenter(this, this); private final MainPresenter mPresenter = new MainPresenter(this, this);
@Override @Override
protected void onCreate(Bundle savedInstanceState) protected void onCreate(Bundle savedInstanceState)
@ -85,7 +86,7 @@ public final class MainActivity extends AppCompatActivity implements MainView
.run(this, false, this::setPlatformTabsAndStartGameFileCacheService); .run(this, false, this::setPlatformTabsAndStartGameFileCacheService);
} }
mPresenter.addDirIfNeeded(this); mPresenter.addDirIfNeeded();
// In case the user changed a setting that affects how games are displayed, // In case the user changed a setting that affects how games are displayed,
// such as system language, cover downloading... // such as system language, cover downloading...
@ -162,14 +163,17 @@ public final class MainActivity extends AppCompatActivity implements MainView
@Override @Override
public void launchFileListActivity() public void launchFileListActivity()
{ {
FileBrowserHelper.openDirectoryPicker(this, FileBrowserHelper.GAME_EXTENSIONS); Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
startActivityForResult(intent, MainPresenter.REQUEST_DIRECTORY);
} }
@Override @Override
public void launchOpenFileActivity() public void launchOpenFileActivity()
{ {
FileBrowserHelper.openFilePicker(this, MainPresenter.REQUEST_GAME_FILE, false, Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
FileBrowserHelper.GAME_EXTENSIONS); intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
startActivityForResult(intent, MainPresenter.REQUEST_GAME_FILE);
} }
@Override @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 the user picked a file, as opposed to just backing out.
if (resultCode == MainActivity.RESULT_OK) if (resultCode == MainActivity.RESULT_OK)
{ {
Uri uri = result.getData();
switch (requestCode) switch (requestCode)
{ {
case MainPresenter.REQUEST_DIRECTORY: case MainPresenter.REQUEST_DIRECTORY:
mPresenter.onDirectorySelected(FileBrowserHelper.getSelectedPath(result)); mPresenter.onDirectorySelected(result);
break; break;
case MainPresenter.REQUEST_GAME_FILE: 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; break;
case MainPresenter.REQUEST_WAD_FILE: case MainPresenter.REQUEST_WAD_FILE:
FileBrowserHelper.runAfterExtensionCheck(this, result.getData(), FileBrowserHelper.runAfterExtensionCheck(this, uri, FileBrowserHelper.WAD_EXTENSION,
FileBrowserHelper.WAD_EXTENSION,
() -> mPresenter.installWAD(result.getData().toString())); () -> mPresenter.installWAD(result.getData().toString()));
break; break;
} }

View File

@ -2,9 +2,11 @@ package org.dolphinemu.dolphinemu.ui.main;
import android.app.Activity; import android.app.Activity;
import android.content.BroadcastReceiver; import android.content.BroadcastReceiver;
import android.content.ContentResolver;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.IntentFilter; import android.content.IntentFilter;
import android.net.Uri;
import android.widget.Toast; import android.widget.Toast;
import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AlertDialog;
@ -13,10 +15,16 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import org.dolphinemu.dolphinemu.BuildConfig; import org.dolphinemu.dolphinemu.BuildConfig;
import org.dolphinemu.dolphinemu.NativeLibrary; import org.dolphinemu.dolphinemu.NativeLibrary;
import org.dolphinemu.dolphinemu.R; 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.features.settings.ui.MenuTag;
import org.dolphinemu.dolphinemu.model.GameFileCache; import org.dolphinemu.dolphinemu.model.GameFileCache;
import org.dolphinemu.dolphinemu.services.GameFileCacheService; import org.dolphinemu.dolphinemu.services.GameFileCacheService;
import org.dolphinemu.dolphinemu.utils.AfterDirectoryInitializationRunner; 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 public final class MainPresenter
{ {
@ -95,18 +103,40 @@ public final class MainPresenter
return false; return false;
} }
public void addDirIfNeeded(Context context) public void addDirIfNeeded()
{ {
if (mDirToAdd != null) if (mDirToAdd != null)
{ {
GameFileCache.addGameFolder(mDirToAdd, context); GameFileCache.addGameFolder(mDirToAdd);
mDirToAdd = null; 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) public void installWAD(String file)

View File

@ -2,6 +2,7 @@ package org.dolphinemu.dolphinemu.ui.main;
import android.content.Intent; import android.content.Intent;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.widget.Toast; import android.widget.Toast;
@ -39,11 +40,11 @@ public final class TvMainActivity extends FragmentActivity implements MainView
{ {
private static boolean sShouldRescanLibrary = true; private static boolean sShouldRescanLibrary = true;
private MainPresenter mPresenter = new MainPresenter(this, this); private final MainPresenter mPresenter = new MainPresenter(this, this);
private BrowseSupportFragment mBrowseFragment; private BrowseSupportFragment mBrowseFragment;
private ArrayList<ArrayObjectAdapter> mGameRows = new ArrayList<>(); private final ArrayList<ArrayObjectAdapter> mGameRows = new ArrayList<>();
@Override @Override
protected void onCreate(Bundle savedInstanceState) protected void onCreate(Bundle savedInstanceState)
@ -73,7 +74,7 @@ public final class TvMainActivity extends FragmentActivity implements MainView
GameFileCacheService.startLoad(this); GameFileCacheService.startLoad(this);
} }
mPresenter.addDirIfNeeded(this); mPresenter.addDirIfNeeded();
// In case the user changed a setting that affects how games are displayed, // In case the user changed a setting that affects how games are displayed,
// such as system language, cover downloading... // such as system language, cover downloading...
@ -167,14 +168,17 @@ public final class TvMainActivity extends FragmentActivity implements MainView
@Override @Override
public void launchFileListActivity() public void launchFileListActivity()
{ {
FileBrowserHelper.openDirectoryPicker(this, FileBrowserHelper.GAME_EXTENSIONS); Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
startActivityForResult(intent, MainPresenter.REQUEST_DIRECTORY);
} }
@Override @Override
public void launchOpenFileActivity() public void launchOpenFileActivity()
{ {
FileBrowserHelper.openFilePicker(this, MainPresenter.REQUEST_GAME_FILE, false, Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
FileBrowserHelper.GAME_EXTENSIONS); intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
startActivityForResult(intent, MainPresenter.REQUEST_GAME_FILE);
} }
@Override @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 the user picked a file, as opposed to just backing out.
if (resultCode == MainActivity.RESULT_OK) if (resultCode == MainActivity.RESULT_OK)
{ {
Uri uri = result.getData();
switch (requestCode) switch (requestCode)
{ {
case MainPresenter.REQUEST_DIRECTORY: case MainPresenter.REQUEST_DIRECTORY:
mPresenter.onDirectorySelected(FileBrowserHelper.getSelectedPath(result)); mPresenter.onDirectorySelected(result);
break; break;
case MainPresenter.REQUEST_GAME_FILE: 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; break;
case MainPresenter.REQUEST_WAD_FILE: case MainPresenter.REQUEST_WAD_FILE:
FileBrowserHelper.runAfterExtensionCheck(this, result.getData(), FileBrowserHelper.runAfterExtensionCheck(this, uri, FileBrowserHelper.WAD_EXTENSION,
FileBrowserHelper.WAD_EXTENSION,
() -> mPresenter.installWAD(result.getData().toString())); () -> mPresenter.installWAD(result.getData().toString()));
break; break;
} }

View File

@ -14,54 +14,76 @@ import androidx.annotation.Keep;
import org.dolphinemu.dolphinemu.DolphinApplication; import org.dolphinemu.dolphinemu.DolphinApplication;
import java.io.FileNotFoundException; 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 public class ContentHandler
{ {
@Keep @Keep
public static int openFd(String uri, String mode) public static int openFd(@NonNull String uri, @NonNull String mode)
{ {
try try
{ {
return getContentResolver().openFileDescriptor(Uri.parse(uri), mode).detachFd(); return getContentResolver().openFileDescriptor(unmangle(uri), mode).detachFd();
} }
catch (SecurityException e) catch (SecurityException e)
{ {
Log.error("Tried to open " + uri + " without permission"); Log.error("Tried to open " + uri + " without permission");
return -1;
} }
// Some content providers throw IllegalArgumentException for invalid modes, catch (Exception ignored)
// despite the documentation saying that invalid modes result in a FileNotFoundException
catch (FileNotFoundException | IllegalArgumentException | NullPointerException e)
{ {
return -1;
} }
return -1;
} }
@Keep @Keep
public static boolean delete(String uri) public static boolean delete(@NonNull String uri)
{ {
try try
{ {
return DocumentsContract.deleteDocument(getContentResolver(), Uri.parse(uri)); return DocumentsContract.deleteDocument(getContentResolver(), unmangle(uri));
}
catch (SecurityException e)
{
Log.error("Tried to delete " + uri + " without permission");
return false;
} }
catch (FileNotFoundException e) catch (FileNotFoundException e)
{ {
// Return true because we care about the file not being there, not the actual delete. // Return true because we care about the file not being there, not the actual delete.
return true; 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) public static boolean exists(@NonNull String uri)
{ {
try try
{ {
Uri documentUri = treeToDocument(unmangle(uri));
final String[] projection = new String[]{Document.COLUMN_MIME_TYPE, Document.COLUMN_SIZE}; 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; return cursor != null && cursor.getCount() > 0;
} }
@ -70,15 +92,65 @@ public class ContentHandler
{ {
Log.error("Tried to check if " + uri + " exists without permission"); Log.error("Tried to check if " + uri + " exists without permission");
} }
catch (Exception ignored)
{
}
return false; 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 @Nullable
public static String getDisplayName(@NonNull Uri uri) public static String getDisplayName(@NonNull Uri uri)
{ {
final String[] projection = new String[]{Document.COLUMN_DISPLAY_NAME}; 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()) if (cursor != null && cursor.moveToFirst())
{ {
@ -89,10 +161,257 @@ public class ContentHandler
{ {
Log.error("Tried to get display name of " + uri + " without permission"); Log.error("Tried to get display name of " + uri + " without permission");
} }
catch (Exception ignored)
{
}
return null; 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() private static ContentResolver getContentResolver()
{ {
return DolphinApplication.getAppContext().getContentResolver(); return DolphinApplication.getAppContext().getContentResolver();

View File

@ -28,7 +28,14 @@ import java.util.Set;
public final class FileBrowserHelper public final class FileBrowserHelper
{ {
public static final HashSet<String> GAME_EXTENSIONS = new HashSet<>(Arrays.asList( 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( public static final HashSet<String> RAW_EXTENSION = new HashSet<>(Collections.singletonList(
"raw")); "raw"));
@ -50,21 +57,6 @@ public final class FileBrowserHelper
activity.startActivityForResult(i, MainPresenter.REQUEST_DIRECTORY); 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 @Nullable
public static String getSelectedPath(Intent result) public static String getSelectedPath(Intent result)
{ {
@ -79,22 +71,6 @@ public final class FileBrowserHelper
return null; 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) public static boolean isPathEmptyOrValid(StringSetting path)
{ {
return isPathEmptyOrValid(path.getStringGlobal()); return isPathEmptyOrValid(path.getStringGlobal());
@ -112,10 +88,10 @@ public final class FileBrowserHelper
String path = uri.getLastPathSegment(); String path = uri.getLastPathSegment();
if (path != null) if (path != null)
extension = getExtension(new File(path).getName()); extension = getExtension(new File(path).getName(), false);
if (extension == null) if (extension == null)
extension = getExtension(ContentHandler.getDisplayName(uri)); extension = getExtension(ContentHandler.getDisplayName(uri), false);
if (extension != null && validExtensions.contains(extension)) if (extension != null && validExtensions.contains(extension))
{ {
@ -133,10 +109,8 @@ public final class FileBrowserHelper
int messageId = validExtensions.size() == 1 ? int messageId = validExtensions.size() == 1 ?
R.string.wrong_file_extension_single : R.string.wrong_file_extension_multiple; R.string.wrong_file_extension_single : R.string.wrong_file_extension_multiple;
ArrayList<String> extensionsList = new ArrayList<>(validExtensions); message = context.getString(messageId, extension,
Collections.sort(extensionsList); setToSortedDelimitedString(validExtensions));
message = context.getString(messageId, extension, join(", ", extensionsList));
} }
new AlertDialog.Builder(context, R.style.DolphinDialogBase) new AlertDialog.Builder(context, R.style.DolphinDialogBase)
@ -148,13 +122,22 @@ public final class FileBrowserHelper
} }
@Nullable @Nullable
private static String getExtension(@Nullable String fileName) public static String getExtension(@Nullable String fileName, boolean includeDot)
{ {
if (fileName == null) if (fileName == null)
return null; return null;
int dotIndex = fileName.lastIndexOf("."); 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 // TODO: Replace this with String.join once we can use Java 8

View File

@ -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="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_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_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> <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 --> <!-- Misc -->

View File

@ -4,6 +4,7 @@
#include "jni/AndroidCommon/AndroidCommon.h" #include "jni/AndroidCommon/AndroidCommon.h"
#include <ios>
#include <string> #include <string>
#include <string_view> #include <string_view>
#include <vector> #include <vector>
@ -43,6 +44,14 @@ std::vector<std::string> JStringArrayToVector(JNIEnv* env, jobjectArray array)
return result; 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) bool IsPathAndroidContent(const std::string& uri)
{ {
return StringBeginsWith(uri, "content://"); return StringBeginsWith(uri, "content://");
@ -66,6 +75,28 @@ std::string OpenModeToAndroid(std::string mode)
return 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) int OpenAndroidContent(const std::string& uri, const std::string& mode)
{ {
JNIEnv* env = IDCache::GetEnvForThread(); JNIEnv* env = IDCache::GetEnvForThread();
@ -81,6 +112,43 @@ bool DeleteAndroidContent(const std::string& uri)
IDCache::GetContentHandlerDelete(), ToJString(env, 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() int GetNetworkIpAddress()
{ {
JNIEnv* env = IDCache::GetEnvForThread(); JNIEnv* env = IDCache::GetEnvForThread();

View File

@ -4,7 +4,9 @@
#pragma once #pragma once
#include <ios>
#include <string> #include <string>
#include <vector>
#include <jni.h> #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. // 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::string mode);
std::string OpenModeToAndroid(std::ios_base::openmode mode);
// Opens a given file and returns a file descriptor. // Opens a given file and returns a file descriptor.
int OpenAndroidContent(const std::string& uri, const std::string& mode); int OpenAndroidContent(const std::string& uri, const std::string& mode);
// Deletes a given file. // Deletes a given file.
bool DeleteAndroidContent(const std::string& uri); 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 GetNetworkIpAddress();
int GetNetworkPrefixLength(); int GetNetworkPrefixLength();
int GetNetworkGateway(); int GetNetworkGateway();

View File

@ -10,6 +10,8 @@ static constexpr jint JNI_VERSION = JNI_VERSION_1_6;
static JavaVM* s_java_vm; static JavaVM* s_java_vm;
static jclass s_string_class;
static jclass s_native_library_class; static jclass s_native_library_class;
static jmethodID s_display_alert_msg; static jmethodID s_display_alert_msg;
static jmethodID s_do_rumble; static jmethodID s_do_rumble;
@ -44,6 +46,10 @@ static jmethodID s_compress_cb_run;
static jclass s_content_handler_class; static jclass s_content_handler_class;
static jmethodID s_content_handler_open_fd; static jmethodID s_content_handler_open_fd;
static jmethodID s_content_handler_delete; 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 jclass s_network_helper_class;
static jmethodID s_network_helper_get_network_ip_address; static jmethodID s_network_helper_get_network_ip_address;
@ -75,6 +81,11 @@ JNIEnv* GetEnvForThread()
return owned.env; return owned.env;
} }
jclass GetStringClass()
{
return s_string_class;
}
jclass GetNativeLibraryClass() jclass GetNativeLibraryClass()
{ {
return s_native_library_class; return s_native_library_class;
@ -210,6 +221,26 @@ jmethodID GetContentHandlerDelete()
return s_content_handler_delete; 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() jclass GetNetworkHelperClass()
{ {
return s_network_helper_class; return s_network_helper_class;
@ -229,6 +260,7 @@ jmethodID GetNetworkHelperGetNetworkGateway()
{ {
return s_network_helper_get_network_gateway; return s_network_helper_get_network_gateway;
} }
} // namespace IDCache } // namespace IDCache
#ifdef __cplusplus #ifdef __cplusplus
@ -243,6 +275,9 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved)
if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION) != JNI_OK) if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION) != JNI_OK)
return JNI_ERR; 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"); const jclass native_library_class = env->FindClass("org/dolphinemu/dolphinemu/NativeLibrary");
s_native_library_class = reinterpret_cast<jclass>(env->NewGlobalRef(native_library_class)); s_native_library_class = reinterpret_cast<jclass>(env->NewGlobalRef(native_library_class));
s_display_alert_msg = env->GetStaticMethodID(s_native_library_class, "displayAlertMsg", 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"); "(Ljava/lang/String;Ljava/lang/String;)I");
s_content_handler_delete = s_content_handler_delete =
env->GetStaticMethodID(s_content_handler_class, "delete", "(Ljava/lang/String;)Z"); 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 = const jclass network_helper_class =
env->FindClass("org/dolphinemu/dolphinemu/utils/NetworkHelper"); env->FindClass("org/dolphinemu/dolphinemu/utils/NetworkHelper");

View File

@ -10,6 +10,8 @@ namespace IDCache
{ {
JNIEnv* GetEnvForThread(); JNIEnv* GetEnvForThread();
jclass GetStringClass();
jclass GetNativeLibraryClass(); jclass GetNativeLibraryClass();
jmethodID GetDisplayAlertMsg(); jmethodID GetDisplayAlertMsg();
jmethodID GetDoRumble(); jmethodID GetDoRumble();
@ -44,6 +46,10 @@ jmethodID GetCompressCallbackRun();
jclass GetContentHandlerClass(); jclass GetContentHandlerClass();
jmethodID GetContentHandlerOpenFd(); jmethodID GetContentHandlerOpenFd();
jmethodID GetContentHandlerDelete(); jmethodID GetContentHandlerDelete();
jmethodID GetContentHandlerGetSizeAndIsDirectory();
jmethodID GetContentHandlerGetDisplayName();
jmethodID GetContentHandlerGetChildNames();
jmethodID GetContentHandlerDoFileSearch();
jclass GetNetworkHelperClass(); jclass GetNetworkHelperClass();
jmethodID GetNetworkHelperGetNetworkIpAddress(); jmethodID GetNetworkHelperGetNetworkIpAddress();

View File

@ -4,6 +4,7 @@
#include <algorithm> #include <algorithm>
#include <functional> #include <functional>
#include <iterator>
#include "Common/CommonPaths.h" #include "Common/CommonPaths.h"
#include "Common/FileSearch.h" #include "Common/FileSearch.h"
@ -15,6 +16,10 @@
namespace fs = std::filesystem; namespace fs = std::filesystem;
#define HAS_STD_FILESYSTEM #define HAS_STD_FILESYSTEM
#else #else
#ifdef ANDROID
#include "jni/AndroidCommon/AndroidCommon.h"
#endif
#include <cstring> #include <cstring>
#include "Common/CommonFuncs.h" #include "Common/CommonFuncs.h"
#include "Common/FileUtil.h" #include "Common/FileUtil.h"
@ -24,36 +29,30 @@ namespace Common
{ {
#ifndef HAS_STD_FILESYSTEM #ifndef HAS_STD_FILESYSTEM
static std::vector<std::string> static void FileSearchWithTest(const std::string& directory, bool recursive,
FileSearchWithTest(const std::vector<std::string>& directories, bool recursive, std::vector<std::string>* result_out,
std::function<bool(const File::FSTEntry&)> callback) std::function<bool(const File::FSTEntry&)> callback)
{ {
std::vector<std::string> result; File::FSTEntry top = File::ScanDirectoryTree(directory, recursive);
for (const std::string& directory : directories)
{
File::FSTEntry top = File::ScanDirectoryTree(directory, recursive);
std::function<void(File::FSTEntry&)> DoEntry; const std::function<void(File::FSTEntry&)> DoEntry = [&](File::FSTEntry& entry) {
DoEntry = [&](File::FSTEntry& entry) { if (callback(entry))
if (callback(entry)) result_out->push_back(entry.physicalName);
result.push_back(entry.physicalName); for (auto& child : entry.children)
for (auto& child : entry.children)
DoEntry(child);
};
for (auto& child : top.children)
DoEntry(child); DoEntry(child);
} };
// remove duplicates
std::sort(result.begin(), result.end()); for (auto& child : top.children)
result.erase(std::unique(result.begin(), result.end()), result.end()); DoEntry(child);
return result;
} }
std::vector<std::string> DoFileSearch(const std::vector<std::string>& directories, std::vector<std::string> DoFileSearch(const std::vector<std::string>& directories,
const std::vector<std::string>& exts, bool recursive) const std::vector<std::string>& exts, bool recursive)
{ {
std::vector<std::string> result;
bool accept_all = exts.empty(); 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) if (accept_all)
return true; return true;
if (entry.isDirectory) if (entry.isDirectory)
@ -63,7 +62,34 @@ std::vector<std::string> DoFileSearch(const std::vector<std::string>& directorie
return name.length() >= ext.length() && return name.length() >= ext.length() &&
strcasecmp(name.c_str() + name.length() - ext.length(), ext.c_str()) == 0; 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 #else

View File

@ -78,19 +78,40 @@ FileInfo::FileInfo(const char* path) : FileInfo(std::string(path))
#else #else
FileInfo::FileInfo(const std::string& path) : FileInfo(path.c_str()) 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) 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 #endif
FileInfo::FileInfo(int fd) 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 bool FileInfo::Exists() const
{ {
return m_exists; return m_exists;
@ -476,14 +497,47 @@ FSTEntry ScanDirectoryTree(const std::string& directory, bool recursive)
{ {
const std::string virtual_name(TStrToUTF8(ffd.cFileName)); const std::string virtual_name(TStrToUTF8(ffd.cFileName));
#else #else
DIR* dirp = opendir(directory.c_str()); DIR* dirp = nullptr;
if (!dirp)
return parent_entry; #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 // 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 #endif
if (virtual_name == "." || virtual_name == "..") if (virtual_name == "." || virtual_name == "..")
continue; continue;
@ -514,7 +568,8 @@ FSTEntry ScanDirectoryTree(const std::string& directory, bool recursive)
FindClose(hFind); FindClose(hFind);
#else #else
} }
closedir(dirp); if (dirp)
closedir(dirp);
#endif #endif
return parent_entry; return parent_entry;

View File

@ -18,6 +18,11 @@
#include "Common/StringUtil.h" #include "Common/StringUtil.h"
#endif #endif
#ifdef ANDROID
#include "Common/StringUtil.h"
#include "jni/AndroidCommon/AndroidCommon.h"
#endif
// User directory indices for GetUserPath // User directory indices for GetUserPath
enum enum
{ {
@ -109,6 +114,10 @@ public:
u64 GetSize() const; u64 GetSize() const;
private: private:
#ifdef ANDROID
void AndroidContentInit(const std::string& path);
#endif
struct stat m_stat; struct stat m_stat;
bool m_exists; bool m_exists;
}; };
@ -214,14 +223,20 @@ std::string GetExeDirectory();
bool WriteStringToFile(const std::string& filename, std::string_view str); bool WriteStringToFile(const std::string& filename, std::string_view str);
bool ReadFileToString(const std::string& filename, std::string& 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> template <typename T>
void OpenFStream(T& fstream, const std::string& filename, std::ios_base::openmode openmode) void OpenFStream(T& fstream, const std::string& filename, std::ios_base::openmode openmode)
{ {
#ifdef _WIN32 #ifdef _WIN32
fstream.open(UTF8ToTStr(filename).c_str(), openmode); fstream.open(UTF8ToTStr(filename).c_str(), openmode);
#else #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 #endif
} }

View File

@ -158,6 +158,15 @@ BootParameters::GenerateFromFile(std::vector<std::string> paths,
if (paths.size() == 1) if (paths.size() == 1)
paths.clear(); 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 = { static const std::unordered_set<std::string> disc_image_extensions = {
{".gcm", ".iso", ".tgc", ".wbfs", ".ciso", ".gcz", ".wia", ".rvz", ".dol", ".elf"}}; {".gcm", ".iso", ".tgc", ".wbfs", ".ciso", ".gcz", ".wia", ".rvz", ".dol", ".elf"}};
if (disc_image_extensions.find(extension) != disc_image_extensions.end() || is_drive) if (disc_image_extensions.find(extension) != disc_image_extensions.end() || is_drive)