From ef605f7d8f8241b95b977d95cf5247c1f2d8a309 Mon Sep 17 00:00:00 2001 From: bunnei Date: Fri, 3 Feb 2023 16:13:16 -0800 Subject: [PATCH] android: Implement SAF support & migrate to SDK 31. (#4) --- src/android/app/build.gradle | 6 +- src/android/app/src/main/AndroidManifest.xml | 13 +- .../java/org/yuzu/yuzu_emu/NativeLibrary.java | 22 +- .../org/yuzu/yuzu_emu/YuzuApplication.java | 8 +- .../activities/CustomFilePickerActivity.java | 38 --- .../yuzu/yuzu_emu/adapters/GameAdapter.java | 9 +- .../settings/ui/SettingsActivity.java | 6 - .../ui/SettingsActivityPresenter.java | 3 - .../settings/ui/SettingsActivityView.java | 5 - .../fragments/CustomFilePickerFragment.java | 120 -------- .../yuzu_emu/fragments/EmulationFragment.java | 4 - .../org/yuzu/yuzu_emu/model/GameDatabase.java | 48 ++-- .../yuzu_emu/model/MinimalDocumentFile.java | 28 ++ .../yuzu/yuzu_emu/ui/main/MainActivity.java | 83 ++---- .../yuzu/yuzu_emu/ui/main/MainPresenter.java | 4 +- .../utils/DirectoryInitialization.java | 132 +-------- .../yuzu/yuzu_emu/utils/DocumentsTree.java | 125 ++++++++ .../yuzu_emu/utils/FileBrowserHelper.java | 65 +---- .../org/yuzu/yuzu_emu/utils/FileUtil.java | 270 ++++++++++++++++-- .../yuzu_emu/utils/PermissionsHandler.java | 35 --- .../yuzu/yuzu_emu/utils/StartupHandler.java | 44 ++- src/android/app/src/main/jni/config.cpp | 29 +- src/android/app/src/main/jni/id_cache.cpp | 46 +++ src/android/app/src/main/jni/native.cpp | 12 +- src/android/app/src/main/jni/native.h | 95 +++--- .../main/res/layout/filepicker_toolbar.xml | 32 --- .../res/values-night/styles_filepicker.xml | 5 - .../src/main/res/values-w1050dp/dimens.xml | 1 - .../app/src/main/res/values-w820dp/dimens.xml | 1 - .../app/src/main/res/values/strings.xml | 3 +- .../app/src/main/res/values/styles.xml | 16 -- .../src/main/res/values/styles_filepicker.xml | 5 - src/common/CMakeLists.txt | 8 + src/common/fs/file.cpp | 38 +++ src/common/fs/fs_android.cpp | 98 +++++++ src/common/fs/fs_android.h | 62 ++++ src/common/fs/path_util.cpp | 31 +- src/common/fs/path_util.h | 8 + 38 files changed, 856 insertions(+), 702 deletions(-) delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/CustomFilePickerActivity.java delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/CustomFilePickerFragment.java create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MinimalDocumentFile.java create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DocumentsTree.java delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/PermissionsHandler.java delete mode 100644 src/android/app/src/main/res/layout/filepicker_toolbar.xml delete mode 100644 src/android/app/src/main/res/values-night/styles_filepicker.xml delete mode 100644 src/android/app/src/main/res/values/styles_filepicker.xml create mode 100644 src/common/fs/fs_android.cpp create mode 100644 src/common/fs/fs_android.h diff --git a/src/android/app/build.gradle b/src/android/app/build.gradle index ffbadce14..c516b2bff 100644 --- a/src/android/app/build.gradle +++ b/src/android/app/build.gradle @@ -32,7 +32,7 @@ android { // TODO If this is ever modified, change application_id in strings.xml applicationId "org.yuzu.yuzu_emu" minSdkVersion 28 - targetSdkVersion 29 + targetSdkVersion 31 versionCode autoVersion versionName getVersion() ndk.abiFilters abiFilter @@ -126,6 +126,7 @@ dependencies { implementation 'androidx.lifecycle:lifecycle-viewmodel:2.5.1' implementation 'androidx.fragment:fragment:1.5.3' implementation "androidx.slidingpanelayout:slidingpanelayout:1.2.0" + implementation "androidx.documentfile:documentfile:1.0.1" implementation 'com.google.android.material:material:1.6.1' // For loading huge screenshots from the disk. @@ -138,9 +139,6 @@ dependencies { implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' - - // Please don't upgrade the billing library as the newer version is not GPL-compatible - implementation 'com.android.billingclient:billing:2.0.3' } def getVersion() { diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml index 0d7e3f7ad..88e1669cd 100644 --- a/src/android/app/src/main/AndroidManifest.xml +++ b/src/android/app/src/main/AndroidManifest.xml @@ -31,6 +31,7 @@ @@ -57,18 +58,6 @@ - - - - - - - - - getFragment( - @Nullable final String startPath, final int mode, final boolean allowMultiple, - final boolean allowCreateDir, final boolean allowExistingFile, - final boolean singleClick) { - CustomFilePickerFragment fragment = new CustomFilePickerFragment(); - // startPath is allowed to be null. In that case, default folder should be SD-card and not "/" - fragment.setArgs( - startPath != null ? startPath : Environment.getExternalStorageDirectory().getPath(), - mode, allowMultiple, allowCreateDir, allowExistingFile, singleClick); - - Intent intent = getIntent(); - int title = intent == null ? 0 : intent.getIntExtra(EXTRA_TITLE, 0); - fragment.setTitle(title); - String allowedExtensions = intent == null ? "*" : intent.getStringExtra(EXTRA_EXTENSIONS); - fragment.setAllowedExtensions(allowedExtensions); - - return fragment; - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.java b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.java index fa785741b..cd9f823d4 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.java +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.java @@ -16,16 +16,16 @@ import androidx.core.content.ContextCompat; import androidx.fragment.app.FragmentActivity; import androidx.recyclerview.widget.RecyclerView; +import org.yuzu.yuzu_emu.YuzuApplication; import org.yuzu.yuzu_emu.R; import org.yuzu.yuzu_emu.activities.EmulationActivity; import org.yuzu.yuzu_emu.model.GameDatabase; import org.yuzu.yuzu_emu.ui.DividerItemDecoration; +import org.yuzu.yuzu_emu.utils.FileUtil; import org.yuzu.yuzu_emu.utils.Log; import org.yuzu.yuzu_emu.utils.PicassoUtils; import org.yuzu.yuzu_emu.viewholders.GameViewHolder; -import java.nio.file.Path; -import java.nio.file.Paths; import java.util.stream.Stream; /** @@ -88,8 +88,9 @@ public final class GameAdapter extends RecyclerView.Adapter impl holder.textGameTitle.setText(mCursor.getString(GameDatabase.GAME_COLUMN_TITLE).replaceAll("[\\t\\n\\r]+", " ")); holder.textCompany.setText(mCursor.getString(GameDatabase.GAME_COLUMN_COMPANY)); - final Path gamePath = Paths.get(mCursor.getString(GameDatabase.GAME_COLUMN_PATH)); - holder.textFileName.setText(gamePath.getFileName().toString()); + String filepath = mCursor.getString(GameDatabase.GAME_COLUMN_PATH); + String filename = FileUtil.getFilename(YuzuApplication.getAppContext(), filepath); + holder.textFileName.setText(filename); // TODO These shouldn't be necessary once the move to a DB-based model is complete. holder.gameId = mCursor.getString(GameDatabase.GAME_COLUMN_GAME_ID); diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.java b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.java index 916ced382..0a1323a1f 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.java +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.java @@ -159,12 +159,6 @@ public final class SettingsActivity extends AppCompatActivity implements Setting dialog.dismiss(); } - @Override - public void showPermissionNeededHint() { - Toast.makeText(this, R.string.write_permission_needed, Toast.LENGTH_SHORT) - .show(); - } - @Override public void showExternalStorageNotMountedHint() { Toast.makeText(this, R.string.external_storage_not_mounted, Toast.LENGTH_SHORT) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityPresenter.java b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityPresenter.java index ba6b6762b..25b7758a9 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityPresenter.java +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityPresenter.java @@ -78,9 +78,6 @@ public final class SettingsActivityPresenter { if (directoryInitializationState == DirectoryInitializationState.YUZU_DIRECTORIES_INITIALIZED) { mView.hideLoading(); loadSettingsUI(); - } else if (directoryInitializationState == DirectoryInitializationState.EXTERNAL_STORAGE_PERMISSION_NEEDED) { - mView.showPermissionNeededHint(); - mView.hideLoading(); } else if (directoryInitializationState == DirectoryInitializationState.CANT_FIND_EXTERNAL_STORAGE) { mView.showExternalStorageNotMountedHint(); mView.hideLoading(); diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityView.java b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityView.java index 5aff3bcf7..58ccf31b7 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityView.java +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityView.java @@ -76,11 +76,6 @@ public interface SettingsActivityView { */ void hideLoading(); - /** - * Show a hint to the user that the app needs write to external storage access - */ - void showPermissionNeededHint(); - /** * Show a hint to the user that the app needs the external storage to be mounted */ diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/CustomFilePickerFragment.java b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/CustomFilePickerFragment.java deleted file mode 100644 index 2658b1445..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/CustomFilePickerFragment.java +++ /dev/null @@ -1,120 +0,0 @@ -package org.yuzu.yuzu_emu.fragments; - -import android.net.Uri; -import android.os.Bundle; -import android.os.Environment; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.appcompat.widget.Toolbar; -import androidx.core.content.FileProvider; - -import com.nononsenseapps.filepicker.FilePickerFragment; - -import org.yuzu.yuzu_emu.R; - -import java.io.File; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -public class CustomFilePickerFragment extends FilePickerFragment { - private static String ALL_FILES = "*"; - private int mTitle; - private static List extensions = Collections.singletonList(ALL_FILES); - - @NonNull - @Override - public Uri toUri(@NonNull final File file) { - return FileProvider - .getUriForFile(getContext(), - getContext().getApplicationContext().getPackageName() + ".filesprovider", - file); - } - - @Override - public void onActivityCreated(Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - - if (mode == MODE_DIR) { - TextView ok = getActivity().findViewById(R.id.nnf_button_ok); - ok.setText(R.string.select_dir); - - TextView cancel = getActivity().findViewById(R.id.nnf_button_cancel); - cancel.setVisibility(View.GONE); - } - } - - @Override - protected View inflateRootView(LayoutInflater inflater, ViewGroup container) { - View view = super.inflateRootView(inflater, container); - if (mTitle != 0) { - Toolbar toolbar = view.findViewById(com.nononsenseapps.filepicker.R.id.nnf_picker_toolbar); - ViewGroup parent = (ViewGroup) toolbar.getParent(); - int index = parent.indexOfChild(toolbar); - View newToolbar = inflater.inflate(R.layout.filepicker_toolbar, toolbar, false); - TextView title = newToolbar.findViewById(R.id.filepicker_title); - title.setText(mTitle); - parent.removeView(toolbar); - parent.addView(newToolbar, index); - } - return view; - } - - public void setTitle(int title) { - mTitle = title; - } - - public void setAllowedExtensions(String allowedExtensions) { - if (allowedExtensions == null) - return; - - extensions = Arrays.asList(allowedExtensions.split(",")); - } - - @Override - protected boolean isItemVisible(@NonNull final File file) { - // Some users jump to the conclusion that Dolphin isn't able to detect their - // files if the files don't show up in the file picker when mode == MODE_DIR. - // To avoid this, show files even when the user needs to select a directory. - return (showHiddenItems || !file.isHidden()) && - (file.isDirectory() || extensions.contains(ALL_FILES) || - extensions.contains(fileExtension(file.getName()).toLowerCase())); - } - - @Override - public boolean isCheckable(@NonNull final File file) { - // We need to make a small correction to the isCheckable logic due to - // overriding isItemVisible to show files when mode == MODE_DIR. - // AbstractFilePickerFragment always treats files as checkable when - // allowExistingFile == true, but we don't want files to be checkable when mode == MODE_DIR. - return super.isCheckable(file) && !(mode == MODE_DIR && file.isFile()); - } - - @Override - public void goUp() { - if (Environment.getExternalStorageDirectory().getPath().equals(mCurrentPath.getPath())) { - goToDir(new File("/storage/")); - return; - } - if (mCurrentPath.equals(new File("/storage/"))){ - return; - } - super.goUp(); - } - - @Override - public void onClickDir(@NonNull View view, @NonNull DirViewHolder viewHolder) { - if(viewHolder.file.equals(new File("/storage/emulated/"))) - viewHolder.file = new File("/storage/emulated/0/"); - super.onClickDir(view, viewHolder); - } - - private static String fileExtension(@NonNull String filename) { - int i = filename.lastIndexOf('.'); - return i < 0 ? "" : filename.substring(i + 1); - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.java b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.java index f7a242171..32f077944 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.java +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.java @@ -155,10 +155,6 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C if (directoryInitializationState == DirectoryInitializationState.YUZU_DIRECTORIES_INITIALIZED) { mEmulationState.run(activity.isActivityRecreated()); - } else if (directoryInitializationState == - DirectoryInitializationState.EXTERNAL_STORAGE_PERMISSION_NEEDED) { - Toast.makeText(getContext(), R.string.write_permission_needed, Toast.LENGTH_SHORT) - .show(); } else if (directoryInitializationState == DirectoryInitializationState.CANT_FIND_EXTERNAL_STORAGE) { Toast.makeText(getContext(), R.string.external_storage_not_mounted, diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameDatabase.java b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameDatabase.java index ac5db1c36..771e35c69 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameDatabase.java +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameDatabase.java @@ -5,8 +5,10 @@ import android.content.Context; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; +import android.net.Uri; import org.yuzu.yuzu_emu.NativeLibrary; +import org.yuzu.yuzu_emu.utils.FileUtil; import org.yuzu.yuzu_emu.utils.Log; import java.io.File; @@ -63,10 +65,12 @@ public final class GameDatabase extends SQLiteOpenHelper { private static final String SQL_DELETE_FOLDERS = "DROP TABLE IF EXISTS " + TABLE_NAME_FOLDERS; private static final String SQL_DELETE_GAMES = "DROP TABLE IF EXISTS " + TABLE_NAME_GAMES; + private final Context context; public GameDatabase(Context context) { // Superclass constructor builds a database or uses an existing one. super(context, "games.db", null, DB_VERSION); + this.context = context; } @Override @@ -123,8 +127,6 @@ public final class GameDatabase extends SQLiteOpenHelper { File game = new File(gamePath); if (!game.exists()) { - Log.error("[GameDatabase] Game file no longer exists. Removing from the library: " + - gamePath); database.delete(TABLE_NAME_GAMES, KEY_DB_ID + " = ?", new String[]{Long.toString(fileCursor.getLong(COLUMN_DB_ID))}); @@ -150,9 +152,9 @@ public final class GameDatabase extends SQLiteOpenHelper { while (folderCursor.moveToNext()) { String folderPath = folderCursor.getString(FOLDER_COLUMN_PATH); - File folder = new File(folderPath); + Uri folderUri = Uri.parse(folderPath); // If the folder is empty because it no longer exists, remove it from the library. - if (!folder.exists()) { + if (FileUtil.listFiles(context, folderUri).length == 0) { Log.error( "[GameDatabase] Folder no longer exists. Removing from the library: " + folderPath); database.delete(TABLE_NAME_FOLDERS, @@ -160,7 +162,7 @@ public final class GameDatabase extends SQLiteOpenHelper { new String[]{Long.toString(folderCursor.getLong(COLUMN_DB_ID))}); } - addGamesRecursive(database, folder, allowedExtensions, 3); + this.addGamesRecursive(database, folderUri, allowedExtensions, 3); } fileCursor.close(); @@ -169,33 +171,27 @@ public final class GameDatabase extends SQLiteOpenHelper { database.close(); } - private static void addGamesRecursive(SQLiteDatabase database, File parent, Set allowedExtensions, int depth) { + private void addGamesRecursive(SQLiteDatabase database, Uri parent, Set allowedExtensions, int depth) { if (depth <= 0) { return; } - File[] children = parent.listFiles(); - if (children != null) { - for (File file : children) { - if (file.isHidden()) { - continue; - } + MinimalDocumentFile[] children = FileUtil.listFiles(context, parent); + for (MinimalDocumentFile file : children) { + if (file.isDirectory()) { + Set newExtensions = new HashSet<>(Arrays.asList( + ".xci", ".nsp", ".nca", ".nro")); + this.addGamesRecursive(database, file.getUri(), newExtensions, depth - 1); + } else { + String filename = file.getUri().toString(); - if (file.isDirectory()) { - Set newExtensions = new HashSet<>(Arrays.asList( - ".xci", ".nsp", ".nca", ".nro")); - addGamesRecursive(database, file, newExtensions, depth - 1); - } else { - String filePath = file.getPath(); + int extensionStart = filename.lastIndexOf('.'); + if (extensionStart > 0) { + String fileExtension = filename.substring(extensionStart); - int extensionStart = filePath.lastIndexOf('.'); - if (extensionStart > 0) { - String fileExtension = filePath.substring(extensionStart); - - // Check that the file has an extension we care about before trying to read out of it. - if (allowedExtensions.contains(fileExtension.toLowerCase())) { - attemptToAddGame(database, filePath); - } + // Check that the file has an extension we care about before trying to read out of it. + if (allowedExtensions.contains(fileExtension.toLowerCase())) { + attemptToAddGame(database, filename); } } } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MinimalDocumentFile.java b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MinimalDocumentFile.java new file mode 100644 index 000000000..4ec001a7f --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MinimalDocumentFile.java @@ -0,0 +1,28 @@ +package org.yuzu.yuzu_emu.model; + +import android.net.Uri; +import android.provider.DocumentsContract; + +public class MinimalDocumentFile { + private final String filename; + private final Uri uri; + private final String mimeType; + + public MinimalDocumentFile(String filename, String mimeType, Uri uri) { + this.filename = filename; + this.mimeType = mimeType; + this.uri = uri; + } + + public String getFilename() { + return filename; + } + + public Uri getUri() { + return uri; + } + + public boolean isDirectory() { + return mimeType.equals(DocumentsContract.Document.MIME_TYPE_DIR); + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.java b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.java index d419750a3..26ff14914 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.java +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.java @@ -1,12 +1,11 @@ package org.yuzu.yuzu_emu.ui.main; import android.content.Intent; -import android.content.pm.PackageManager; +import android.net.Uri; import android.os.Bundle; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; -import android.widget.Toast; import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; @@ -18,16 +17,11 @@ import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity; import org.yuzu.yuzu_emu.model.GameProvider; import org.yuzu.yuzu_emu.ui.platform.PlatformGamesFragment; import org.yuzu.yuzu_emu.utils.AddDirectoryHelper; -import org.yuzu.yuzu_emu.utils.DirectoryInitialization; import org.yuzu.yuzu_emu.utils.FileBrowserHelper; -import org.yuzu.yuzu_emu.utils.PermissionsHandler; import org.yuzu.yuzu_emu.utils.PicassoUtils; import org.yuzu.yuzu_emu.utils.StartupHandler; import org.yuzu.yuzu_emu.utils.ThemeUtil; -import java.util.Arrays; -import java.util.Collections; - /** * The main Activity of the Lollipop style UI. Manages several PlatformGamesFragments, which * individually display a grid of available games for each Fragment, in a tabbed layout. @@ -54,12 +48,9 @@ public final class MainActivity extends AppCompatActivity implements MainView { mPresenter.onCreate(); if (savedInstanceState == null) { - StartupHandler.HandleInit(this); - if (PermissionsHandler.hasWriteAccess(this)) { - mPlatformGamesFragment = new PlatformGamesFragment(); - getSupportFragmentManager().beginTransaction().add(mFrameLayoutId, mPlatformGamesFragment) - .commit(); - } + StartupHandler.handleInit(this); + mPlatformGamesFragment = new PlatformGamesFragment(); + getSupportFragmentManager().beginTransaction().add(mFrameLayoutId, mPlatformGamesFragment).commit(); } else { mPlatformGamesFragment = (PlatformGamesFragment) getSupportFragmentManager().getFragment(savedInstanceState, "mPlatformGamesFragment"); } @@ -72,15 +63,13 @@ public final class MainActivity extends AppCompatActivity implements MainView { @Override protected void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); - if (PermissionsHandler.hasWriteAccess(this)) { - if (getSupportFragmentManager() == null) { - return; - } - if (outState == null) { - return; - } - getSupportFragmentManager().putFragment(outState, "mPlatformGamesFragment", mPlatformGamesFragment); + if (getSupportFragmentManager() == null) { + return; } + if (outState == null) { + return; + } + getSupportFragmentManager().putFragment(outState, "mPlatformGamesFragment", mPlatformGamesFragment); } @Override @@ -119,27 +108,17 @@ public final class MainActivity extends AppCompatActivity implements MainView { @Override public void launchSettingsActivity(String menuTag) { - if (PermissionsHandler.hasWriteAccess(this)) { - SettingsActivity.launch(this, menuTag, ""); - } else { - PermissionsHandler.checkWritePermission(this); - } + SettingsActivity.launch(this, menuTag, ""); } @Override public void launchFileListActivity(int request) { - if (PermissionsHandler.hasWriteAccess(this)) { - switch (request) { - case MainPresenter.REQUEST_ADD_DIRECTORY: - FileBrowserHelper.openDirectoryPicker(this, - MainPresenter.REQUEST_ADD_DIRECTORY, - R.string.select_game_folder, - Arrays.asList("nso", "nro", "nca", "xci", - "nsp", "kip")); - break; - } - } else { - PermissionsHandler.checkWritePermission(this); + switch (request) { + case MainPresenter.REQUEST_ADD_DIRECTORY: + FileBrowserHelper.openDirectoryPicker(this, + MainPresenter.REQUEST_ADD_DIRECTORY, + R.string.select_game_folder); + break; } } @@ -155,6 +134,8 @@ public final class MainActivity extends AppCompatActivity implements MainView { case MainPresenter.REQUEST_ADD_DIRECTORY: // If the user picked a file, as opposed to just backing out. if (resultCode == MainActivity.RESULT_OK) { + int takeFlags = (Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION); + getContentResolver().takePersistableUriPermission(Uri.parse(result.getDataString()), takeFlags); // When a new directory is picked, we currently will reset the existing games // database. This effectively means that only one game directory is supported. // TODO(bunnei): Consider fixing this in the future, or removing code for this. @@ -166,32 +147,6 @@ public final class MainActivity extends AppCompatActivity implements MainView { } } - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - switch (requestCode) { - case PermissionsHandler.REQUEST_CODE_WRITE_PERMISSION: - if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { - DirectoryInitialization.start(this); - - mPlatformGamesFragment = new PlatformGamesFragment(); - getSupportFragmentManager().beginTransaction().add(mFrameLayoutId, mPlatformGamesFragment) - .commit(); - - // Immediately prompt user to select a game directory on first boot - if (mPresenter != null) { - mPresenter.launchFileListActivity(MainPresenter.REQUEST_ADD_DIRECTORY); - } - } else { - Toast.makeText(this, R.string.write_permission_needed, Toast.LENGTH_SHORT) - .show(); - } - break; - default: - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - break; - } - } - /** * Called by the framework whenever any actionbar/toolbar icon is clicked. * diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainPresenter.java b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainPresenter.java index 4cf643552..01f577600 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainPresenter.java +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainPresenter.java @@ -22,7 +22,7 @@ public final class MainPresenter { public void onCreate() { String versionName = BuildConfig.VERSION_NAME; mView.setVersionString(versionName); - refeshGameList(); + refreshGameList(); } public void launchFileListActivity(int request) { @@ -63,7 +63,7 @@ public final class MainPresenter { mDirToAdd = dir; } - public void refeshGameList() { + public void refreshGameList() { GameDatabase databaseHelper = YuzuApplication.databaseHelper; databaseHelper.scanLibrary(databaseHelper.getWritableDatabase()); mView.refresh(); diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DirectoryInitialization.java b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DirectoryInitialization.java index bac52bb2a..f922ae183 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DirectoryInitialization.java +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DirectoryInitialization.java @@ -1,35 +1,16 @@ -/** - * Copyright 2014 Dolphin Emulator Project - * Licensed under GPLv2+ - * Refer to the license.txt file included. - */ - package org.yuzu.yuzu_emu.utils; import android.content.Context; import android.content.Intent; -import android.content.SharedPreferences; -import android.os.Environment; -import android.preference.PreferenceManager; - import androidx.localbroadcastmanager.content.LocalBroadcastManager; import org.yuzu.yuzu_emu.NativeLibrary; -import java.io.File; -import java.io.FileOutputStream; import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; import java.util.concurrent.atomic.AtomicBoolean; -/** - * A service that spawns its own thread in order to copy several binary and shader files - * from the yuzu APK to the external file system. - */ public final class DirectoryInitialization { public static final String BROADCAST_ACTION = "org.yuzu.yuzu_emu.BROADCAST"; - public static final String EXTRA_STATE = "directoryState"; private static volatile DirectoryInitializationState directoryState = null; private static String userPath; @@ -37,7 +18,6 @@ public final class DirectoryInitialization { public static void start(Context context) { // Can take a few seconds to run, so don't block UI thread. - //noinspection TrivialFunctionalExpressionUsage ((Runnable) () -> init(context)).run(); } @@ -46,31 +26,15 @@ public final class DirectoryInitialization { return; if (directoryState != DirectoryInitializationState.YUZU_DIRECTORIES_INITIALIZED) { - if (PermissionsHandler.hasWriteAccess(context)) { - if (setUserDirectory()) { - initializeInternalStorage(context); - NativeLibrary.CreateConfigFile(); - directoryState = DirectoryInitializationState.YUZU_DIRECTORIES_INITIALIZED; - } else { - directoryState = DirectoryInitializationState.CANT_FIND_EXTERNAL_STORAGE; - } - } else { - directoryState = DirectoryInitializationState.EXTERNAL_STORAGE_PERMISSION_NEEDED; - } + initializeInternalStorage(context); + NativeLibrary.CreateConfigFile(); + directoryState = DirectoryInitializationState.YUZU_DIRECTORIES_INITIALIZED; } isDirectoryInitializationRunning.set(false); sendBroadcastState(directoryState, context); } - private static void deleteDirectoryRecursively(File file) { - if (file.isDirectory()) { - for (File child : file.listFiles()) - deleteDirectoryRecursively(child); - } - file.delete(); - } - public static boolean areDirectoriesReady() { return directoryState == DirectoryInitializationState.YUZU_DIRECTORIES_INITIALIZED; } @@ -85,41 +49,13 @@ public final class DirectoryInitialization { return userPath; } - private static native void SetSysDirectory(String path); - - private static boolean setUserDirectory() { - if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) { - File externalPath = Environment.getExternalStorageDirectory(); - if (externalPath != null) { - userPath = externalPath.getAbsolutePath() + "/yuzu-emu"; - Log.debug("[DirectoryInitialization] User Dir: " + userPath); - // NativeLibrary.SetUserDirectory(userPath); - return true; - } - + public static void initializeInternalStorage(Context context) { + try { + userPath = context.getExternalFilesDir(null).getCanonicalPath(); + NativeLibrary.SetAppDirectory(userPath); + } catch(IOException e) { + e.printStackTrace(); } - - return false; - } - - private static void initializeInternalStorage(Context context) { - File sysDirectory = new File(context.getFilesDir(), "Sys"); - - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); - String revision = NativeLibrary.GetGitRevision(); - if (!preferences.getString("sysDirectoryVersion", "").equals(revision)) { - // There is no extracted Sys directory, or there is a Sys directory from another - // version of yuzu that might contain outdated files. Let's (re-)extract Sys. - deleteDirectoryRecursively(sysDirectory); - copyAssetFolder("Sys", sysDirectory, true, context); - - SharedPreferences.Editor editor = preferences.edit(); - editor.putString("sysDirectoryVersion", revision); - editor.apply(); - } - - // Let the native code know where the Sys directory is. - SetSysDirectory(sysDirectory.getPath()); } private static void sendBroadcastState(DirectoryInitializationState state, Context context) { @@ -129,58 +65,8 @@ public final class DirectoryInitialization { LocalBroadcastManager.getInstance(context).sendBroadcast(localIntent); } - private static void copyAsset(String asset, File output, Boolean overwrite, Context context) { - Log.verbose("[DirectoryInitialization] Copying File " + asset + " to " + output); - - try { - if (!output.exists() || overwrite) { - InputStream in = context.getAssets().open(asset); - OutputStream out = new FileOutputStream(output); - copyFile(in, out); - in.close(); - out.close(); - } - } catch (IOException e) { - Log.error("[DirectoryInitialization] Failed to copy asset file: " + asset + - e.getMessage()); - } - } - - private static void copyAssetFolder(String assetFolder, File outputFolder, Boolean overwrite, - Context context) { - Log.verbose("[DirectoryInitialization] Copying Folder " + assetFolder + " to " + - outputFolder); - - try { - boolean createdFolder = false; - for (String file : context.getAssets().list(assetFolder)) { - if (!createdFolder) { - outputFolder.mkdir(); - createdFolder = true; - } - copyAssetFolder(assetFolder + File.separator + file, new File(outputFolder, file), - overwrite, context); - copyAsset(assetFolder + File.separator + file, new File(outputFolder, file), overwrite, - context); - } - } catch (IOException e) { - Log.error("[DirectoryInitialization] Failed to copy asset folder: " + assetFolder + - e.getMessage()); - } - } - - private static void copyFile(InputStream in, OutputStream out) throws IOException { - byte[] buffer = new byte[1024]; - int read; - - while ((read = in.read(buffer)) != -1) { - out.write(buffer, 0, read); - } - } - public enum DirectoryInitializationState { YUZU_DIRECTORIES_INITIALIZED, - EXTERNAL_STORAGE_PERMISSION_NEEDED, CANT_FIND_EXTERNAL_STORAGE } } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DocumentsTree.java b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DocumentsTree.java new file mode 100644 index 000000000..beb790ab1 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DocumentsTree.java @@ -0,0 +1,125 @@ +package org.yuzu.yuzu_emu.utils; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.Nullable; +import androidx.documentfile.provider.DocumentFile; + +import org.yuzu.yuzu_emu.YuzuApplication; +import org.yuzu.yuzu_emu.model.MinimalDocumentFile; + +import java.util.HashMap; +import java.util.Map; +import java.util.StringTokenizer; + +public class DocumentsTree { + private DocumentsNode root; + private final Context context; + public static final String DELIMITER = "/"; + + public DocumentsTree() { + context = YuzuApplication.getAppContext(); + } + + public void setRoot(Uri rootUri) { + root = null; + root = new DocumentsNode(); + root.uri = rootUri; + root.isDirectory = true; + } + + public int openContentUri(String filepath, String openmode) { + DocumentsNode node = resolvePath(filepath); + if (node == null) { + return -1; + } + return FileUtil.openContentUri(context, node.uri.toString(), openmode); + } + + public long getFileSize(String filepath) { + DocumentsNode node = resolvePath(filepath); + if (node == null || node.isDirectory) { + return 0; + } + return FileUtil.getFileSize(context, node.uri.toString()); + } + + public boolean Exists(String filepath) { + return resolvePath(filepath) != null; + } + + @Nullable + private DocumentsNode resolvePath(String filepath) { + StringTokenizer tokens = new StringTokenizer(filepath, DELIMITER, false); + DocumentsNode iterator = root; + while (tokens.hasMoreTokens()) { + String token = tokens.nextToken(); + if (token.isEmpty()) continue; + iterator = find(iterator, token); + if (iterator == null) return null; + } + return iterator; + } + + @Nullable + private DocumentsNode find(DocumentsNode parent, String filename) { + if (parent.isDirectory && !parent.loaded) { + structTree(parent); + } + return parent.children.get(filename); + } + + /** + * Construct current level directory tree + * @param parent parent node of this level + */ + private void structTree(DocumentsNode parent) { + MinimalDocumentFile[] documents = FileUtil.listFiles(context, parent.uri); + for (MinimalDocumentFile document: documents) { + DocumentsNode node = new DocumentsNode(document); + node.parent = parent; + parent.children.put(node.name, node); + } + parent.loaded = true; + } + + public static boolean isNativePath(String path) { + if (path.length() > 0) { + return path.charAt(0) == '/'; + } + return false; + } + + private static class DocumentsNode { + private DocumentsNode parent; + private final Map children = new HashMap<>(); + private String name; + private Uri uri; + private boolean loaded = false; + private boolean isDirectory = false; + + private DocumentsNode() {} + private DocumentsNode(MinimalDocumentFile document) { + name = document.getFilename(); + uri = document.getUri(); + isDirectory = document.isDirectory(); + loaded = !isDirectory; + } + private DocumentsNode(DocumentFile document, boolean isCreateDir) { + name = document.getName(); + uri = document.getUri(); + isDirectory = isCreateDir; + loaded = true; + } + + private void rename(String name) { + if (parent == null) { + return; + } + parent.children.remove(this.name); + this.name = name; + parent.children.put(name, this); + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileBrowserHelper.java b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileBrowserHelper.java index ad3ec3dc1..6175f39c4 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileBrowserHelper.java +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileBrowserHelper.java @@ -1,73 +1,16 @@ package org.yuzu.yuzu_emu.utils; import android.content.Intent; -import android.net.Uri; -import android.os.Environment; - -import androidx.annotation.Nullable; import androidx.fragment.app.FragmentActivity; -import com.nononsenseapps.filepicker.FilePickerActivity; -import com.nononsenseapps.filepicker.Utils; - -import org.yuzu.yuzu_emu.activities.CustomFilePickerActivity; - -import java.io.File; -import java.util.List; - public final class FileBrowserHelper { - public static void openDirectoryPicker(FragmentActivity activity, int requestCode, int title, List extensions) { - Intent i = new Intent(activity, CustomFilePickerActivity.class); - - i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, false); - i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, false); - i.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_DIR); - i.putExtra(FilePickerActivity.EXTRA_START_PATH, - Environment.getExternalStorageDirectory().getPath()); - i.putExtra(CustomFilePickerActivity.EXTRA_TITLE, title); - i.putExtra(CustomFilePickerActivity.EXTRA_EXTENSIONS, String.join(",", extensions)); - + public static void openDirectoryPicker(FragmentActivity activity, int requestCode, int title) { + Intent i = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + i.putExtra(Intent.EXTRA_TITLE, title); activity.startActivityForResult(i, requestCode); } - public static void openFilePicker(FragmentActivity activity, int requestCode, int title, - List extensions, boolean allowMultiple) { - Intent i = new Intent(activity, CustomFilePickerActivity.class); - - i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, allowMultiple); - i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, false); - i.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_FILE); - i.putExtra(FilePickerActivity.EXTRA_START_PATH, - Environment.getExternalStorageDirectory().getPath()); - i.putExtra(CustomFilePickerActivity.EXTRA_TITLE, title); - i.putExtra(CustomFilePickerActivity.EXTRA_EXTENSIONS, String.join(",", extensions)); - - activity.startActivityForResult(i, requestCode); - } - - @Nullable public static String getSelectedDirectory(Intent result) { - // Use the provided utility method to parse the result - List files = Utils.getSelectedFilesFromResult(result); - if (!files.isEmpty()) { - File file = Utils.getFileForUri(files.get(0)); - return file.getAbsolutePath(); - } - - return null; - } - - @Nullable - public static String[] getSelectedFiles(Intent result) { - // Use the provided utility method to parse the result - List files = Utils.getSelectedFilesFromResult(result); - if (!files.isEmpty()) { - String[] paths = new String[files.size()]; - for (int i = 0; i < files.size(); i++) - paths[i] = Utils.getFileForUri(files.get(i)).getAbsolutePath(); - return paths; - } - - return null; + return result.getDataString(); } } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.java b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.java index 11d06c7ee..624fd4a88 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.java +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.java @@ -1,37 +1,261 @@ package org.yuzu.yuzu_emu.utils; -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.provider.DocumentsContract; + +import androidx.annotation.Nullable; +import androidx.documentfile.provider.DocumentFile; + +import org.yuzu.yuzu_emu.model.MinimalDocumentFile; + import java.io.InputStream; +import java.io.OutputStream; +import java.net.URLDecoder; +import java.util.ArrayList; +import java.util.List; public class FileUtil { - public static byte[] getBytesFromFile(File file) throws IOException { - final long length = file.length(); + static final String PATH_TREE = "tree"; + static final String DECODE_METHOD = "UTF-8"; + static final String APPLICATION_OCTET_STREAM = "application/octet-stream"; + static final String TEXT_PLAIN = "text/plain"; - // You cannot create an array using a long type. - if (length > Integer.MAX_VALUE) { - // File is too large - throw new IOException("File is too large!"); + /** + * Create a file from directory with filename. + * @param context Application context + * @param directory parent path for file. + * @param filename file display name. + * @return boolean + */ + @Nullable + public static DocumentFile createFile(Context context, String directory, String filename) { + try { + Uri directoryUri = Uri.parse(directory); + DocumentFile parent = DocumentFile.fromTreeUri(context, directoryUri); + if (parent == null) return null; + filename = URLDecoder.decode(filename, DECODE_METHOD); + String mimeType = APPLICATION_OCTET_STREAM; + if (filename.endsWith(".txt")) { + mimeType = TEXT_PLAIN; + } + DocumentFile exists = parent.findFile(filename); + if (exists != null) return exists; + return parent.createFile(mimeType, filename); + } catch (Exception e) { + Log.error("[FileUtil]: Cannot create file, error: " + e.getMessage()); } + return null; + } - byte[] bytes = new byte[(int) length]; + /** + * Create a directory from directory with filename. + * @param context Application context + * @param directory parent path for directory. + * @param directoryName directory display name. + * @return boolean + */ + @Nullable + public static DocumentFile createDir(Context context, String directory, String directoryName) { + try { + Uri directoryUri = Uri.parse(directory); + DocumentFile parent = DocumentFile.fromTreeUri(context, directoryUri); + if (parent == null) return null; + directoryName = URLDecoder.decode(directoryName, DECODE_METHOD); + DocumentFile isExist = parent.findFile(directoryName); + if (isExist != null) return isExist; + return parent.createDirectory(directoryName); + } catch (Exception e) { + Log.error("[FileUtil]: Cannot create file, error: " + e.getMessage()); + } + return null; + } - int offset = 0; - int numRead; + /** + * Open content uri and return file descriptor to JNI. + * @param context Application context + * @param path Native content uri path + * @param openmode will be one of "r", "r", "rw", "wa", "rwa" + * @return file descriptor + */ + public static int openContentUri(Context context, String path, String openmode) { + try { + Uri uri = Uri.parse(path); + ParcelFileDescriptor parcelFileDescriptor = context.getContentResolver().openFileDescriptor(uri, openmode); + if (parcelFileDescriptor == null) { + Log.error("[FileUtil]: Cannot get the file descriptor from uri: " + path); + return -1; + } + return parcelFileDescriptor.detachFd(); + } + catch (Exception e) { + Log.error("[FileUtil]: Cannot open content uri, error: " + e.getMessage()); + } + return -1; + } - try (InputStream is = new FileInputStream(file)) { - while (offset < bytes.length - && (numRead = is.read(bytes, offset, bytes.length - offset)) >= 0) { - offset += numRead; + /** + * Reference: https://stackoverflow.com/questions/42186820/documentfile-is-very-slow + * This function will be faster than DoucmentFile.listFiles + * @param context Application context + * @param uri Directory uri. + * @return CheapDocument lists. + */ + public static MinimalDocumentFile[] listFiles(Context context, Uri uri) { + final ContentResolver resolver = context.getContentResolver(); + final String[] columns = new String[]{ + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE, + }; + Cursor c = null; + final List results = new ArrayList<>(); + try { + String docId; + if (isRootTreeUri(uri)) { + docId = DocumentsContract.getTreeDocumentId(uri); + } else { + docId = DocumentsContract.getDocumentId(uri); + } + final Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, docId); + c = resolver.query(childrenUri, columns, null, null, null); + while(c.moveToNext()) { + final String documentId = c.getString(0); + final String documentName = c.getString(1); + final String documentMimeType = c.getString(2); + final Uri documentUri = DocumentsContract.buildDocumentUriUsingTree(uri, documentId); + MinimalDocumentFile document = new MinimalDocumentFile(documentName, documentMimeType, documentUri); + results.add(document); + } + } catch (Exception e) { + Log.error("[FileUtil]: Cannot list file error: " + e.getMessage()); + } finally { + closeQuietly(c); + } + return results.toArray(new MinimalDocumentFile[0]); + } + + /** + * Check whether given path exists. + * @param path Native content uri path + * @return bool + */ + public static boolean Exists(Context context, String path) { + Cursor c = null; + try { + Uri mUri = Uri.parse(path); + final String[] columns = new String[] { DocumentsContract.Document.COLUMN_DOCUMENT_ID }; + c = context.getContentResolver().query(mUri, columns, null, null, null); + return c.getCount() > 0; + } catch (Exception e) { + Log.info("[FileUtil] Cannot find file from given path, error: " + e.getMessage()); + } finally { + closeQuietly(c); + } + return false; + } + + /** + * Check whether given path is a directory + * @param path content uri path + * @return bool + */ + public static boolean isDirectory(Context context, String path) { + final ContentResolver resolver = context.getContentResolver(); + final String[] columns = new String[] { + DocumentsContract.Document.COLUMN_MIME_TYPE + }; + boolean isDirectory = false; + Cursor c = null; + try { + Uri mUri = Uri.parse(path); + c = resolver.query(mUri, columns, null, null, null); + c.moveToNext(); + final String mimeType = c.getString(0); + isDirectory = mimeType.equals(DocumentsContract.Document.MIME_TYPE_DIR); + } catch (Exception e) { + Log.error("[FileUtil]: Cannot list files, error: " + e.getMessage()); + } finally { + closeQuietly(c); + } + return isDirectory; + } + + /** + * Get file display name from given path + * @param path content uri path + * @return String display name + */ + public static String getFilename(Context context, String path) { + final ContentResolver resolver = context.getContentResolver(); + final String[] columns = new String[] { + DocumentsContract.Document.COLUMN_DISPLAY_NAME + }; + String filename = ""; + Cursor c = null; + try { + Uri mUri = Uri.parse(path); + c = resolver.query(mUri, columns, null, null, null); + c.moveToNext(); + filename = c.getString(0); + } catch (Exception e) { + Log.error("[FileUtil]: Cannot get file size, error: " + e.getMessage()); + } finally { + closeQuietly(c); + } + return filename; + } + + public static String[] getFilesName(Context context, String path) { + Uri uri = Uri.parse(path); + List files = new ArrayList<>(); + for (MinimalDocumentFile file: FileUtil.listFiles(context, uri)) { + files.add(file.getFilename()); + } + return files.toArray(new String[0]); + } + + /** + * Get file size from given path. + * @param path content uri path + * @return long file size + */ + public static long getFileSize(Context context, String path) { + final ContentResolver resolver = context.getContentResolver(); + final String[] columns = new String[] { + DocumentsContract.Document.COLUMN_SIZE + }; + long size = 0; + Cursor c =null; + try { + Uri mUri = Uri.parse(path); + c = resolver.query(mUri, columns, null, null, null); + c.moveToNext(); + size = c.getLong(0); + } catch (Exception e) { + Log.error("[FileUtil]: Cannot get file size, error: " + e.getMessage()); + } finally { + closeQuietly(c); + } + return size; + } + + public static boolean isRootTreeUri(Uri uri) { + final List paths = uri.getPathSegments(); + return paths.size() == 2 && PATH_TREE.equals(paths.get(0)); + } + + public static void closeQuietly(AutoCloseable closeable) { + if (closeable != null) { + try { + closeable.close(); + } catch (RuntimeException rethrown) { + throw rethrown; + } catch (Exception ignored) { } } - - // Ensure all the bytes have been read in - if (offset < bytes.length) { - throw new IOException("Could not completely read file " + file.getName()); - } - - return bytes; } } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/PermissionsHandler.java b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/PermissionsHandler.java deleted file mode 100644 index 2eb200da4..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/PermissionsHandler.java +++ /dev/null @@ -1,35 +0,0 @@ -package org.yuzu.yuzu_emu.utils; - -import android.annotation.TargetApi; -import android.content.Context; -import android.content.pm.PackageManager; -import android.os.Build; - -import androidx.core.content.ContextCompat; -import androidx.fragment.app.FragmentActivity; - -import static android.Manifest.permission.WRITE_EXTERNAL_STORAGE; - -public class PermissionsHandler { - public static final int REQUEST_CODE_WRITE_PERMISSION = 500; - - // We use permissions acceptance as an indicator if this is a first boot for the user. - public static boolean isFirstBoot(final FragmentActivity activity) { - return ContextCompat.checkSelfPermission(activity, WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED; - } - - @TargetApi(Build.VERSION_CODES.M) - public static boolean checkWritePermission(final FragmentActivity activity) { - if (isFirstBoot(activity)) { - activity.requestPermissions(new String[]{WRITE_EXTERNAL_STORAGE}, - REQUEST_CODE_WRITE_PERMISSION); - return false; - } - - return true; - } - - public static boolean hasWriteAccess(Context context) { - return ContextCompat.checkSelfPermission(context, WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED; - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/StartupHandler.java b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/StartupHandler.java index 5d22e8e08..6d3e58e18 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/StartupHandler.java +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/StartupHandler.java @@ -1,44 +1,38 @@ package org.yuzu.yuzu_emu.utils; -import android.content.Intent; -import android.os.Bundle; -import android.text.TextUtils; - +import android.content.SharedPreferences; +import android.preference.PreferenceManager; import androidx.appcompat.app.AlertDialog; -import androidx.fragment.app.FragmentActivity; import org.yuzu.yuzu_emu.R; -import org.yuzu.yuzu_emu.activities.EmulationActivity; +import org.yuzu.yuzu_emu.YuzuApplication; +import org.yuzu.yuzu_emu.ui.main.MainActivity; +import org.yuzu.yuzu_emu.ui.main.MainPresenter; public final class StartupHandler { - private static void handlePermissionsCheck(FragmentActivity parent) { - // Ask the user to grant write permission if it's not already granted - PermissionsHandler.checkWritePermission(parent); + private static SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.getAppContext()); - String start_file = ""; - Bundle extras = parent.getIntent().getExtras(); - if (extras != null) { - start_file = extras.getString("AutoStartFile"); - } - - if (!TextUtils.isEmpty(start_file)) { - // Start the emulation activity, send the ISO passed in and finish the main activity - Intent emulation_intent = new Intent(parent, EmulationActivity.class); - emulation_intent.putExtra("SelectedGame", start_file); - parent.startActivity(emulation_intent); - parent.finish(); - } + private static void handleStartupPromptDismiss(MainActivity parent) { + parent.launchFileListActivity(MainPresenter.REQUEST_ADD_DIRECTORY); } - public static void HandleInit(FragmentActivity parent) { - if (PermissionsHandler.isFirstBoot(parent)) { + private static void markFirstBoot() { + final SharedPreferences.Editor editor = mPreferences.edit(); + editor.putBoolean("FirstApplicationLaunch", false); + editor.apply(); + } + + public static void handleInit(MainActivity parent) { + if (mPreferences.getBoolean("FirstApplicationLaunch", true)) { + markFirstBoot(); + // Prompt user with standard first boot disclaimer new AlertDialog.Builder(parent) .setTitle(R.string.app_name) .setIcon(R.mipmap.ic_launcher) .setMessage(parent.getResources().getString(R.string.app_disclaimer)) .setPositiveButton(android.R.string.ok, null) - .setOnDismissListener(dialogInterface -> handlePermissionsCheck(parent)) + .setOnDismissListener(dialogInterface -> handleStartupPromptDismiss(parent)) .show(); } } diff --git a/src/android/app/src/main/jni/config.cpp b/src/android/app/src/main/jni/config.cpp index 326dab5fc..0a3cb9162 100644 --- a/src/android/app/src/main/jni/config.cpp +++ b/src/android/app/src/main/jni/config.cpp @@ -18,11 +18,8 @@ namespace FS = Common::FS; -const std::filesystem::path default_config_path = - FS::GetYuzuPath(FS::YuzuPath::ConfigDir) / "config.ini"; - Config::Config(std::optional config_path) - : config_loc{config_path.value_or(default_config_path)}, + : config_loc{config_path.value_or(FS::GetYuzuPath(FS::YuzuPath::ConfigDir) / "config.ini")}, config{std::make_unique(FS::PathToUTF8String(config_loc))} { Reload(); } @@ -66,8 +63,8 @@ void Config::ReadSetting(const std::string& group, Settings::Setting& sett template void Config::ReadSetting(const std::string& group, Settings::Setting& setting) { - setting = static_cast(config->GetInteger(group, setting.GetLabel(), - static_cast(setting.GetDefault()))); + setting = static_cast( + config->GetInteger(group, setting.GetLabel(), static_cast(setting.GetDefault()))); } void Config::ReadValues() { @@ -93,9 +90,9 @@ void Config::ReadValues() { for (int i = 0; i < num_touch_from_button_maps; ++i) { Settings::TouchFromButtonMap map; map.name = config->Get("ControlsGeneral", - std::string("touch_from_button_maps_") + std::to_string(i) + - std::string("_name"), - "default"); + std::string("touch_from_button_maps_") + std::to_string(i) + + std::string("_name"), + "default"); const int num_touch_maps = config->GetInteger( "ControlsGeneral", std::string("touch_from_button_maps_") + std::to_string(i) + std::string("_count"), @@ -105,9 +102,9 @@ void Config::ReadValues() { for (int j = 0; j < num_touch_maps; ++j) { std::string touch_mapping = config->Get("ControlsGeneral", - std::string("touch_from_button_maps_") + std::to_string(i) + - std::string("_bind_") + std::to_string(j), - ""); + std::string("touch_from_button_maps_") + std::to_string(i) + + std::string("_bind_") + std::to_string(j), + ""); map.buttons.emplace_back(std::move(touch_mapping)); } @@ -127,16 +124,16 @@ void Config::ReadValues() { ReadSetting("Data Storage", Settings::values.use_virtual_sd); FS::SetYuzuPath(FS::YuzuPath::NANDDir, config->Get("Data Storage", "nand_directory", - FS::GetYuzuPathString(FS::YuzuPath::NANDDir))); + FS::GetYuzuPathString(FS::YuzuPath::NANDDir))); FS::SetYuzuPath(FS::YuzuPath::SDMCDir, config->Get("Data Storage", "sdmc_directory", - FS::GetYuzuPathString(FS::YuzuPath::SDMCDir))); + FS::GetYuzuPathString(FS::YuzuPath::SDMCDir))); FS::SetYuzuPath(FS::YuzuPath::LoadDir, config->Get("Data Storage", "load_directory", - FS::GetYuzuPathString(FS::YuzuPath::LoadDir))); + FS::GetYuzuPathString(FS::YuzuPath::LoadDir))); FS::SetYuzuPath(FS::YuzuPath::DumpDir, config->Get("Data Storage", "dump_directory", - FS::GetYuzuPathString(FS::YuzuPath::DumpDir))); + FS::GetYuzuPathString(FS::YuzuPath::DumpDir))); ReadSetting("Data Storage", Settings::values.gamecard_inserted); ReadSetting("Data Storage", Settings::values.gamecard_current_game); ReadSetting("Data Storage", Settings::values.gamecard_path); diff --git a/src/android/app/src/main/jni/id_cache.cpp b/src/android/app/src/main/jni/id_cache.cpp index 2955122be..8f085798d 100644 --- a/src/android/app/src/main/jni/id_cache.cpp +++ b/src/android/app/src/main/jni/id_cache.cpp @@ -1,9 +1,17 @@ +// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include + +#include "common/fs/fs_android.h" #include "jni/id_cache.h" static JavaVM* s_java_vm; static jclass s_native_library_class; static jmethodID s_exit_emulation_activity; +static constexpr jint JNI_VERSION = JNI_VERSION_1_6; + namespace IDCache { JNIEnv* GetEnvForThread() { @@ -34,3 +42,41 @@ jmethodID GetExitEmulationActivity() { } } // namespace IDCache + +#ifdef __cplusplus +extern "C" { +#endif + +jint JNI_OnLoad(JavaVM* vm, void* reserved) { + s_java_vm = vm; + + JNIEnv* env; + if (vm->GetEnv(reinterpret_cast(&env), JNI_VERSION) != JNI_OK) + return JNI_ERR; + + // Initialize Java classes + const jclass native_library_class = env->FindClass("org/yuzu/yuzu_emu/NativeLibrary"); + s_native_library_class = reinterpret_cast(env->NewGlobalRef(native_library_class)); + s_exit_emulation_activity = + env->GetStaticMethodID(s_native_library_class, "exitEmulationActivity", "(I)V"); + + // Initialize Android Storage + Common::FS::Android::RegisterCallbacks(env, s_native_library_class); + + return JNI_VERSION; +} + +void JNI_OnUnload(JavaVM* vm, void* reserved) { + JNIEnv* env; + if (vm->GetEnv(reinterpret_cast(&env), JNI_VERSION) != JNI_OK) { + return; + } + + // UnInitialize Android Storage + Common::FS::Android::UnRegisterCallbacks(); + env->DeleteGlobalRef(s_native_library_class); +} + +#ifdef __cplusplus +} +#endif diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp index f0df6cac1..c1880db46 100644 --- a/src/android/app/src/main/jni/native.cpp +++ b/src/android/app/src/main/jni/native.cpp @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + #include #include #include @@ -7,6 +10,7 @@ #include #include "common/detached_tasks.h" +#include "common/fs/path_util.h" #include "common/logging/backend.h" #include "common/logging/log.h" #include "common/microprofile.h" @@ -257,9 +261,11 @@ void Java_org_yuzu_yuzu_1emu_NativeLibrary_NotifyOrientationChange(JNIEnv* env, jint layout_option, jint rotation) {} -void Java_org_yuzu_yuzu_1emu_NativeLibrary_SetUserDirectory([[maybe_unused]] JNIEnv* env, - [[maybe_unused]] jclass clazz, - [[maybe_unused]] jstring j_directory) {} +void Java_org_yuzu_yuzu_1emu_NativeLibrary_SetAppDirectory([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jclass clazz, + [[maybe_unused]] jstring j_directory) { + Common::FS::SetAppDirectory(GetJString(env, j_directory)); +} void Java_org_yuzu_yuzu_1emu_NativeLibrary_UnPauseEmulation([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) {} diff --git a/src/android/app/src/main/jni/native.h b/src/android/app/src/main/jni/native.h index 3b23f380b..fbe015b55 100644 --- a/src/android/app/src/main/jni/native.h +++ b/src/android/app/src/main/jni/native.h @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + #pragma once #include @@ -8,16 +11,16 @@ extern "C" { #endif JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_UnPauseEmulation(JNIEnv* env, - jclass clazz); - -JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_PauseEmulation(JNIEnv* env, jclass clazz); +JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_PauseEmulation(JNIEnv* env, + jclass clazz); + JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_StopEmulation(JNIEnv* env, - jclass clazz); + jclass clazz); JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_IsRunning(JNIEnv* env, - jclass clazz); + jclass clazz); JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_onGamePadEvent( JNIEnv* env, jclass clazz, jstring j_device, jint j_button, jint action); @@ -29,61 +32,58 @@ JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_onGamePadAxisEv JNIEnv* env, jclass clazz, jstring j_device, jint axis_id, jfloat axis_val); JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_onTouchEvent(JNIEnv* env, - jclass clazz, - jfloat x, jfloat y, - jboolean pressed); + jclass clazz, + jfloat x, jfloat y, + jboolean pressed); -JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_onTouchMoved(JNIEnv* env, - jclass clazz, jfloat x, - jfloat y); +JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_onTouchMoved(JNIEnv* env, jclass clazz, + jfloat x, jfloat y); -JNIEXPORT jintArray JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetIcon(JNIEnv* env, - jclass clazz, - jstring j_file); +JNIEXPORT jintArray JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetIcon(JNIEnv* env, jclass clazz, + jstring j_file); -JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetTitle(JNIEnv* env, +JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetTitle(JNIEnv* env, jclass clazz, + jstring j_filename); + +JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetDescription(JNIEnv* env, + jclass clazz, + jstring j_filename); + +JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetGameId(JNIEnv* env, jclass clazz, + jstring j_filename); + +JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetRegions(JNIEnv* env, jclass clazz, jstring j_filename); -JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetDescription( - JNIEnv* env, jclass clazz, jstring j_filename); - -JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetGameId(JNIEnv* env, - jclass clazz, - jstring j_filename); - -JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetRegions(JNIEnv* env, - jclass clazz, - jstring j_filename); - JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetCompany(JNIEnv* env, - jclass clazz, - jstring j_filename); + jclass clazz, + jstring j_filename); JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetGitRevision(JNIEnv* env, - jclass clazz); + jclass clazz); -JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_SetUserDirectory( - JNIEnv* env, jclass clazz, jstring j_directory); +JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_SetAppDirectory(JNIEnv* env, + jclass clazz, + jstring j_directory); JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_utils_DirectoryInitialization_SetSysDirectory( JNIEnv* env, jclass clazz, jstring path_); JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_SetSysDirectory(JNIEnv* env, - jclass clazz, - jstring path); + jclass clazz, + jstring path); JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_CreateConfigFile(JNIEnv* env, - jclass clazz); + jclass clazz); JNIEXPORT jint JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_DefaultCPUCore(JNIEnv* env, - jclass clazz); -JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_SetProfiling(JNIEnv* env, - jclass clazz, - jboolean enable); + jclass clazz); +JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_SetProfiling(JNIEnv* env, jclass clazz, + jboolean enable); JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_WriteProfileResults(JNIEnv* env, - jclass clazz); + jclass clazz); JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_NotifyOrientationChange( JNIEnv* env, jclass clazz, jint layout_option, jint rotation); @@ -96,18 +96,17 @@ Java_org_yuzu_yuzu_1emu_NativeLibrary_Run__Ljava_lang_String_2Ljava_lang_String_ JNIEnv* env, jclass clazz, jstring j_file, jstring j_savestate, jboolean j_delete_savestate); JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_SurfaceChanged(JNIEnv* env, - jclass clazz, - jobject surf); + jclass clazz, + jobject surf); JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_SurfaceDestroyed(JNIEnv* env, - jclass clazz); + jclass clazz); -JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_InitGameIni(JNIEnv* env, - jclass clazz, - jstring j_game_id); +JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_InitGameIni(JNIEnv* env, jclass clazz, + jstring j_game_id); JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_ReloadSettings(JNIEnv* env, - jclass clazz); + jclass clazz); JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_SetUserSetting( JNIEnv* env, jclass clazz, jstring j_game_id, jstring j_section, jstring j_key, @@ -117,10 +116,10 @@ JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetUserSetting( JNIEnv* env, jclass clazz, jstring game_id, jstring section, jstring key); JNIEXPORT jdoubleArray JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetPerfStats(JNIEnv* env, - jclass clazz); + jclass clazz); JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_LogDeviceInfo(JNIEnv* env, - jclass clazz); + jclass clazz); #ifdef __cplusplus } diff --git a/src/android/app/src/main/res/layout/filepicker_toolbar.xml b/src/android/app/src/main/res/layout/filepicker_toolbar.xml deleted file mode 100644 index 644934171..000000000 --- a/src/android/app/src/main/res/layout/filepicker_toolbar.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - diff --git a/src/android/app/src/main/res/values-night/styles_filepicker.xml b/src/android/app/src/main/res/values-night/styles_filepicker.xml deleted file mode 100644 index 1a175cdcf..000000000 --- a/src/android/app/src/main/res/values-night/styles_filepicker.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - - -