diff options
| author | JosJuice <josjuice@gmail.com> | 2020-12-30 11:10:35 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2020-12-30 11:10:35 +0100 |
| commit | c1d041b8886e60400721b827dcf2eb7cce45eceb (patch) | |
| tree | 97cb04428e3338c03e9a82db27fc411e2c2243de /Source/Android/app/src/main/java | |
| parent | 8a3b14d7dca147232a87b51cef12840656292171 (diff) | |
| parent | d78277c063d43e3234fe80a38d272500f3dfce3a (diff) | |
Merge pull request #9318 from JosJuice/android-saf-games
Android: Use storage access framework for game list
Diffstat (limited to 'Source/Android/app/src/main/java')
11 files changed, 519 insertions, 160 deletions
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/EmulationActivity.java b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/EmulationActivity.java index 2d7688bbaf..9cb2406cb1 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/EmulationActivity.java +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/EmulationActivity.java @@ -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: diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsActivity.java b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsActivity.java index 02c2040f7b..df21cec99c 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsActivity.java +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsActivity.java @@ -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()); diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsAdapter.java b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsAdapter.java index 3442c72fa5..c82a13e2cc 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsAdapter.java +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsAdapter.java @@ -306,28 +306,17 @@ public final class SettingsAdapter extends RecyclerView.Adapter<SettingViewHolde mClickedPosition = position; FilePicker filePicker = (FilePicker) item; - switch (filePicker.getRequestType()) - { - 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())); - } + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("*/*"); - 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"); + 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()); } public void onFilePickerConfirmation(String selectedFile) diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/utils/SettingsFile.java b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/utils/SettingsFile.java index e65805fd95..82ea3a84f1 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/utils/SettingsFile.java +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/utils/SettingsFile.java @@ -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"; diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/model/GameFileCache.java b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/model/GameFileCache.java index 08a999bd32..a15e734d0b 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/model/GameFileCache.java +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/model/GameFileCache.java @@ -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]); diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/services/GameFileCacheService.java b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/services/GameFileCacheService.java index ff37ccac4b..ab295c5550 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/services/GameFileCacheService.java +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/services/GameFileCacheService.java @@ -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); diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/MainActivity.java b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/MainActivity.java index bd95809f63..e5150758d4 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/MainActivity.java +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/MainActivity.java @@ -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; } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/MainPresenter.java b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/MainPresenter.java index 8db0ee44a1..cc06689c9b 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/MainPresenter.java +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/MainPresenter.java @@ -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) diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/TvMainActivity.java b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/TvMainActivity.java index c7bc923461..1141b98dbf 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/TvMainActivity.java +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/TvMainActivity.java @@ -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; } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/ContentHandler.java b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/ContentHandler.java index dbeb410079..52600fe488 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/ContentHandler.java +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/ContentHandler.java @@ -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)); + 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"); - return false; } - catch (FileNotFoundException e) + catch (Exception ignored) { - // Return true because we care about the file not being there, not the actual delete. - return true; } + + 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(); diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/FileBrowserHelper.java b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/FileBrowserHelper.java index 79ec49deae..f68bc46857 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/FileBrowserHelper.java +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/FileBrowserHelper.java @@ -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 |
