citra_android: Storage Access Framework implementation (#6313)
parent
8c12eb4905
commit
8d563d37b4
@ -1 +1 @@
|
||||
Subproject commit 66937ea62d126a92b5057e3fd9ceac7c44daf4f5
|
||||
Subproject commit 80a171a179c1f901e4f8dfc8962417f44865ceec
|
@ -1,38 +0,0 @@
|
||||
package org.citra.citra_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.citra.citra_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;
|
||||
}
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
package org.citra.citra_emu.contracts;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.util.Pair;
|
||||
|
||||
import androidx.activity.result.contract.ActivityResultContract;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
public class OpenFileResultContract extends ActivityResultContract<Boolean, Intent> {
|
||||
@NonNull
|
||||
@Override
|
||||
public Intent createIntent(@NonNull Context context, Boolean allowMultiple) {
|
||||
return new Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||
.setType("application/octet-stream")
|
||||
.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, allowMultiple);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Intent parseResult(int i, @Nullable Intent intent) {
|
||||
return intent;
|
||||
}
|
||||
}
|
@ -0,0 +1,91 @@
|
||||
package org.citra.citra_emu.dialogs;
|
||||
|
||||
import android.app.Dialog;
|
||||
import android.content.SharedPreferences;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.preference.PreferenceManager;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.widget.CheckBox;
|
||||
import android.widget.TextView;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.fragment.app.DialogFragment;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
import java.util.Objects;
|
||||
import org.citra.citra_emu.R;
|
||||
import org.citra.citra_emu.utils.FileUtil;
|
||||
import org.citra.citra_emu.utils.PermissionsHandler;
|
||||
|
||||
public class CitraDirectoryDialog extends DialogFragment {
|
||||
public static final String TAG = "citra_directory_dialog_fragment";
|
||||
|
||||
private static final String MOVE_DATE_ENABLE = "IS_MODE_DATA_ENABLE";
|
||||
|
||||
TextView pathView;
|
||||
|
||||
TextView spaceView;
|
||||
|
||||
CheckBox checkBox;
|
||||
|
||||
AlertDialog dialog;
|
||||
|
||||
Listener listener;
|
||||
|
||||
public interface Listener {
|
||||
void onPressPositiveButton(boolean moveData, Uri path);
|
||||
}
|
||||
|
||||
public static CitraDirectoryDialog newInstance(String path, Listener listener) {
|
||||
CitraDirectoryDialog frag = new CitraDirectoryDialog();
|
||||
frag.listener = listener;
|
||||
Bundle args = new Bundle();
|
||||
args.putString("path", path);
|
||||
frag.setArguments(args);
|
||||
return frag;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Dialog onCreateDialog(Bundle savedInstanceState) {
|
||||
final FragmentActivity activity = requireActivity();
|
||||
final Uri path = Uri.parse(Objects.requireNonNull(requireArguments().getString("path")));
|
||||
SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(activity);
|
||||
String freeSpaceText =
|
||||
getResources().getString(R.string.free_space, FileUtil.getFreeSpace(activity, path));
|
||||
|
||||
LayoutInflater inflater = getLayoutInflater();
|
||||
View view = inflater.inflate(R.layout.dialog_citra_directory, null);
|
||||
|
||||
checkBox = view.findViewById(R.id.checkBox);
|
||||
pathView = view.findViewById(R.id.path);
|
||||
spaceView = view.findViewById(R.id.space);
|
||||
|
||||
checkBox.setChecked(mPreferences.getBoolean(MOVE_DATE_ENABLE, true));
|
||||
if (!PermissionsHandler.hasWriteAccess(activity)) {
|
||||
checkBox.setVisibility(View.GONE);
|
||||
}
|
||||
checkBox.setOnCheckedChangeListener(
|
||||
(v, isChecked)
|
||||
// record move data selection with SharedPreferences
|
||||
-> mPreferences.edit().putBoolean(MOVE_DATE_ENABLE, checkBox.isChecked()).apply());
|
||||
|
||||
pathView.setText(path.getPath());
|
||||
spaceView.setText(freeSpaceText);
|
||||
|
||||
setCancelable(false);
|
||||
|
||||
dialog = new MaterialAlertDialogBuilder(activity)
|
||||
.setView(view)
|
||||
.setIcon(R.mipmap.ic_launcher)
|
||||
.setTitle(R.string.app_name)
|
||||
.setPositiveButton(
|
||||
android.R.string.ok,
|
||||
(d, v) -> listener.onPressPositiveButton(checkBox.isChecked(), path))
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.create();
|
||||
return dialog;
|
||||
}
|
||||
}
|
@ -0,0 +1,61 @@
|
||||
package org.citra.citra_emu.dialogs;
|
||||
|
||||
import android.app.Dialog;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.widget.ProgressBar;
|
||||
import android.widget.TextView;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.fragment.app.DialogFragment;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
import org.citra.citra_emu.R;
|
||||
|
||||
public class CopyDirProgressDialog extends DialogFragment {
|
||||
public static final String TAG = "copy_dir_progress_dialog";
|
||||
ProgressBar progressBar;
|
||||
|
||||
TextView progressText;
|
||||
|
||||
AlertDialog dialog;
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Dialog onCreateDialog(Bundle savedInstanceState) {
|
||||
final FragmentActivity activity = requireActivity();
|
||||
|
||||
LayoutInflater inflater = getLayoutInflater();
|
||||
View view = inflater.inflate(R.layout.dialog_progress_bar, null);
|
||||
|
||||
progressBar = view.findViewById(R.id.progress_bar);
|
||||
progressText = view.findViewById(R.id.progress_text);
|
||||
progressText.setText("");
|
||||
|
||||
setCancelable(false);
|
||||
|
||||
dialog = new MaterialAlertDialogBuilder(activity)
|
||||
.setView(view)
|
||||
.setIcon(R.mipmap.ic_launcher)
|
||||
.setTitle(R.string.move_data)
|
||||
.setMessage("")
|
||||
.create();
|
||||
return dialog;
|
||||
}
|
||||
|
||||
public void onUpdateSearchProgress(String msg) {
|
||||
requireActivity().runOnUiThread(() -> {
|
||||
dialog.setMessage(getResources().getString(R.string.searching_direcotry, msg));
|
||||
});
|
||||
}
|
||||
|
||||
public void onUpdateCopyProgress(String msg, int progress, int max) {
|
||||
requireActivity().runOnUiThread(() -> {
|
||||
progressBar.setProgress(progress);
|
||||
progressBar.setMax(max);
|
||||
progressText.setText(String.format("%d/%d", progress, max));
|
||||
dialog.setMessage(getResources().getString(R.string.copy_file_name, msg));
|
||||
});
|
||||
}
|
||||
}
|
@ -1,120 +0,0 @@
|
||||
package org.citra.citra_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.citra.citra_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);
|
||||
}
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
package org.citra.citra_emu.model;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.provider.DocumentsContract;
|
||||
|
||||
/**
|
||||
* A struct that is much more "cheaper" than DocumentFile.
|
||||
* Only contains the information we needed.
|
||||
*/
|
||||
public class CheapDocument {
|
||||
private final String filename;
|
||||
private final Uri uri;
|
||||
private final String mimeType;
|
||||
|
||||
public CheapDocument(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 String getMimeType() {
|
||||
return mimeType;
|
||||
}
|
||||
|
||||
public boolean isDirectory() {
|
||||
return mimeType.equals(DocumentsContract.Document.MIME_TYPE_DIR);
|
||||
}
|
||||
}
|
@ -0,0 +1,87 @@
|
||||
package org.citra.citra_emu.utils;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
import java.util.concurrent.Executors;
|
||||
import org.citra.citra_emu.dialogs.CitraDirectoryDialog;
|
||||
import org.citra.citra_emu.dialogs.CopyDirProgressDialog;
|
||||
|
||||
/**
|
||||
* Citra directory initialization ui flow controller.
|
||||
*/
|
||||
public class CitraDirectoryHelper {
|
||||
public interface Listener {
|
||||
void onDirectoryInitialized();
|
||||
}
|
||||
|
||||
private final FragmentActivity mFragmentActivity;
|
||||
private final Listener mListener;
|
||||
|
||||
public CitraDirectoryHelper(FragmentActivity mFragmentActivity, Listener mListener) {
|
||||
this.mFragmentActivity = mFragmentActivity;
|
||||
this.mListener = mListener;
|
||||
}
|
||||
|
||||
public void showCitraDirectoryDialog(Uri result) {
|
||||
CitraDirectoryDialog citraDirectoryDialog = CitraDirectoryDialog.newInstance(
|
||||
result.toString(), ((moveData, path) -> {
|
||||
Uri previous = PermissionsHandler.getCitraDirectory();
|
||||
// Do noting if user select the previous path.
|
||||
if (path.equals(previous)) {
|
||||
return;
|
||||
}
|
||||
int takeFlags = (Intent.FLAG_GRANT_WRITE_URI_PERMISSION |
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
mFragmentActivity.getContentResolver().takePersistableUriPermission(path,
|
||||
takeFlags);
|
||||
if (!moveData || previous == null) {
|
||||
initializeCitraDirectory(path);
|
||||
mListener.onDirectoryInitialized();
|
||||
return;
|
||||
}
|
||||
|
||||
// If user check move data, show copy progress dialog.
|
||||
showCopyDialog(previous, path);
|
||||
}));
|
||||
citraDirectoryDialog.show(mFragmentActivity.getSupportFragmentManager(),
|
||||
CitraDirectoryDialog.TAG);
|
||||
}
|
||||
|
||||
private void showCopyDialog(Uri previous, Uri path) {
|
||||
CopyDirProgressDialog copyDirProgressDialog = new CopyDirProgressDialog();
|
||||
copyDirProgressDialog.showNow(mFragmentActivity.getSupportFragmentManager(),
|
||||
CopyDirProgressDialog.TAG);
|
||||
|
||||
// Run copy dir in background
|
||||
Executors.newSingleThreadExecutor().execute(() -> {
|
||||
FileUtil.copyDir(
|
||||
mFragmentActivity, previous.toString(), path.toString(),
|
||||
new FileUtil.CopyDirListener() {
|
||||
@Override
|
||||
public void onSearchProgress(String directoryName) {
|
||||
copyDirProgressDialog.onUpdateSearchProgress(directoryName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCopyProgress(String filename, int progress, int max) {
|
||||
copyDirProgressDialog.onUpdateCopyProgress(filename, progress, max);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComplete() {
|
||||
initializeCitraDirectory(path);
|
||||
copyDirProgressDialog.dismissAllowingStateLoss();
|
||||
mListener.onDirectoryInitialized();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void initializeCitraDirectory(Uri path) {
|
||||
if (!PermissionsHandler.setCitraDirectory(path.toString()))
|
||||
return;
|
||||
DirectoryInitialization.resetCitraDirectoryState();
|
||||
DirectoryInitialization.start(mFragmentActivity);
|
||||
}
|
||||
}
|
@ -0,0 +1,271 @@
|
||||
package org.citra.citra_emu.utils;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.provider.DocumentsContract;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.documentfile.provider.DocumentFile;
|
||||
|
||||
import org.citra.citra_emu.CitraApplication;
|
||||
import org.citra.citra_emu.model.CheapDocument;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.URLDecoder;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.StringTokenizer;
|
||||
|
||||
/**
|
||||
* A cached document tree for citra user directory.
|
||||
* For every filepath which is not startsWith "content://" will need to use this class to traverse.
|
||||
* For example:
|
||||
* C++ citra log file directory will be /log/citra_log.txt.
|
||||
* After DocumentsTree.resolvePath() it will become content URI.
|
||||
*/
|
||||
public class DocumentsTree {
|
||||
private DocumentsNode root;
|
||||
private final Context context;
|
||||
public static final String DELIMITER = "/";
|
||||
|
||||
public DocumentsTree() {
|
||||
context = CitraApplication.getAppContext();
|
||||
}
|
||||
|
||||
public void setRoot(Uri rootUri) {
|
||||
root = null;
|
||||
root = new DocumentsNode();
|
||||
root.uri = rootUri;
|
||||
root.isDirectory = true;
|
||||
}
|
||||
|
||||
public boolean createFile(String filepath, String name) {
|
||||
DocumentsNode node = resolvePath(filepath);
|
||||
if (node == null) return false;
|
||||
if (!node.isDirectory) return false;
|
||||
if (!node.loaded) structTree(node);
|
||||
Uri mUri = node.uri;
|
||||
try {
|
||||
String filename = URLDecoder.decode(name, FileUtil.DECODE_METHOD);
|
||||
if (node.children.get(filename) != null) return true;
|
||||
DocumentFile createdFile = FileUtil.createFile(context, mUri.toString(), name);
|
||||
if (createdFile == null) return false;
|
||||
DocumentsNode document = new DocumentsNode(createdFile, false);
|
||||
document.parent = node;
|
||||
node.children.put(document.key, document);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Log.error("[DocumentsTree]: Cannot create file, error: " + e.getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean createDir(String filepath, String name) {
|
||||
DocumentsNode node = resolvePath(filepath);
|
||||
if (node == null) return false;
|
||||
if (!node.isDirectory) return false;
|
||||
if (!node.loaded) structTree(node);
|
||||
Uri mUri = node.uri;
|
||||
try {
|
||||
String filename = URLDecoder.decode(name, FileUtil.DECODE_METHOD);
|
||||
if (node.children.get(filename) != null) return true;
|
||||
DocumentFile createdDirectory = FileUtil.createDir(context, mUri.toString(), name);
|
||||
if (createdDirectory == null) return false;
|
||||
DocumentsNode document = new DocumentsNode(createdDirectory, true);
|
||||
document.parent = node;
|
||||
node.children.put(document.key, document);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Log.error("[DocumentsTree]: Cannot create file, error: " + e.getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
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 String getFilename(String filepath) {
|
||||
DocumentsNode node = resolvePath(filepath);
|
||||
if (node == null) {
|
||||
return "";
|
||||
}
|
||||
return node.name;
|
||||
}
|
||||
|
||||
public String[] getFilesName(String filepath) {
|
||||
DocumentsNode node = resolvePath(filepath);
|
||||
if (node == null || !node.isDirectory) {
|
||||
return new String[0];
|
||||
}
|
||||
// If this directory have not been iterate struct it.
|
||||
if (!node.loaded) structTree(node);
|
||||
return node.children.keySet().toArray(new String[0]);
|
||||
}
|
||||
|
||||
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 isDirectory(String filepath) {
|
||||
DocumentsNode node = resolvePath(filepath);
|
||||
if (node == null) return false;
|
||||
return node.isDirectory;
|
||||
}
|
||||
|
||||
public boolean Exists(String filepath) {
|
||||
return resolvePath(filepath) != null;
|
||||
}
|
||||
|
||||
public boolean copyFile(String sourcePath, String destinationParentPath, String destinationFilename) {
|
||||
DocumentsNode sourceNode = resolvePath(sourcePath);
|
||||
if (sourceNode == null) return false;
|
||||
DocumentsNode destinationNode = resolvePath(destinationParentPath);
|
||||
if (destinationNode == null) return false;
|
||||
try {
|
||||
DocumentFile destinationParent = DocumentFile.fromTreeUri(context, destinationNode.uri);
|
||||
if (destinationParent == null) return false;
|
||||
String filename = URLDecoder.decode(destinationFilename, "UTF-8");
|
||||
DocumentFile destination = destinationParent.createFile("application/octet-stream", filename);
|
||||
if (destination == null) return false;
|
||||
DocumentsNode document = new DocumentsNode();
|
||||
document.uri = destination.getUri();
|
||||
document.parent = destinationNode;
|
||||
document.name = destination.getName();
|
||||
document.isDirectory = destination.isDirectory();
|
||||
document.loaded = true;
|
||||
InputStream input = context.getContentResolver().openInputStream(sourceNode.uri);
|
||||
OutputStream output = context.getContentResolver().openOutputStream(destination.getUri());
|
||||
byte[] buffer = new byte[1024];
|
||||
int len;
|
||||
while ((len = input.read(buffer)) != -1) {
|
||||
output.write(buffer, 0, len);
|
||||
}
|
||||
input.close();
|
||||
output.flush();
|
||||
output.close();
|
||||
destinationNode.children.put(document.key, document);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Log.error("[DocumentsTree]: Cannot copy file, error: " + e.getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean renameFile(String filepath, String destinationFilename) {
|
||||
DocumentsNode node = resolvePath(filepath);
|
||||
if (node == null) return false;
|
||||
try {
|
||||
Uri mUri = node.uri;
|
||||
String filename = URLDecoder.decode(destinationFilename, FileUtil.DECODE_METHOD);
|
||||
DocumentsContract.renameDocument(context.getContentResolver(), mUri, filename);
|
||||
node.rename(filename);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Log.error("[DocumentsTree]: Cannot rename file, error: " + e.getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean deleteDocument(String filepath) {
|
||||
DocumentsNode node = resolvePath(filepath);
|
||||
if (node == null) return false;
|
||||
try {
|
||||
Uri mUri = node.uri;
|
||||
if (!DocumentsContract.deleteDocument(context.getContentResolver(), mUri)) {
|
||||
return false;
|
||||
}
|
||||
if (node.parent != null) {
|
||||
node.parent.children.remove(node.key);
|
||||
}
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Log.error("[DocumentsTree]: Cannot rename file, error: " + e.getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private DocumentsNode resolvePath(String filepath) {
|
||||
if (root == null)
|
||||
return null;
|
||||
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) {
|
||||
CheapDocument[] documents = FileUtil.listFiles(context, parent.uri);
|
||||
for (CheapDocument document : documents) {
|
||||
DocumentsNode node = new DocumentsNode(document);
|
||||
node.parent = parent;
|
||||
parent.children.put(node.key, node);
|
||||
}
|
||||
parent.loaded = true;
|
||||
}
|
||||
|
||||
private static class DocumentsNode {
|
||||
private DocumentsNode parent;
|
||||
private final Map<String, DocumentsNode> children = new HashMap<>();
|
||||
private String key;
|
||||
private String name;
|
||||
private Uri uri;
|
||||
private boolean loaded = false;
|
||||
private boolean isDirectory = false;
|
||||
|
||||
private DocumentsNode() {}
|
||||
|
||||
private DocumentsNode(CheapDocument document) {
|
||||
name = document.getFilename();
|
||||
uri = document.getUri();
|
||||
key = FileUtil.getFilenameWithExtensions(uri);
|
||||
isDirectory = document.isDirectory();
|
||||
loaded = !isDirectory;
|
||||
}
|
||||
|
||||
private DocumentsNode(DocumentFile document, boolean isCreateDir) {
|
||||
name = document.getName();
|
||||
uri = document.getUri();
|
||||
key = FileUtil.getFilenameWithExtensions(uri);
|
||||
isDirectory = isCreateDir;
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
private void rename(String key) {
|
||||
if (parent == null) {
|
||||
return;
|
||||
}
|
||||
parent.children.remove(this.key);
|
||||
this.name = key;
|
||||
parent.children.put(key, this);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:textSize="@dimen/text_medium"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/spacing_medlarge"
|
||||
android:layout_marginLeft="@dimen/spacing_large"
|
||||
android:layout_marginRight="@dimen/spacing_large"
|
||||
android:text="@string/select_citra_user_folder" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/path"
|
||||
android:textSize="@dimen/text_medium"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/spacing_small"
|
||||
android:layout_marginLeft="@dimen/spacing_large"
|
||||
android:layout_marginRight="@dimen/spacing_large"
|
||||
android:text="@string/fatal_error" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/space"
|
||||
android:textSize="@dimen/text_medium"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/spacing_small"
|
||||
android:layout_marginLeft="@dimen/spacing_large"
|
||||
android:layout_marginRight="@dimen/spacing_large"
|
||||
android:text="@string/free_space" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/checkBox"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/spacing_small"
|
||||
android:layout_marginLeft="@dimen/spacing_large"
|
||||
android:layout_marginRight="@dimen/spacing_large"
|
||||
android:text="@string/move_data" />
|
||||
</LinearLayout>
|
@ -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>
|
@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="FilePickerBaseTheme" parent="NNF_BaseTheme" />
|
||||
</resources>
|
@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="FilePickerBaseTheme" parent="NNF_BaseTheme.Light" />
|
||||
</resources>
|
@ -0,0 +1,190 @@
|
||||
// Copyright 2023 Citra Emulator Project
|
||||
// Licensed under GPLv2 or any later version
|
||||
// Refer to the license.txt file included.
|
||||
|
||||
#ifdef ANDROID
|
||||
#include "common/android_storage.h"
|
||||
|
||||
namespace AndroidStorage {
|
||||
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;
|
||||
}
|
||||
|
||||
AndroidOpenMode ParseOpenmode(const std::string_view openmode) {
|
||||
AndroidOpenMode android_open_mode = AndroidOpenMode::NEVER;
|
||||
const char* mode = openmode.data();
|
||||
int o = 0;
|
||||
switch (*mode++) {
|
||||
case 'r':
|
||||
android_open_mode = AndroidStorage::AndroidOpenMode::READ;
|
||||
break;
|
||||
case 'w':
|
||||
android_open_mode = AndroidStorage::AndroidOpenMode::WRITE;
|
||||
o = O_TRUNC;
|
||||
break;
|
||||
case 'a':
|
||||
android_open_mode = AndroidStorage::AndroidOpenMode::WRITE;
|
||||
o = O_APPEND;
|
||||
break;
|
||||
}
|
||||
|
||||
// [rwa]\+ or [rwa]b\+ means read and write
|
||||
if (*mode == '+' || (*mode == 'b' && mode[1] == '+')) {
|
||||
android_open_mode = AndroidStorage::AndroidOpenMode::READ_WRITE;
|
||||
}
|
||||
|
||||
return android_open_mode | o;
|
||||
}
|
||||
|
||||
void InitJNI(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 CleanupJNI() {
|
||||
#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 CreateFile(const std::string& directory, const std::string& filename) {
|
||||
if (create_file == nullptr)
|
||||
return false;
|
||||
auto env = GetEnvForThread();
|
||||
jstring j_directory = env->NewStringUTF(directory.c_str());
|
||||
jstring j_filename = env->NewStringUTF(filename.c_str());
|
||||
return env->CallStaticBooleanMethod(native_library, create_file, j_directory, j_filename);
|
||||
}
|
||||
|
||||
bool CreateDir(const std::string& directory, const std::string& filename) {
|
||||
if (create_dir == nullptr)
|
||||
return false;
|
||||
auto env = GetEnvForThread();
|
||||
jstring j_directory = env->NewStringUTF(directory.c_str());
|
||||
jstring j_directory_name = env->NewStringUTF(filename.c_str());
|
||||
return env->CallStaticBooleanMethod(native_library, create_dir, j_directory, j_directory_name);
|
||||
}
|
||||
|
||||
int OpenContentUri(const std::string& filepath, AndroidOpenMode openmode) {
|
||||
if (open_content_uri == nullptr)
|
||||
return -1;
|
||||
|
||||
const char* mode = "";
|
||||
switch (openmode) {
|
||||
case AndroidOpenMode::READ:
|
||||
mode = "r";
|
||||
break;
|
||||
case AndroidOpenMode::WRITE:
|
||||
mode = "w";
|
||||
break;
|
||||
case AndroidOpenMode::READ_WRITE:
|
||||
mode = "rw";
|
||||
break;
|
||||
case AndroidOpenMode::WRITE_TRUNCATE:
|
||||
mode = "wt";
|
||||
break;
|
||||
case AndroidOpenMode::WRITE_APPEND:
|
||||
mode = "wa";
|
||||
break;
|
||||
case AndroidOpenMode::READ_WRITE_APPEND:
|
||||
mode = "rwa";
|
||||
break;
|
||||
case AndroidOpenMode::READ_WRITE_TRUNCATE:
|
||||
mode = "rwt";
|
||||
break;
|
||||
case AndroidOpenMode::NEVER:
|
||||
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);
|
||||
}
|
||||
|
||||
std::vector<std::string> GetFilesName(const std::string& filepath) {
|
||||
auto vector = std::vector<std::string>();
|
||||
if (get_files_name == nullptr)
|
||||
return vector;
|
||||
auto env = GetEnvForThread();
|
||||
jstring j_filepath = env->NewStringUTF(filepath.c_str());
|
||||
auto j_object =
|
||||
(jobjectArray)env->CallStaticObjectMethod(native_library, get_files_name, j_filepath);
|
||||
jsize j_size = env->GetArrayLength(j_object);
|
||||
for (int i = 0; i < j_size; i++) {
|
||||
auto string = (jstring)(env->GetObjectArrayElement(j_object, i));
|
||||
vector.emplace_back(env->GetStringUTFChars(string, nullptr));
|
||||
}
|
||||
return vector;
|
||||
}
|
||||
|
||||
bool CopyFile(const std::string& source, const std::string& destination_path,
|
||||
const std::string& destination_filename) {
|
||||
if (copy_file == nullptr)
|
||||
return false;
|
||||
auto env = GetEnvForThread();
|
||||
jstring j_source_path = env->NewStringUTF(source.c_str());
|
||||
jstring j_destination_path = env->NewStringUTF(destination_path.c_str());
|
||||
jstring j_destination_filename = env->NewStringUTF(destination_filename.c_str());
|
||||
return env->CallStaticBooleanMethod(native_library, copy_file, j_source_path,
|
||||
j_destination_path, j_destination_filename);
|
||||
}
|
||||
|
||||
bool RenameFile(const std::string& source, const std::string& filename) {
|
||||
if (rename_file == nullptr)
|
||||
return false;
|
||||
auto env = GetEnvForThread();
|
||||
jstring j_source_path = env->NewStringUTF(source.c_str());
|
||||
jstring j_destination_path = env->NewStringUTF(filename.c_str());
|
||||
return env->CallStaticBooleanMethod(native_library, rename_file, j_source_path,
|
||||
j_destination_path);
|
||||
}
|
||||
|
||||
#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 AndroidStorage
|
||||
#endif
|
@ -0,0 +1,84 @@
|
||||
// Copyright 2023 Citra Emulator Project
|
||||
// Licensed under GPLv2 or any later version
|
||||
// Refer to the license.txt file included.
|
||||
|
||||
#pragma once
|
||||
|
||||
#ifdef ANDROID
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <fcntl.h>
|
||||
#include <jni.h>
|
||||
|
||||
#define ANDROID_STORAGE_FUNCTIONS(V) \
|
||||
V(CreateFile, bool, (const std::string& directory, const std::string& filename), create_file, \
|
||||
"createFile", "(Ljava/lang/String;Ljava/lang/String;)Z") \
|
||||
V(CreateDir, bool, (const std::string& directory, const std::string& filename), create_dir, \
|
||||
"createDir", "(Ljava/lang/String;Ljava/lang/String;)Z") \
|
||||
V(OpenContentUri, int, (const std::string& filepath, AndroidOpenMode openmode), \
|
||||
open_content_uri, "openContentUri", "(Ljava/lang/String;Ljava/lang/String;)I") \
|
||||
V(GetFilesName, std::vector<std::string>, (const std::string& filepath), get_files_name, \
|
||||
"getFilesName", "(Ljava/lang/String;)[Ljava/lang/String;") \
|
||||
V(CopyFile, bool, \
|
||||
(const std::string& source, const std::string& destination_path, \
|
||||
const std::string& destination_filename), \
|
||||
copy_file, "copyFile", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Z") \
|
||||
V(RenameFile, bool, (const std::string& source, const std::string& filename), rename_file, \
|
||||
"renameFile", "(Ljava/lang/String;Ljava/lang/String;)Z")
|
||||
#define ANDROID_SINGLE_PATH_DETERMINE_FUNCTIONS(V) \
|
||||
V(IsDirectory, bool, is_directory, CallStaticBooleanMethod, "isDirectory", \
|
||||
"(Ljava/lang/String;)Z") \
|
||||
V(FileExists, bool, file_exists, CallStaticBooleanMethod, "fileExists", \
|
||||
"(Ljava/lang/String;)Z") \
|
||||
V(GetSize, std::uint64_t, get_size, CallStaticLongMethod, "getSize", "(Ljava/lang/String;)J") \
|
||||
V(DeleteDocument, bool, delete_document, CallStaticBooleanMethod, "deleteDocument", \
|
||||
"(Ljava/lang/String;)Z")
|
||||
namespace AndroidStorage {
|
||||
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
|
||||
// Reference:
|
||||
// https://developer.android.com/reference/android/os/ParcelFileDescriptor#parseMode(java.lang.String)
|
||||
enum class AndroidOpenMode {
|
||||
READ = O_RDONLY, // "r"
|
||||
WRITE = O_WRONLY, // "w"
|
||||
READ_WRITE = O_RDWR, // "rw"
|
||||
WRITE_APPEND = O_WRONLY | O_APPEND, // "wa"
|
||||
WRITE_TRUNCATE = O_WRONLY | O_TRUNC, // "wt
|
||||
READ_WRITE_APPEND = O_RDWR | O_APPEND, // "rwa"
|
||||
READ_WRITE_TRUNCATE = O_RDWR | O_TRUNC, // "rwt"
|
||||
NEVER = EINVAL,
|
||||
};
|
||||
|
||||
inline AndroidOpenMode operator|(AndroidOpenMode a, int b) {
|
||||
return static_cast<AndroidOpenMode>(static_cast<int>(a) | b);
|
||||
}
|
||||
|
||||
AndroidOpenMode ParseOpenmode(const std::string_view openmode);
|
||||
|
||||
void InitJNI(JNIEnv* env, jclass clazz);
|
||||
|
||||
void CleanupJNI();
|
||||
|
||||
#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 AndroidStorage
|
||||
#endif
|
Loading…
Reference in New Issue