diff options
38 files changed, 851 insertions, 697 deletions
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 @@ <activity android:name="org.yuzu.yuzu_emu.ui.main.MainActivity" + android:exported="true" android:theme="@style/YuzuBase" android:resizeableActivity="false"> @@ -57,18 +58,6 @@ <service android:name="org.yuzu.yuzu_emu.utils.ForegroundService"/> - <activity - android:name="org.yuzu.yuzu_emu.activities.CustomFilePickerActivity" - android:label="@string/app_name" - android:theme="@style/FilePickerTheme"> - <intent-filter> - <action android:name="android.intent.action.GET_CONTENT" /> - <category android:name="android.intent.category.DEFAULT" /> - </intent-filter> - </activity> - - <service android:name="org.yuzu.yuzu_emu.utils.DirectoryInitialization"/> - <provider android:name="org.yuzu.yuzu_emu.model.GameProvider" android:authorities="${applicationId}.provider" diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.java b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.java index e15612a36..acb3fc2d6 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.java +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.java @@ -25,7 +25,9 @@ import androidx.core.content.ContextCompat; import androidx.fragment.app.DialogFragment; import org.yuzu.yuzu_emu.activities.EmulationActivity; +import org.yuzu.yuzu_emu.utils.DocumentsTree; import org.yuzu.yuzu_emu.utils.EmulationMenuSettings; +import org.yuzu.yuzu_emu.utils.FileUtil; import org.yuzu.yuzu_emu.utils.Log; import java.lang.ref.WeakReference; @@ -66,6 +68,20 @@ public final class NativeLibrary { // Disallows instantiation. } + public static int openContentUri(String path, String openmode) { + if (DocumentsTree.isNativePath(path)) { + return YuzuApplication.documentsTree.openContentUri(path, openmode); + } + return FileUtil.openContentUri(YuzuApplication.getAppContext(), path, openmode); + } + + public static long getSize(String path) { + if (DocumentsTree.isNativePath(path)) { + return YuzuApplication.documentsTree.getFileSize(path); + } + return FileUtil.getFileSize(YuzuApplication.getAppContext(), path); + } + /** * Handles button press events for a gamepad. * @@ -147,11 +163,7 @@ public final class NativeLibrary { public static native String GetGitRevision(); - /** - * Sets the current working user directory - * If not set, it auto-detects a location - */ - public static native void SetUserDirectory(String directory); + public static native void SetAppDirectory(String directory); // Create the config.ini file. public static native void CreateConfigFile(); diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.java b/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.java index 700916f87..d7b75e5a6 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.java +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.java @@ -11,11 +11,12 @@ import android.content.Context; import android.os.Build; import org.yuzu.yuzu_emu.model.GameDatabase; +import org.yuzu.yuzu_emu.utils.DocumentsTree; import org.yuzu.yuzu_emu.utils.DirectoryInitialization; -import org.yuzu.yuzu_emu.utils.PermissionsHandler; public class YuzuApplication extends Application { public static GameDatabase databaseHelper; + public static DocumentsTree documentsTree; private static YuzuApplication application; private void createNotificationChannel() { @@ -39,10 +40,9 @@ public class YuzuApplication extends Application { public void onCreate() { super.onCreate(); application = this; + documentsTree = new DocumentsTree(); - if (PermissionsHandler.hasWriteAccess(getApplicationContext())) { - DirectoryInitialization.start(getApplicationContext()); - } + DirectoryInitialization.start(getApplicationContext()); NativeLibrary.LogDeviceInfo(); createNotificationChannel(); diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/CustomFilePickerActivity.java b/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/CustomFilePickerActivity.java deleted file mode 100644 index a79780814..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/CustomFilePickerActivity.java +++ /dev/null @@ -1,38 +0,0 @@ -package org.yuzu.yuzu_emu.activities; - -import android.content.Intent; -import android.os.Environment; - -import androidx.annotation.Nullable; - -import com.nononsenseapps.filepicker.AbstractFilePickerFragment; -import com.nononsenseapps.filepicker.FilePickerActivity; - -import org.yuzu.yuzu_emu.fragments.CustomFilePickerFragment; - -import java.io.File; - -public class CustomFilePickerActivity extends FilePickerActivity { - public static final String EXTRA_TITLE = "filepicker.intent.TITLE"; - public static final String EXTRA_EXTENSIONS = "filepicker.intent.EXTENSIONS"; - - @Override - protected AbstractFilePickerFragment<File> 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<GameViewHolder> 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 @@ -160,12 +160,6 @@ public final class SettingsActivity extends AppCompatActivity implements Setting } @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) .show(); 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 @@ -77,11 +77,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 */ void showExternalStorageNotMountedHint(); 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<String> 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 @@ -156,10 +156,6 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C 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, Toast.LENGTH_SHORT) 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<String> allowedExtensions, int depth) { + private void addGamesRecursive(SQLiteDatabase database, Uri parent, Set<String> allowedExtensions, int depth) { if (depth <= 0) { return; } - File[] children = parent.listFiles(); - if (children != null) { - for (File file : children) { - if (file.isHidden()) { - continue; - } - - if (file.isDirectory()) { - Set<String> newExtensions = new HashSet<>(Arrays.asList( - ".xci", ".nsp", ".nca", ".nro")); - addGamesRecursive(database, file, newExtensions, depth - 1); - } else { - String filePath = file.getPath(); - - 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); - } + MinimalDocumentFile[] children = FileUtil.listFiles(context, parent); + for (MinimalDocumentFile file : children) { + if (file.isDirectory()) { + Set<String> newExtensions = new HashSet<>(Arrays.asList( + ".xci", ".nsp", ".nca", ".nro")); + this.addGamesRecursive(database, file.getUri(), newExtensions, depth - 1); + } else { + String filename = file.getUri().toString(); + + int extensionStart = filename.lastIndexOf('.'); + if (extensionStart > 0) { + String fileExtension = filename.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, 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; - } - - } - - 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(); + public static void initializeInternalStorage(Context context) { + try { + userPath = context.getExternalFilesDir(null).getCanonicalPath(); + NativeLibrary.SetAppDirectory(userPath); + } catch(IOException e) { + e.printStackTrace(); } - - // 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<String, DocumentsNode> 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<String> 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<String> 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<Uri> 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<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; + 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<MinimalDocumentFile> 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; + } - // Ensure all the bytes have been read in - if (offset < bytes.length) { - throw new IOException("Could not completely read file " + file.getName()); + /** + * 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<String> files = new ArrayList<>(); + for (MinimalDocumentFile file: FileUtil.listFiles(context, uri)) { + files.add(file.getFilename()); + } + return files.toArray(new String[0]); + } - return bytes; + /** + * 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<String> 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) { + } + } } } 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"); - } + private static void handleStartupPromptDismiss(MainActivity parent) { + parent.launchFileListActivity(MainPresenter.REQUEST_ADD_DIRECTORY); + } - 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 markFirstBoot() { + final SharedPreferences.Editor editor = mPreferences.edit(); + editor.putBoolean("FirstApplicationLaunch", false); + editor.apply(); } - public static void HandleInit(FragmentActivity parent) { - if (PermissionsHandler.isFirstBoot(parent)) { + 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<std::filesystem::path> 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<INIReader>(FS::PathToUTF8String(config_loc))} { Reload(); } @@ -66,8 +63,8 @@ void Config::ReadSetting(const std::string& group, Settings::Setting<bool>& sett template <typename Type, bool ranged> void Config::ReadSetting(const std::string& group, Settings::Setting<Type, ranged>& setting) { - setting = static_cast<Type>(config->GetInteger(group, setting.GetLabel(), - static_cast<long>(setting.GetDefault()))); + setting = static_cast<Type>( + config->GetInteger(group, setting.GetLabel(), static_cast<long>(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 <jni.h> + +#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<void**>(&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<jclass>(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<void**>(&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 <codecvt> #include <locale> #include <string> @@ -7,6 +10,7 @@ #include <android/native_window_jni.h> #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 <jni.h> @@ -8,16 +11,16 @@ extern "C" { #endif JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_UnPauseEmulation(JNIEnv* env, - jclass clazz); + jclass clazz); JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_PauseEmulation(JNIEnv* env, - jclass clazz); + 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, - jclass clazz, - jstring j_filename); +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_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_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); + 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 @@ -<?xml version="1.0" encoding="utf-8"?> -<androidx.appcompat.widget.Toolbar xmlns:android="http://schemas.android.com/apk/res/android" - android:id="@+id/nnf_picker_toolbar" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_alignParentTop="true" - android:background="?attr/colorPrimary" - android:minHeight="?attr/actionBarSize" - android:theme="?nnf_toolbarTheme"> - - <LinearLayout - android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="vertical"> - - <TextView - android:id="@+id/filepicker_title" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:ellipsize="start" - android:singleLine="true" - android:textAppearance="@style/TextAppearance.AppCompat.Widget.ActionBar.Title" /> - - <TextView - android:id="@+id/nnf_current_dir" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:ellipsize="start" - android:singleLine="true" - android:textAppearance="@style/TextAppearance.AppCompat.Widget.ActionBar.Subtitle" /> - </LinearLayout> -</androidx.appcompat.widget.Toolbar> 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 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - - <style name="FilePickerBaseTheme" parent="NNF_BaseTheme" /> -</resources> diff --git a/src/android/app/src/main/res/values-w1050dp/dimens.xml b/src/android/app/src/main/res/values-w1050dp/dimens.xml index 92fcb2b66..78481cb1c 100644 --- a/src/android/app/src/main/res/values-w1050dp/dimens.xml +++ b/src/android/app/src/main/res/values-w1050dp/dimens.xml @@ -2,5 +2,4 @@ <resources> <!-- Example customization of dimensions originally defined in res/values/dimens.xml (such as screen margins) for screens with more than 1024dp of available width. --> - <dimen name="activity_horizontal_margin">96dp</dimen> </resources> diff --git a/src/android/app/src/main/res/values-w820dp/dimens.xml b/src/android/app/src/main/res/values-w820dp/dimens.xml index d27181e85..1b1ada235 100644 --- a/src/android/app/src/main/res/values-w820dp/dimens.xml +++ b/src/android/app/src/main/res/values-w820dp/dimens.xml @@ -1,5 +1,4 @@ <resources> <!-- Example customization of dimensions originally defined in res/values/dimens.xml (such as screen margins) for screens with more than 820dp of available width. --> - <dimen name="activity_horizontal_margin">64dp</dimen> </resources> diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index cc84f700e..893f6aa1a 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -48,7 +48,7 @@ <string name="grid_menu_core_settings">Settings</string> <!-- Add Directory Screen--> - <string name="select_game_folder">Select Game Folder</string> + <string name="select_game_folder">Select game folder</string> <string name="install_cia_title">Install CIA</string> <!-- Preferences Screen --> @@ -71,7 +71,6 @@ <string name="emulation_touch_overlay_reset">Reset Overlay</string> <string name="emulation_close_game_message">Are you sure that you would like to close the current game?</string> - <string name="write_permission_needed">You need to allow write access to external storage for the emulator to work</string> <string name="load_settings">Loading Settings...</string> <string name="external_storage_not_mounted">The external storage needs to be available in order to use yuzu</string> diff --git a/src/android/app/src/main/res/values/styles.xml b/src/android/app/src/main/res/values/styles.xml index 62f24bad3..fdedc9b2e 100644 --- a/src/android/app/src/main/res/values/styles.xml +++ b/src/android/app/src/main/res/values/styles.xml @@ -61,22 +61,6 @@ <item name="android:windowAllowReturnTransitionOverlap">true</item> </style> - <!-- Inherit from a base file picker theme that handles day/night --> - <style name="FilePickerTheme" parent="FilePickerBaseTheme"> - <item name="colorSurface">@color/view_background</item> - <item name="colorOnSurface">@color/view_text</item> - <item name="colorPrimary">@color/citra_orange</item> - <item name="colorPrimaryDark">@color/citra_orange_dark</item> - <item name="colorAccent">@color/citra_accent</item> - <item name="android:windowBackground">@color/view_background</item> - - <!-- Need to set this also to style create folder dialog --> - <item name="alertDialogTheme">@style/FilePickerAlertDialogTheme</item> - - <item name="nnf_list_item_divider">@drawable/gamelist_divider</item> - <item name="nnf_toolbarTheme">@style/ThemeOverlay.AppCompat.DayNight.ActionBar</item> - </style> - <style name="FilePickerAlertDialogTheme" parent="Theme.AppCompat.DayNight.Dialog.Alert"> <item name="colorSurface">@color/view_background</item> <item name="colorOnSurface">@color/view_text</item> diff --git a/src/android/app/src/main/res/values/styles_filepicker.xml b/src/android/app/src/main/res/values/styles_filepicker.xml deleted file mode 100644 index 0b0c3fe1a..000000000 --- a/src/android/app/src/main/res/values/styles_filepicker.xml +++ /dev/null @@ -1,5 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - - <style name="FilePickerBaseTheme" parent="NNF_BaseTheme.Light" /> -</resources> diff --git a/src/common/CMakeLists.txt b/src/common/CMakeLists.txt index 13ed68b3f..aecb46872 100644 --- a/src/common/CMakeLists.txt +++ b/src/common/CMakeLists.txt @@ -155,6 +155,14 @@ if (WIN32) target_link_libraries(common PRIVATE ntdll) endif() +if(ANDROID) + target_sources(common + PRIVATE + fs/fs_android.cpp + fs/fs_android.h + ) +endif() + if(ARCHITECTURE_x86_64) target_sources(common PRIVATE diff --git a/src/common/fs/file.cpp b/src/common/fs/file.cpp index 656b03cc5..b0b25eb43 100644 --- a/src/common/fs/file.cpp +++ b/src/common/fs/file.cpp @@ -5,6 +5,9 @@ #include "common/fs/file.h" #include "common/fs/fs.h" +#ifdef ANDROID +#include "common/fs/fs_android.h" +#endif #include "common/logging/log.h" #ifdef _WIN32 @@ -252,6 +255,23 @@ void IOFile::Open(const fs::path& path, FileAccessMode mode, FileType type, File } else { _wfopen_s(&file, path.c_str(), AccessModeToWStr(mode, type)); } +#elif ANDROID + if (Android::IsContentUri(path)) { + ASSERT_MSG(mode == FileAccessMode::Read, "Content URI file access is for read-only!"); + const auto fd = Android::OpenContentUri(path, Android::OpenMode::Read); + if (fd != -1) { + file = fdopen(fd, "r"); + const auto error_num = errno; + if (error_num != 0 && file == nullptr) { + LOG_ERROR(Common_Filesystem, "Error opening file: {}, error: {}", path.c_str(), + strerror(error_num)); + } + } else { + LOG_ERROR(Common_Filesystem, "Error opening file: {}", path.c_str()); + } + } else { + file = std::fopen(path.c_str(), AccessModeToStr(mode, type)); + } #else file = std::fopen(path.c_str(), AccessModeToStr(mode, type)); #endif @@ -372,6 +392,23 @@ u64 IOFile::GetSize() const { // Flush any unwritten buffered data into the file prior to retrieving the file size. std::fflush(file); +#if ANDROID + u64 file_size = 0; + if (Android::IsContentUri(file_path)) { + file_size = Android::GetSize(file_path); + } else { + std::error_code ec; + + file_size = fs::file_size(file_path, ec); + + if (ec) { + LOG_ERROR(Common_Filesystem, + "Failed to retrieve the file size of path={}, ec_message={}", + PathToUTF8String(file_path), ec.message()); + return 0; + } + } +#else std::error_code ec; const auto file_size = fs::file_size(file_path, ec); @@ -381,6 +418,7 @@ u64 IOFile::GetSize() const { PathToUTF8String(file_path), ec.message()); return 0; } +#endif return file_size; } diff --git a/src/common/fs/fs_android.cpp b/src/common/fs/fs_android.cpp new file mode 100644 index 000000000..298a79bac --- /dev/null +++ b/src/common/fs/fs_android.cpp @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "common/fs/fs_android.h" + +namespace Common::FS::Android { + +JNIEnv* GetEnvForThread() { + thread_local static struct OwnedEnv { + OwnedEnv() { + status = g_jvm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6); + if (status == JNI_EDETACHED) + g_jvm->AttachCurrentThread(&env, nullptr); + } + + ~OwnedEnv() { + if (status == JNI_EDETACHED) + g_jvm->DetachCurrentThread(); + } + + int status; + JNIEnv* env = nullptr; + } owned; + return owned.env; +} + +void RegisterCallbacks(JNIEnv* env, jclass clazz) { + env->GetJavaVM(&g_jvm); + native_library = clazz; + +#define FR(FunctionName, ReturnValue, JMethodID, Caller, JMethodName, Signature) \ + F(JMethodID, JMethodName, Signature) +#define FS(FunctionName, ReturnValue, Parameters, JMethodID, JMethodName, Signature) \ + F(JMethodID, JMethodName, Signature) +#define F(JMethodID, JMethodName, Signature) \ + JMethodID = env->GetStaticMethodID(native_library, JMethodName, Signature); + ANDROID_SINGLE_PATH_DETERMINE_FUNCTIONS(FR) + ANDROID_STORAGE_FUNCTIONS(FS) +#undef F +#undef FS +#undef FR +} + +void UnRegisterCallbacks() { +#define FR(FunctionName, ReturnValue, JMethodID, Caller, JMethodName, Signature) F(JMethodID) +#define FS(FunctionName, ReturnValue, Parameters, JMethodID, JMethodName, Signature) F(JMethodID) +#define F(JMethodID) JMethodID = nullptr; + ANDROID_SINGLE_PATH_DETERMINE_FUNCTIONS(FR) + ANDROID_STORAGE_FUNCTIONS(FS) +#undef F +#undef FS +#undef FR +} + +bool IsContentUri(const std::string& path) { + constexpr std::string_view prefix = "content://"; + if (path.size() < prefix.size()) [[unlikely]] { + return false; + } + + return path.find(prefix) == 0; +} + +int OpenContentUri(const std::string& filepath, OpenMode openmode) { + if (open_content_uri == nullptr) + return -1; + + const char* mode = ""; + switch (openmode) { + case OpenMode::Read: + mode = "r"; + break; + default: + UNIMPLEMENTED(); + return -1; + } + auto env = GetEnvForThread(); + jstring j_filepath = env->NewStringUTF(filepath.c_str()); + jstring j_mode = env->NewStringUTF(mode); + return env->CallStaticIntMethod(native_library, open_content_uri, j_filepath, j_mode); +} + +#define FR(FunctionName, ReturnValue, JMethodID, Caller, JMethodName, Signature) \ + F(FunctionName, ReturnValue, JMethodID, Caller) +#define F(FunctionName, ReturnValue, JMethodID, Caller) \ + ReturnValue FunctionName(const std::string& filepath) { \ + if (JMethodID == nullptr) { \ + return 0; \ + } \ + auto env = GetEnvForThread(); \ + jstring j_filepath = env->NewStringUTF(filepath.c_str()); \ + return env->Caller(native_library, JMethodID, j_filepath); \ + } +ANDROID_SINGLE_PATH_DETERMINE_FUNCTIONS(FR) +#undef F +#undef FR + +} // namespace Common::FS::Android diff --git a/src/common/fs/fs_android.h b/src/common/fs/fs_android.h new file mode 100644 index 000000000..bb8a52648 --- /dev/null +++ b/src/common/fs/fs_android.h @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include <string> +#include <vector> +#include <jni.h> + +#define ANDROID_STORAGE_FUNCTIONS(V) \ + V(OpenContentUri, int, (const std::string& filepath, OpenMode openmode), open_content_uri, \ + "openContentUri", "(Ljava/lang/String;Ljava/lang/String;)I") + +#define ANDROID_SINGLE_PATH_DETERMINE_FUNCTIONS(V) \ + V(GetSize, std::uint64_t, get_size, CallStaticLongMethod, "getSize", "(Ljava/lang/String;)J") + +namespace Common::FS::Android { + +static JavaVM* g_jvm = nullptr; +static jclass native_library = nullptr; + +#define FR(FunctionName, ReturnValue, JMethodID, Caller, JMethodName, Signature) F(JMethodID) +#define FS(FunctionName, ReturnValue, Parameters, JMethodID, JMethodName, Signature) F(JMethodID) +#define F(JMethodID) static jmethodID JMethodID = nullptr; +ANDROID_SINGLE_PATH_DETERMINE_FUNCTIONS(FR) +ANDROID_STORAGE_FUNCTIONS(FS) +#undef F +#undef FS +#undef FR + +enum class OpenMode { + Read, + Write, + ReadWrite, + WriteAppend, + WriteTruncate, + ReadWriteAppend, + ReadWriteTruncate, + Never +}; + +void RegisterCallbacks(JNIEnv* env, jclass clazz); + +void UnRegisterCallbacks(); + +bool IsContentUri(const std::string& path); + +#define FS(FunctionName, ReturnValue, Parameters, JMethodID, JMethodName, Signature) \ + F(FunctionName, Parameters, ReturnValue) +#define F(FunctionName, Parameters, ReturnValue) ReturnValue FunctionName Parameters; +ANDROID_STORAGE_FUNCTIONS(FS) +#undef F +#undef FS + +#define FR(FunctionName, ReturnValue, JMethodID, Caller, JMethodName, Signature) \ + F(FunctionName, ReturnValue) +#define F(FunctionName, ReturnValue) ReturnValue FunctionName(const std::string& filepath); +ANDROID_SINGLE_PATH_DETERMINE_FUNCTIONS(FR) +#undef F +#undef FR + +} // namespace Common::FS::Android diff --git a/src/common/fs/path_util.cpp b/src/common/fs/path_util.cpp index ca755b053..e026a13d9 100644 --- a/src/common/fs/path_util.cpp +++ b/src/common/fs/path_util.cpp @@ -6,6 +6,9 @@ #include <unordered_map> #include "common/fs/fs.h" +#ifdef ANDROID +#include "common/fs/fs_android.h" +#endif #include "common/fs/fs_paths.h" #include "common/fs/path_util.h" #include "common/logging/log.h" @@ -80,9 +83,7 @@ public: yuzu_paths.insert_or_assign(yuzu_path, new_path); } -private: - PathManagerImpl() { - fs::path yuzu_path; + void Reinitialize(fs::path yuzu_path = {}) { fs::path yuzu_path_cache; fs::path yuzu_path_config; @@ -96,12 +97,9 @@ private: yuzu_path_cache = yuzu_path / CACHE_DIR; yuzu_path_config = yuzu_path / CONFIG_DIR; #elif ANDROID - // On Android internal storage is mounted as "/sdcard" - if (Exists("/sdcard")) { - yuzu_path = "/sdcard/yuzu-emu"; - yuzu_path_cache = yuzu_path / CACHE_DIR; - yuzu_path_config = yuzu_path / CONFIG_DIR; - } + ASSERT(!yuzu_path.empty()); + yuzu_path_cache = yuzu_path / CACHE_DIR; + yuzu_path_config = yuzu_path / CONFIG_DIR; #else yuzu_path = GetCurrentDir() / PORTABLE_DIR; @@ -129,6 +127,11 @@ private: GenerateYuzuPath(YuzuPath::TASDir, yuzu_path / TAS_DIR); } +private: + PathManagerImpl() { + Reinitialize(); + } + ~PathManagerImpl() = default; void GenerateYuzuPath(YuzuPath yuzu_path, const fs::path& new_path) { @@ -217,6 +220,10 @@ fs::path RemoveTrailingSeparators(const fs::path& path) { return fs::path{string_path}; } +void SetAppDirectory(const std::string& app_directory) { + PathManagerImpl::GetInstance().Reinitialize(app_directory); +} + const fs::path& GetYuzuPath(YuzuPath yuzu_path) { return PathManagerImpl::GetInstance().GetYuzuPathImpl(yuzu_path); } @@ -357,6 +364,12 @@ std::vector<std::string> SplitPathComponents(std::string_view filename) { std::string SanitizePath(std::string_view path_, DirectorySeparator directory_separator) { std::string path(path_); +#ifdef ANDROID + if (Android::IsContentUri(path)) { + return path; + } +#endif // ANDROID + char type1 = directory_separator == DirectorySeparator::BackwardSlash ? '/' : '\\'; char type2 = directory_separator == DirectorySeparator::BackwardSlash ? '\\' : '/'; diff --git a/src/common/fs/path_util.h b/src/common/fs/path_util.h index 13d713f1e..7cfe85b70 100644 --- a/src/common/fs/path_util.h +++ b/src/common/fs/path_util.h @@ -181,6 +181,14 @@ template <typename Path> #endif /** + * Sets the directory used for application storage. Used on Android where we do not know internal + * storage until informed by the frontend. + * + * @param app_directory Directory to use for application storage. + */ +void SetAppDirectory(const std::string& app_directory); + +/** * Gets the filesystem path associated with the YuzuPath enum. * * @param yuzu_path YuzuPath enum |