diff --git a/android/.idea/codeStyles/codeStyleConfig.xml b/android/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 000000000..a55e7a179 --- /dev/null +++ b/android/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/android/app/src/cpp/android_host_interface.cpp b/android/app/src/cpp/android_host_interface.cpp index 9b9fa79cf..99b21248f 100644 --- a/android/app/src/cpp/android_host_interface.cpp +++ b/android/app/src/cpp/android_host_interface.cpp @@ -5,6 +5,7 @@ #include "android_audio_stream.h" #include "android_gles_host_display.h" #include "core/gpu.h" +#include "core/game_list.h" #include "core/host_display.h" #include "core/system.h" #include @@ -35,6 +36,9 @@ static AndroidHostInterface* GetNativeClass(JNIEnv* env, jobject obj) static std::string JStringToString(JNIEnv* env, jstring str) { + if (str == nullptr) + return {}; + jsize length = env->GetStringUTFLength(str); if (length == 0) return {}; @@ -427,3 +431,47 @@ DEFINE_JNI_ARGS_METHOD(void, AndroidHostInterface_surfaceChanged, jobject obj, j hi->RunOnEmulationThread( [hi, native_surface, format, width, height]() { hi->SurfaceChanged(native_surface, format, width, height); }, true); } + +DEFINE_JNI_ARGS_METHOD(jarray, GameList_getEntries, jobject unused, jstring j_cache_path, jstring j_redump_dat_path, jarray j_search_directories, jboolean search_recursively) +{ + const std::string cache_path = JStringToString(env, j_cache_path); + const std::string redump_dat_path = JStringToString(env, j_redump_dat_path); + + GameList gl; + if (!redump_dat_path.empty()) + gl.ParseRedumpDatabase(redump_dat_path.c_str()); + + const jsize search_directories_size = env->GetArrayLength(j_search_directories); + for (jsize i = 0; i < search_directories_size; i++) + { + jobject search_dir_obj = env->GetObjectArrayElement(reinterpret_cast(j_search_directories), i); + const std::string search_dir = JStringToString(env, reinterpret_cast(search_dir_obj)); + if (!search_dir.empty()) + gl.AddDirectory(search_dir.c_str(), search_recursively); + } + + jclass entry_class = env->FindClass("com/github/stenzek/duckstation/GameListEntry"); + Assert(entry_class != nullptr); + + jmethodID entry_constructor = env->GetMethodID(entry_class, "", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;J)V"); + Assert(entry_constructor != nullptr); + + jobjectArray entry_array = env->NewObjectArray(gl.GetEntryCount(), entry_class, nullptr); + Assert(entry_array != nullptr); + + u32 counter = 0; + for (const GameList::GameListEntry& entry : gl.GetEntries()) + { + jstring path = env->NewStringUTF(entry.path.c_str()); + jstring code = env->NewStringUTF(entry.code.c_str()); + jstring title = env->NewStringUTF(entry.title.c_str()); + jstring region = env->NewStringUTF(Settings::GetConsoleRegionName(entry.region)); + jlong size = entry.total_size; + + jobject entry_jobject = env->NewObject(entry_class, entry_constructor, path, code, title, region, size); + + env->SetObjectArrayElement(entry_array, counter++, entry_jobject); + } + + return entry_array; +} \ No newline at end of file diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/ConsoleRegion.java b/android/app/src/main/java/com/github/stenzek/duckstation/ConsoleRegion.java new file mode 100644 index 000000000..0f56417a1 --- /dev/null +++ b/android/app/src/main/java/com/github/stenzek/duckstation/ConsoleRegion.java @@ -0,0 +1,8 @@ +package com.github.stenzek.duckstation; + +public enum ConsoleRegion { + AutoDetect, + NTSC_J, + NTSC_U, + PAL +} diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/EmulationActivity.java b/android/app/src/main/java/com/github/stenzek/duckstation/EmulationActivity.java index f598d5fd1..d8f7a024c 100644 --- a/android/app/src/main/java/com/github/stenzek/duckstation/EmulationActivity.java +++ b/android/app/src/main/java/com/github/stenzek/duckstation/EmulationActivity.java @@ -21,7 +21,9 @@ import androidx.core.app.NavUtils; * status bar and navigation/system bar) with user interaction. */ public class EmulationActivity extends AppCompatActivity implements SurfaceHolder.Callback { - /** Interface to the native emulator core */ + /** + * Interface to the native emulator core + */ AndroidHostInterface mHostInterface; /** @@ -104,11 +106,13 @@ public class EmulationActivity extends AppCompatActivity implements SurfaceHolde return; } - String filename = new String(); - String state_filename = new String(); - if (!mHostInterface.startEmulationThread(holder.getSurface(),filename, state_filename)) - { + String bootPath = getIntent().getStringExtra("bootPath"); + String bootSaveStatePath = getIntent().getStringExtra("bootSaveStatePath"); + + if (!mHostInterface + .startEmulationThread(holder.getSurface(), bootPath, bootSaveStatePath)) { Log.e("EmulationActivity", "Failed to start emulation thread"); + finishActivity(0); return; } } @@ -133,7 +137,7 @@ public class EmulationActivity extends AppCompatActivity implements SurfaceHolde } mVisible = true; - mContentView = (SurfaceView)findViewById(R.id.fullscreen_content); + mContentView = (SurfaceView) findViewById(R.id.fullscreen_content); Log.e("EmulationActivity", "adding callback"); mContentView.getHolder().addCallback(this); diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/FileUtil.java b/android/app/src/main/java/com/github/stenzek/duckstation/FileUtil.java new file mode 100644 index 000000000..98825fd55 --- /dev/null +++ b/android/app/src/main/java/com/github/stenzek/duckstation/FileUtil.java @@ -0,0 +1,95 @@ +package com.github.stenzek.duckstation; + +// https://stackoverflow.com/questions/34927748/android-5-0-documentfile-from-tree-uri + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.content.Context; +import android.net.Uri; +import android.os.Build; +import android.os.storage.StorageManager; +import android.provider.DocumentsContract; + +import androidx.annotation.Nullable; + +import java.io.File; +import java.lang.reflect.Array; +import java.lang.reflect.Method; + +public final class FileUtil { + static String TAG="TAG"; + private static final String PRIMARY_VOLUME_NAME = "primary"; + + @Nullable + public static String getFullPathFromTreeUri(@Nullable final Uri treeUri, Context con) { + if (treeUri == null) return null; + String volumePath = getVolumePath(getVolumeIdFromTreeUri(treeUri),con); + if (volumePath == null) return File.separator; + if (volumePath.endsWith(File.separator)) + volumePath = volumePath.substring(0, volumePath.length() - 1); + + String documentPath = getDocumentPathFromTreeUri(treeUri); + if (documentPath.endsWith(File.separator)) + documentPath = documentPath.substring(0, documentPath.length() - 1); + + if (documentPath.length() > 0) { + if (documentPath.startsWith(File.separator)) + return volumePath + documentPath; + else + return volumePath + File.separator + documentPath; + } + else return volumePath; + } + + + @SuppressLint("ObsoleteSdkInt") + private static String getVolumePath(final String volumeId, Context context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return null; + try { + StorageManager mStorageManager = + (StorageManager) context.getSystemService(Context.STORAGE_SERVICE); + Class storageVolumeClazz = Class.forName("android.os.storage.StorageVolume"); + Method getVolumeList = mStorageManager.getClass().getMethod("getVolumeList"); + Method getUuid = storageVolumeClazz.getMethod("getUuid"); + Method getPath = storageVolumeClazz.getMethod("getPath"); + Method isPrimary = storageVolumeClazz.getMethod("isPrimary"); + Object result = getVolumeList.invoke(mStorageManager); + + final int length = Array.getLength(result); + for (int i = 0; i < length; i++) { + Object storageVolumeElement = Array.get(result, i); + String uuid = (String) getUuid.invoke(storageVolumeElement); + Boolean primary = (Boolean) isPrimary.invoke(storageVolumeElement); + + // primary volume? + if (primary && PRIMARY_VOLUME_NAME.equals(volumeId)) + return (String) getPath.invoke(storageVolumeElement); + + // other volumes? + if (uuid != null && uuid.equals(volumeId)) + return (String) getPath.invoke(storageVolumeElement); + } + // not found. + return null; + } catch (Exception ex) { + return null; + } + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + private static String getVolumeIdFromTreeUri(final Uri treeUri) { + final String docId = DocumentsContract.getTreeDocumentId(treeUri); + final String[] split = docId.split(":"); + if (split.length > 0) return split[0]; + else return null; + } + + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + private static String getDocumentPathFromTreeUri(final Uri treeUri) { + final String docId = DocumentsContract.getTreeDocumentId(treeUri); + final String[] split = docId.split(":"); + if ((split.length >= 2) && (split[1] != null)) return split[1]; + else return File.separator; + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/GameList.java b/android/app/src/main/java/com/github/stenzek/duckstation/GameList.java new file mode 100644 index 000000000..94ee34279 --- /dev/null +++ b/android/app/src/main/java/com/github/stenzek/duckstation/GameList.java @@ -0,0 +1,102 @@ +package com.github.stenzek.duckstation; + +import android.app.Activity; +import android.content.Context; +import android.content.SharedPreferences; +import android.util.ArraySet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.TextView; + +import androidx.preference.PreferenceManager; + +import java.util.Set; + +public class GameList { + static { + System.loadLibrary("duckstation-native"); + } + + private Context mContext; + private String mCachePath; + private String mRedumpDatPath; + private String[] mSearchDirectories; + private boolean mSearchRecursively; + private GameListEntry[] mEntries; + + static private native GameListEntry[] getEntries(String cachePath, String redumpDatPath, + String[] searchDirectories, + boolean searchRecursively); + + public GameList(Context context) { + mContext = context; + refresh(); + } + + public void refresh() { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(mContext); + mCachePath = preferences.getString("GameList/CachePath", ""); + mRedumpDatPath = preferences.getString("GameList/RedumpDatPath", ""); + + Set searchDirectories = + preferences.getStringSet("GameList/SearchDirectories", null); + if (searchDirectories != null) { + mSearchDirectories = new String[searchDirectories.size()]; + searchDirectories.toArray(mSearchDirectories); + } else { + mSearchDirectories = new String[0]; + } + + mSearchRecursively = preferences.getBoolean("GameList/SearchRecursively", true); + + // Search and get entries from native code + mEntries = getEntries(mCachePath, mRedumpDatPath, mSearchDirectories, mSearchRecursively); + } + + public int getEntryCount() { + return mEntries.length; + } + + public GameListEntry getEntry(int index) { + return mEntries[index]; + } + + private class ListViewAdapter extends BaseAdapter { + @Override + public int getCount() { + return mEntries.length; + } + + @Override + public Object getItem(int position) { + return mEntries[position]; + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public int getViewTypeCount() { + return 1; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + if (convertView == null) { + convertView = LayoutInflater.from(mContext) + .inflate(R.layout.game_list_view_entry, parent, false); + } + + mEntries[position].fillView(convertView); + return convertView; + } + } + + public BaseAdapter getListViewAdapter() { + return new ListViewAdapter(); + } +} diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/GameListEntry.java b/android/app/src/main/java/com/github/stenzek/duckstation/GameListEntry.java new file mode 100644 index 000000000..d4d4cab30 --- /dev/null +++ b/android/app/src/main/java/com/github/stenzek/duckstation/GameListEntry.java @@ -0,0 +1,69 @@ +package com.github.stenzek.duckstation; + +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.core.content.ContextCompat; + +public class GameListEntry { + private String mPath; + private String mCode; + private String mTitle; + private ConsoleRegion mRegion; + private long mSize; + + public GameListEntry(String path, String code, String title, String region, long size) { + mPath = path; + mCode = code; + mTitle = title; + mSize = size; + + try { + mRegion = ConsoleRegion.valueOf(region); + } catch (IllegalArgumentException e) { + mRegion = ConsoleRegion.NTSC_U; + } + } + + public String getPath() { + return mPath; + } + + public String getCode() { + return mCode; + } + + public String getTitle() { + return mTitle; + } + + public ConsoleRegion getRegion() { + return mRegion; + } + + public void fillView(View view) { + ((TextView) view.findViewById(R.id.game_list_view_entry_title)).setText(mTitle); + ((TextView) view.findViewById(R.id.game_list_view_entry_path)).setText(mPath); + + String sizeString = String.format("%.2f MB", (double) mSize / 1048576.0); + ((TextView) view.findViewById(R.id.game_list_view_entry_size)).setText(sizeString); + + int drawableId; + switch (mRegion) { + case NTSC_J: + drawableId = R.drawable.flag_jp; + break; + case NTSC_U: + default: + drawableId = R.drawable.flag_us; + break; + case PAL: + drawableId = R.drawable.flag_eu; + break; + } + + ((ImageView) view.findViewById(R.id.game_list_view_entry_region_icon)) + .setImageDrawable(ContextCompat.getDrawable(view.getContext(), drawableId)); + } +} diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/MainActivity.java b/android/app/src/main/java/com/github/stenzek/duckstation/MainActivity.java index 3250312a8..38bdbecde 100644 --- a/android/app/src/main/java/com/github/stenzek/duckstation/MainActivity.java +++ b/android/app/src/main/java/com/github/stenzek/duckstation/MainActivity.java @@ -1,7 +1,9 @@ package com.github.stenzek.duckstation; import android.Manifest; +import android.content.SharedPreferences; import android.content.pm.PackageManager; +import android.net.Uri; import android.os.Bundle; import com.google.android.material.floatingactionbutton.FloatingActionButton; @@ -11,13 +13,32 @@ import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.widget.Toolbar; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; +import androidx.documentfile.provider.DocumentFile; +import androidx.preference.PreferenceManager; import android.content.Intent; + +import androidx.collection.ArraySet; + +import android.util.Log; +import android.view.Gravity; import android.view.View; import android.view.Menu; import android.view.MenuItem; +import android.widget.AdapterView; +import android.widget.ListView; +import android.widget.PopupMenu; + +import java.util.HashSet; +import java.util.Set; +import java.util.prefs.Preferences; public class MainActivity extends AppCompatActivity { + private static final int REQUEST_EXTERNAL_STORAGE_PERMISSIONS = 1; + private static final int REQUEST_ADD_DIRECTORY_TO_GAME_LIST = 2; + + private GameList mGameList; + private ListView mGameListView; @Override protected void onCreate(Bundle savedInstanceState) { @@ -30,9 +51,39 @@ public class MainActivity extends AppCompatActivity { fab.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { - /*Snackbar.make(view, "Replace with your own action", Snackbar.LENGTH_LONG) - .setAction("Action", null).show();*/ - startEmulation("nonexistant.cue"); + Intent i = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + i.addCategory(Intent.CATEGORY_DEFAULT); + i.putExtra(Intent.EXTRA_LOCAL_ONLY, true); + startActivityForResult(Intent.createChooser(i, "Choose directory"), + REQUEST_ADD_DIRECTORY_TO_GAME_LIST); + } + }); + + // Set up game list view. + mGameList = new GameList(this); + mGameListView = findViewById(R.id.game_list_view); + mGameListView.setAdapter(mGameList.getListViewAdapter()); + mGameListView.setOnItemClickListener(new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + startEmulation(mGameList.getEntry(position).getPath()); + } + }); + mGameListView.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() { + @Override + public boolean onItemLongClick(AdapterView parent, View view, int position, + long id) { + PopupMenu menu = new PopupMenu(MainActivity.this, view, + Gravity.RIGHT | Gravity.TOP); + menu.getMenuInflater().inflate(R.menu.menu_game_list_entry, menu.getMenu()); + menu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() { + @Override + public boolean onMenuItemClick(MenuItem item) { + return false; + } + }); + menu.show(); + return true; } }); } @@ -61,22 +112,71 @@ public class MainActivity extends AppCompatActivity { return super.onOptionsItemSelected(item); } + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + switch (requestCode) { + case REQUEST_ADD_DIRECTORY_TO_GAME_LIST: { + if (resultCode != RESULT_OK) + return; + + Uri treeUri = data.getData(); + String path = FileUtil.getFullPathFromTreeUri(treeUri, this); + if (path.length() < 5) { + // sanity check for non-external paths.. do we need permissions or something? + return; + } + + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); + Set currentValues = prefs.getStringSet("GameList/SearchDirectories", null); + if (currentValues == null) + currentValues = new HashSet(); + + currentValues.add(path); + SharedPreferences.Editor editor = prefs.edit(); + editor.putStringSet("GameList/SearchDirectories", currentValues); + editor.apply(); + Log.i("MainActivity", "Added path '" + path + "' to game list search directories"); + mGameList.refresh(); + } + break; + } + } + private boolean checkForExternalStoragePermissions() { - if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED && - ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) - { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) == + PackageManager.PERMISSION_GRANTED && + ContextCompat + .checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) == + PackageManager.PERMISSION_GRANTED) { return true; } - ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE}, 0); + ActivityCompat.requestPermissions(this, + new String[]{Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE}, + REQUEST_EXTERNAL_STORAGE_PERMISSIONS); return false; } + public void onRequestPermissionsResult(int requestCode, String[] permissions, + int[] grantResults) { + // check that all were successful + for (int i = 0; i < grantResults.length; i++) { + if (grantResults[i] != PackageManager.PERMISSION_GRANTED) { + Snackbar.make(mGameListView, + "External storage permissions are required to start emulation.", + Snackbar.LENGTH_LONG); + } + } + } + private boolean startEmulation(String bootPath) { if (!checkForExternalStoragePermissions()) { - Snackbar.make(findViewById(R.id.fab), "External storage permissions are required to start emulation.", Snackbar.LENGTH_LONG); return false; } + Intent intent = new Intent(this, EmulationActivity.class); intent.putExtra("bootPath", bootPath); startActivity(intent); diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/NativeLibrary.java b/android/app/src/main/java/com/github/stenzek/duckstation/NativeLibrary.java deleted file mode 100644 index 2f7e759ed..000000000 --- a/android/app/src/main/java/com/github/stenzek/duckstation/NativeLibrary.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.github.stenzek.duckstation; - -public class NativeLibrary { - static - { - System.loadLibrary("duckstation-native"); - } - - public native boolean createSystem(); - public native boolean bootSystem(String filename, String stateFilename); - public native void runFrame(); -} diff --git a/android/app/src/main/res/drawable/flag_eu.xml b/android/app/src/main/res/drawable/flag_eu.xml new file mode 100644 index 000000000..a3bf3c7f0 --- /dev/null +++ b/android/app/src/main/res/drawable/flag_eu.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/flag_jp.xml b/android/app/src/main/res/drawable/flag_jp.xml new file mode 100644 index 000000000..8ff9d256f --- /dev/null +++ b/android/app/src/main/res/drawable/flag_jp.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/flag_us.xml b/android/app/src/main/res/drawable/flag_us.xml new file mode 100644 index 000000000..08c798d33 --- /dev/null +++ b/android/app/src/main/res/drawable/flag_us.xml @@ -0,0 +1,332 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index ddd8a31b1..39a7c463f 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -29,6 +29,7 @@ android:layout_height="wrap_content" android:layout_gravity="bottom|end" android:layout_margin="@dimen/fab_margin" - app:srcCompat="@android:drawable/ic_dialog_email" /> + app:backgroundTint="@android:color/background_light" + app:srcCompat="@android:drawable/ic_input_add" /> \ No newline at end of file diff --git a/android/app/src/main/res/layout/content_main.xml b/android/app/src/main/res/layout/content_main.xml index a2189464f..1ccf56cdf 100644 --- a/android/app/src/main/res/layout/content_main.xml +++ b/android/app/src/main/res/layout/content_main.xml @@ -1,21 +1,20 @@ - + tools:context=".MainActivity" + tools:showIn="@layout/activity_main"> - - + app:layout_constraintTop_toTopOf="parent" +/> \ No newline at end of file diff --git a/android/app/src/main/res/layout/game_list_view_entry.xml b/android/app/src/main/res/layout/game_list_view_entry.xml new file mode 100644 index 000000000..1ba1ab962 --- /dev/null +++ b/android/app/src/main/res/layout/game_list_view_entry.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/menu/menu_game_list_entry.xml b/android/app/src/main/res/menu/menu_game_list_entry.xml new file mode 100644 index 000000000..45bbbb8c6 --- /dev/null +++ b/android/app/src/main/res/menu/menu_game_list_entry.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + \ No newline at end of file