android: Implement SAF support & migrate to SDK 31. (#4)

This commit is contained in:
bunnei 2023-02-03 16:13:16 -08:00
parent 39ab81a098
commit ef605f7d8f
38 changed files with 856 additions and 702 deletions

View File

@ -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() {

View File

@ -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"

View File

@ -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();

View File

@ -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());
}
NativeLibrary.LogDeviceInfo();
createNotificationChannel();

View File

@ -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;
}
}

View File

@ -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);

View File

@ -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)

View File

@ -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();

View File

@ -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
*/

View File

@ -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);
}
}

View File

@ -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,

View File

@ -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;
}
MinimalDocumentFile[] children = FileUtil.listFiles(context, parent);
for (MinimalDocumentFile file : children) {
if (file.isDirectory()) {
Set<String> newExtensions = new HashSet<>(Arrays.asList(
".xci", ".nsp", ".nca", ".nro"));
addGamesRecursive(database, file, newExtensions, depth - 1);
this.addGamesRecursive(database, file.getUri(), newExtensions, depth - 1);
} else {
String filePath = file.getPath();
String filename = file.getUri().toString();
int extensionStart = filePath.lastIndexOf('.');
int extensionStart = filename.lastIndexOf('.');
if (extensionStart > 0) {
String fileExtension = filePath.substring(extensionStart);
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, filePath);
}
attemptToAddGame(database, filename);
}
}
}

View File

@ -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);
}
}

View File

@ -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)) {
StartupHandler.handleInit(this);
mPlatformGamesFragment = new PlatformGamesFragment();
getSupportFragmentManager().beginTransaction().add(mFrameLayoutId, mPlatformGamesFragment)
.commit();
}
getSupportFragmentManager().beginTransaction().add(mFrameLayoutId, mPlatformGamesFragment).commit();
} else {
mPlatformGamesFragment = (PlatformGamesFragment) getSupportFragmentManager().getFragment(savedInstanceState, "mPlatformGamesFragment");
}
@ -72,7 +63,6 @@ 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;
}
@ -81,7 +71,6 @@ public final class MainActivity extends AppCompatActivity implements MainView {
}
getSupportFragmentManager().putFragment(outState, "mPlatformGamesFragment", mPlatformGamesFragment);
}
}
@Override
protected void onResume() {
@ -119,28 +108,18 @@ 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);
}
}
@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"));
R.string.select_game_folder);
break;
}
} else {
PermissionsHandler.checkWritePermission(this);
}
}
/**
@ -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.
*

View File

@ -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();

View File

@ -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;
}
}
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
}
}

View File

@ -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);
}
}
}

View File

@ -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();
}
}

View File

@ -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];
int offset = 0;
int numRead;
try (InputStream is = new FileInputStream(file)) {
while (offset < bytes.length
&& (numRead = is.read(bytes, offset, bytes.length - offset)) >= 0) {
offset += numRead;
/**
* 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;
}
// Ensure all the bytes have been read in
if (offset < bytes.length) {
throw new IOException("Could not completely read file " + file.getName());
/**
* 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;
}
return bytes;
/**
* 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;
}
/**
* 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]);
}
/**
* 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) {
}
}
}
}

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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() {

View File

@ -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

View File

@ -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,
void Java_org_yuzu_yuzu_1emu_NativeLibrary_SetAppDirectory([[maybe_unused]] JNIEnv* env,
[[maybe_unused]] jclass clazz,
[[maybe_unused]] jstring j_directory) {}
[[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) {}

View File

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <jni.h>
@ -33,23 +36,20 @@ JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_onTouchEvent(JN
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,
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_GetDescription(
JNIEnv* env, jclass clazz, jstring j_filename);
JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetGameId(JNIEnv* env,
jclass clazz,
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,
@ -63,8 +63,9 @@ JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetCompany(JNIEn
JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetGitRevision(JNIEnv* env,
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_);
@ -78,8 +79,7 @@ JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_CreateConfigFile(JN
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,
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,
@ -102,8 +102,7 @@ JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_SurfaceChanged(JNIE
JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_SurfaceDestroyed(JNIEnv* env,
jclass clazz);
JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_InitGameIni(JNIEnv* env,
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_ReloadSettings(JNIEnv* env,

View File

@ -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>

View File

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="FilePickerBaseTheme" parent="NNF_BaseTheme" />
</resources>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="FilePickerBaseTheme" parent="NNF_BaseTheme.Light" />
</resources>

View File

@ -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

View File

@ -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;
}

View File

@ -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

View File

@ -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

View File

@ -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";
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 ? '\\' : '/';

View File

@ -180,6 +180,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.
*