diff --git a/CMakeLists.txt b/CMakeLists.txt index 732ec793e..ef26e4010 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -388,8 +388,10 @@ if(ENABLE_VULKAN) set(ALL_SOURCES ${ALL_SOURCES} ${RENDERER_VK_SOURCE_FILES}) endif() -if(ANDROID) +if(ANDROID AND NOT BUILD_HYDRA_CORE) + set(HEADER_FILES ${HEADER_FILES} include/jni_driver.hpp) set(ALL_SOURCES ${ALL_SOURCES} src/jni_driver.cpp) + target_compile_definitions(Alber PRIVATE PANDA3DS_FRONTEND_PANDROID=1) endif() if(BUILD_HYDRA_CORE) diff --git a/include/jni_driver.hpp b/include/jni_driver.hpp new file mode 100644 index 000000000..94408db41 --- /dev/null +++ b/include/jni_driver.hpp @@ -0,0 +1,8 @@ +#include + +#include "helpers.hpp" + +class Pandroid { + public: + static void onSmdhLoaded(const std::vector& smdh); +}; diff --git a/src/core/loader/ncch.cpp b/src/core/loader/ncch.cpp index 2546aa01f..8881812de 100644 --- a/src/core/loader/ncch.cpp +++ b/src/core/loader/ncch.cpp @@ -6,6 +6,10 @@ #include "loader/ncch.hpp" #include "memory.hpp" +#ifdef PANDA3DS_FRONTEND_PANDROID +#include "jni_driver.hpp" +#endif + #include bool NCCH::loadFromHeader(Crypto::AESEngine &aesEngine, IOFile& file, const FSInfo &info) { @@ -255,6 +259,11 @@ bool NCCH::parseSMDH(const std::vector& smdh) { return false; } + // In the Android version, notify the application that we're loading an SMDH file, to extract data for the title list +#ifdef PANDA3DS_FRONTEND_PANDROID + Pandroid::onSmdhLoaded(smdh); +#endif + // Bitmask showing which regions are allowed. // https://www.3dbrew.org/wiki/SMDH#Region_Lockout const u32 regionMasks = *(u32*)&smdh[0x2018]; diff --git a/src/jni_driver.cpp b/src/jni_driver.cpp index 8f5c352ed..c38359b42 100644 --- a/src/jni_driver.cpp +++ b/src/jni_driver.cpp @@ -1,3 +1,5 @@ +#include "jni_driver.hpp" + #include #include #include @@ -12,6 +14,8 @@ std::unique_ptr emulator = nullptr; HIDService* hidService = nullptr; RendererGL* renderer = nullptr; bool romLoaded = false; +JavaVM* jvm = nullptr; +const char* alberClass = "com/panda3ds/pandroid/AlberDriver"; #define AlberFunction(type, name) JNIEXPORT type JNICALL Java_com_panda3ds_pandroid_AlberDriver_##name @@ -20,7 +24,36 @@ void throwException(JNIEnv* env, const char* message) { env->ThrowNew(exceptionClass, message); } +JNIEnv* jniEnv() { + JNIEnv* env; + auto status = jvm->GetEnv((void**)&env, JNI_VERSION_1_6); + if (status == JNI_EDETACHED) { + jvm->AttachCurrentThread(&env, nullptr); + } else if (status != JNI_OK) { + throw std::runtime_error("Failed to obtain JNIEnv from JVM!!"); + } + + return env; +} + +void Pandroid::onSmdhLoaded(const std::vector& smdh) { + JNIEnv* env = jniEnv(); + int size = smdh.size(); + + jbyteArray result = env->NewByteArray(size); + env->SetByteArrayRegion(result, 0, size, (jbyte*)smdh.data()); + + auto classLoader = env->FindClass(alberClass); + auto method = env->GetStaticMethodID(classLoader, "OnSmdhLoaded", "([B)V"); + + env->CallStaticVoidMethod(classLoader, method, result); + env->DeleteLocalRef(result); +} + extern "C" { + +AlberFunction(void, Setup)(JNIEnv* env, jobject obj) { env->GetJavaVM(&jvm); } + AlberFunction(void, Initialize)(JNIEnv* env, jobject obj) { emulator = std::make_unique(); @@ -73,4 +106,4 @@ AlberFunction(void, SetCirclepadAxis)(JNIEnv* env, jobject obj, jint x, jint y) } } -#undef AlberFunction \ No newline at end of file +#undef AlberFunction diff --git a/src/pandroid/app/build.gradle.kts b/src/pandroid/app/build.gradle.kts index 276eb552e..f1feaf0d4 100644 --- a/src/pandroid/app/build.gradle.kts +++ b/src/pandroid/app/build.gradle.kts @@ -38,5 +38,7 @@ android { dependencies { implementation("androidx.appcompat:appcompat:1.6.1") implementation("com.google.android.material:material:1.8.0") + implementation("androidx.preference:preference:1.2.1") implementation("androidx.constraintlayout:constraintlayout:2.1.4") + implementation("com.google.code.gson:gson:2.10.1") } \ No newline at end of file diff --git a/src/pandroid/app/src/main/AndroidManifest.xml b/src/pandroid/app/src/main/AndroidManifest.xml index 15bf6270c..8caf9bb06 100644 --- a/src/pandroid/app/src/main/AndroidManifest.xml +++ b/src/pandroid/app/src/main/AndroidManifest.xml @@ -24,7 +24,8 @@ tools:targetApi="31"> + android:exported="true" + android:configChanges="orientation"> @@ -34,5 +35,11 @@ android:name=".app.GameActivity" android:configChanges="screenSize|screenLayout|orientation|density|uiMode"> + + + diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/AlberDriver.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/AlberDriver.java index 81bf29a52..2a496693a 100644 --- a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/AlberDriver.java +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/AlberDriver.java @@ -1,8 +1,16 @@ package com.panda3ds.pandroid; +import android.util.Log; + +import com.panda3ds.pandroid.data.SMDH; +import com.panda3ds.pandroid.data.game.GameMetadata; +import com.panda3ds.pandroid.utils.Constants; +import com.panda3ds.pandroid.utils.GameUtils; + public class AlberDriver { AlberDriver() { super(); } + public static native void Setup(); public static native void Initialize(); public static native void RunFrame(int fbo); public static native boolean HasRomLoaded(); @@ -15,5 +23,14 @@ public class AlberDriver { public static native void TouchScreenUp(); public static native void TouchScreenDown(int x, int y); + public static void OnSmdhLoaded(byte[] buffer) { + SMDH smdh = new SMDH(buffer); + Log.i(Constants.LOG_TAG, "Loaded rom SDMH"); + Log.i(Constants.LOG_TAG, String.format("Are you playing '%s' published by '%s'", smdh.getTitle(), smdh.getPublisher())); + GameMetadata game = GameUtils.getCurrentGame(); + GameUtils.removeGame(game); + GameUtils.addGame(GameMetadata.applySMDH(game, smdh)); + } + static { System.loadLibrary("Alber"); } } \ No newline at end of file diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/BaseActivity.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/BaseActivity.java index 72926b076..527d49668 100644 --- a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/BaseActivity.java +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/BaseActivity.java @@ -1,5 +1,38 @@ package com.panda3ds.pandroid.app; +import android.os.Bundle; +import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; +import com.panda3ds.pandroid.R; +import com.panda3ds.pandroid.data.config.GlobalConfig; -public class BaseActivity extends AppCompatActivity {} + +public class BaseActivity extends AppCompatActivity { + private int currentTheme = GlobalConfig.get(GlobalConfig.KEY_APP_THEME); + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + applyTheme(); + super.onCreate(savedInstanceState); + } + + @Override + protected void onResume() { + super.onResume(); + + if (GlobalConfig.get(GlobalConfig.KEY_APP_THEME) != currentTheme) { + recreate(); + } + } + + private void applyTheme() { + switch (GlobalConfig.get(GlobalConfig.KEY_APP_THEME)) { + case GlobalConfig.THEME_ANDROID: setTheme(R.style.Theme_Pandroid); break; + case GlobalConfig.THEME_LIGHT: setTheme(R.style.Theme_Pandroid_Light); break; + case GlobalConfig.THEME_DARK: setTheme(R.style.Theme_Pandroid_Dark); break; + case GlobalConfig.THEME_BLACK: setTheme(R.style.Theme_Pandroid_Black); break; + } + + currentTheme = GlobalConfig.get(GlobalConfig.KEY_APP_THEME); + } +} diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/GameActivity.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/GameActivity.java index 2da73b972..03593b44c 100644 --- a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/GameActivity.java +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/GameActivity.java @@ -2,21 +2,28 @@ import android.content.Intent; import android.os.Bundle; +import android.view.KeyEvent; +import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.view.WindowManager; import android.widget.CheckBox; import android.widget.FrameLayout; import android.widget.Toast; - import androidx.annotation.Nullable; - +import com.panda3ds.pandroid.AlberDriver; import com.panda3ds.pandroid.R; +import com.panda3ds.pandroid.app.game.AlberInputListener; +import com.panda3ds.pandroid.data.config.GlobalConfig; +import com.panda3ds.pandroid.input.InputHandler; +import com.panda3ds.pandroid.input.InputMap; import com.panda3ds.pandroid.utils.Constants; import com.panda3ds.pandroid.view.PandaGlSurfaceView; import com.panda3ds.pandroid.view.PandaLayoutController; public class GameActivity extends BaseActivity { + private final AlberInputListener inputListener = new AlberInputListener(this); + @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -38,7 +45,13 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { PandaLayoutController controllerLayout = findViewById(R.id.controller_layout); controllerLayout.initialize(); - ((CheckBox) findViewById(R.id.hide_screen_controller)).setOnCheckedChangeListener((buttonView, isChecked) -> findViewById(R.id.overlay_controller).setVisibility(isChecked ? View.VISIBLE : View.INVISIBLE)); + ((CheckBox) findViewById(R.id.hide_screen_controller)).setOnCheckedChangeListener((buttonView, checked) -> { + findViewById(R.id.overlay_controller).setVisibility(checked ? View.VISIBLE : View.GONE); + findViewById(R.id.overlay_controller).invalidate(); + findViewById(R.id.overlay_controller).requestLayout(); + GlobalConfig.set(GlobalConfig.KEY_SCREEN_GAMEPAD_VISIBLE, checked); + }); + ((CheckBox) findViewById(R.id.hide_screen_controller)).setChecked(GlobalConfig.get(GlobalConfig.KEY_SCREEN_GAMEPAD_VISIBLE)); } @Override @@ -46,5 +59,41 @@ protected void onResume() { super.onResume(); getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_FULLSCREEN | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION); getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + InputHandler.reset(); + InputHandler.setMotionDeadZone(InputMap.getDeadZone()); + InputHandler.setEventListener(inputListener); + } + + @Override + protected void onPause() { + super.onPause(); + InputHandler.reset(); + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + if (InputHandler.processKeyEvent(event)) { + return true; + } + + return super.dispatchKeyEvent(event); + } + + @Override + public boolean dispatchGenericMotionEvent(MotionEvent ev) { + if (InputHandler.processMotionEvent(ev)) { + return true; + } + + return super.dispatchGenericMotionEvent(ev); + } + + @Override + protected void onDestroy() { + if (AlberDriver.HasRomLoaded()) { + AlberDriver.Finalize(); + } + + super.onDestroy(); } } diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/MainActivity.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/MainActivity.java index f4fc27bf1..ee7acf883 100644 --- a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/MainActivity.java +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/MainActivity.java @@ -8,17 +8,26 @@ import android.os.Build; import android.os.Bundle; import android.os.Environment; -import android.widget.Toast; -import androidx.appcompat.app.AppCompatActivity; +import android.view.MenuItem; +import androidx.annotation.NonNull; import androidx.core.app.ActivityCompat; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import com.google.android.material.navigation.NavigationBarView; import com.panda3ds.pandroid.R; -import com.panda3ds.pandroid.utils.Constants; -import com.panda3ds.pandroid.utils.PathUtils; +import com.panda3ds.pandroid.app.main.GamesFragment; +import com.panda3ds.pandroid.app.main.SearchFragment; +import com.panda3ds.pandroid.app.main.SettingsFragment; -public class MainActivity extends BaseActivity { + +public class MainActivity extends BaseActivity implements NavigationBarView.OnItemSelectedListener { private static final int PICK_ROM = 2; private static final int PERMISSION_REQUEST_CODE = 3; + private final GamesFragment gamesFragment = new GamesFragment(); + private final SearchFragment searchFragment = new SearchFragment(); + private final SettingsFragment settingsFragment = new SettingsFragment(); + private void openFile() { Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); @@ -40,18 +49,28 @@ protected void onCreate(Bundle savedInstanceState) { } setContentView(R.layout.activity_main); - findViewById(R.id.load_rom).setOnClickListener(v -> { openFile(); }); + + NavigationBarView bar = findViewById(R.id.navigation); + bar.setOnItemSelectedListener(this); + bar.postDelayed(() -> bar.setSelectedItemId(bar.getSelectedItemId()), 5); } @Override - protected void onActivityResult(int requestCode, int resultCode, Intent data) { - if (requestCode == PICK_ROM) { - if (resultCode == RESULT_OK) { - String path = PathUtils.getPath(getApplicationContext(), data.getData()); - Toast.makeText(getApplicationContext(), "pandroid opening " + path, Toast.LENGTH_LONG).show(); - startActivity(new Intent(this, GameActivity.class).putExtra(Constants.ACTIVITY_PARAMETER_PATH, path)); - } - super.onActivityResult(requestCode, resultCode, data); + public boolean onNavigationItemSelected(@NonNull MenuItem item) { + int id = item.getItemId(); + FragmentManager manager = getSupportFragmentManager(); + Fragment fragment; + if (id == R.id.games) { + fragment = gamesFragment; + } else if (id == R.id.search) { + fragment = searchFragment; + } else if (id == R.id.settings) { + fragment = settingsFragment; + } else { + return false; } + + manager.beginTransaction().replace(R.id.fragment_container, fragment).commitNow(); + return true; } } \ No newline at end of file diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/PandroidApplication.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/PandroidApplication.java index 0e284db6b..0ae779dd6 100644 --- a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/PandroidApplication.java +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/PandroidApplication.java @@ -2,20 +2,25 @@ import android.app.Application; import android.content.Context; - +import com.panda3ds.pandroid.AlberDriver; import com.panda3ds.pandroid.data.config.GlobalConfig; +import com.panda3ds.pandroid.input.InputMap; +import com.panda3ds.pandroid.utils.GameUtils; + public class PandroidApplication extends Application { - private static Context appContext; + private static Context appContext; + + @Override + public void onCreate() { + super.onCreate(); + appContext = this; - @Override - public void onCreate() { - super.onCreate(); - appContext = this; - GlobalConfig.initialize(); - } + GlobalConfig.initialize(); + GameUtils.initialize(); + InputMap.initialize(); + AlberDriver.Setup(); + } - public static Context getAppContext() { - return appContext; - } + public static Context getAppContext() { return appContext; } } diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/PreferenceActivity.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/PreferenceActivity.java new file mode 100644 index 000000000..64249c5b5 --- /dev/null +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/PreferenceActivity.java @@ -0,0 +1,52 @@ +package com.panda3ds.pandroid.app; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.view.MenuItem; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +import com.panda3ds.pandroid.R; +import com.panda3ds.pandroid.utils.Constants; + +public class PreferenceActivity extends BaseActivity { + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Intent intent = getIntent(); + + setContentView(R.layout.activity_preference); + setSupportActionBar(findViewById(R.id.toolbar)); + + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + if (!intent.hasExtra(Constants.ACTIVITY_PARAMETER_FRAGMENT)) { + finish(); + return; + } + + try { + Class clazz = getClassLoader().loadClass(intent.getStringExtra(Constants.ACTIVITY_PARAMETER_FRAGMENT)); + getSupportFragmentManager().beginTransaction().replace(R.id.fragment_container, (Fragment) clazz.newInstance()).commitNow(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public static void launch(Context context, Class clazz) { + context.startActivity(new Intent(context, PreferenceActivity.class) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .putExtra(Constants.ACTIVITY_PARAMETER_FRAGMENT, clazz.getName())); + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + if (item.getItemId() == android.R.id.home) { + finish(); + } + + return super.onOptionsItemSelected(item); + } +} diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/base/BasePreferenceFragment.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/base/BasePreferenceFragment.java new file mode 100644 index 000000000..3cd28f4bf --- /dev/null +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/base/BasePreferenceFragment.java @@ -0,0 +1,18 @@ +package com.panda3ds.pandroid.app.base; + +import android.annotation.SuppressLint; +import androidx.preference.Preference; +import androidx.preference.PreferenceFragmentCompat; +import com.panda3ds.pandroid.lang.Function; + + +public abstract class BasePreferenceFragment extends PreferenceFragmentCompat { + @SuppressLint("RestrictedApi") + protected void setItemClick(String key, Function listener) { + findPreference(key).setOnPreferenceClickListener(preference -> { + listener.run(preference); + getPreferenceScreen().performClick(); + return false; + }); + } +} diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/game/AlberInputListener.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/game/AlberInputListener.java new file mode 100644 index 000000000..6daca9a61 --- /dev/null +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/game/AlberInputListener.java @@ -0,0 +1,65 @@ +package com.panda3ds.pandroid.app.game; + +import android.app.Activity; +import android.view.KeyEvent; +import com.panda3ds.pandroid.AlberDriver; +import com.panda3ds.pandroid.input.InputEvent; +import com.panda3ds.pandroid.input.InputMap; +import com.panda3ds.pandroid.input.KeyName; +import com.panda3ds.pandroid.lang.Function; +import com.panda3ds.pandroid.math.Vector2; +import java.util.Objects; + + +public class AlberInputListener implements Function { + private final Activity activity; + public AlberInputListener(Activity activity) { this.activity = activity; } + + private final Vector2 axis = new Vector2(0.0f, 0.0f); + + @Override + public void run(InputEvent event) { + KeyName key = InputMap.relative(event.getName()); + + if (Objects.equals(event.getName(), "KEYCODE_BACK")) { + activity.onBackPressed(); + return; + } + + if (key == KeyName.NULL) { + return; + } + + boolean axisChanged = false; + + switch (key) { + case AXIS_UP: + axis.y = event.getValue(); + axisChanged = true; + break; + case AXIS_DOWN: + axis.y = -event.getValue(); + axisChanged = true; + break; + case AXIS_LEFT: + axis.x = -event.getValue(); + axisChanged = true; + break; + case AXIS_RIGHT: + axis.x = event.getValue(); + axisChanged = true; + break; + default: + if (event.isDown()) { + AlberDriver.KeyDown(key.getKeyId()); + } else { + AlberDriver.KeyUp(key.getKeyId()); + } + break; + } + + if (axisChanged) { + AlberDriver.SetCirclepadAxis(Math.round(axis.x * 0x9C), Math.round(axis.y * 0x9C)); + } + } +} diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/main/GamesFragment.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/main/GamesFragment.java new file mode 100644 index 000000000..ff6e4dcae --- /dev/null +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/main/GamesFragment.java @@ -0,0 +1,79 @@ +package com.panda3ds.pandroid.app.main; + +import android.net.Uri; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; +import androidx.activity.result.ActivityResultCallback; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import com.panda3ds.pandroid.R; +import com.panda3ds.pandroid.data.game.GameMetadata; +import com.panda3ds.pandroid.utils.FileUtils; +import com.panda3ds.pandroid.utils.GameUtils; +import com.panda3ds.pandroid.view.gamesgrid.GamesGridView; + + +public class GamesFragment extends Fragment implements ActivityResultCallback { + private final ActivityResultContracts.OpenDocument openRomContract = new ActivityResultContracts.OpenDocument(); + private ActivityResultLauncher pickFileRequest; + private GamesGridView gameListView; + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_games, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + gameListView = view.findViewById(R.id.games); + + view.findViewById(R.id.add_rom).setOnClickListener((v) -> pickFileRequest.launch(new String[] {"*/*"})); + } + + @Override + public void onResume() { + super.onResume(); + gameListView.setGameList(GameUtils.getGames()); + } + + @Override + public void onActivityResult(Uri result) { + if (result != null) { + String uri = result.toString(); + if (GameUtils.findByRomPath(uri) == null) { + if (FileUtils.obtainRealPath(uri) == null) { + Toast.makeText(getContext(), "Invalid file path", Toast.LENGTH_LONG).show(); + return; + } + FileUtils.makeUriPermanent(uri, FileUtils.MODE_READ); + GameMetadata game = new GameMetadata(uri, FileUtils.getName(uri).split("\\.")[0], "Unknown"); + GameUtils.addGame(game); + GameUtils.launch(requireActivity(), game); + } + } + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + pickFileRequest = registerForActivityResult(openRomContract, this); + } + + @Override + public void onDestroy() { + if (pickFileRequest != null) { + pickFileRequest.unregister(); + pickFileRequest = null; + } + + super.onDestroy(); + } +} diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/main/SearchFragment.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/main/SearchFragment.java new file mode 100644 index 000000000..e9db7f80a --- /dev/null +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/main/SearchFragment.java @@ -0,0 +1,62 @@ +package com.panda3ds.pandroid.app.main; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.AppCompatEditText; +import androidx.fragment.app.Fragment; +import com.panda3ds.pandroid.R; +import com.panda3ds.pandroid.data.game.GameMetadata; +import com.panda3ds.pandroid.utils.GameUtils; +import com.panda3ds.pandroid.utils.SearchAgent; +import com.panda3ds.pandroid.view.SimpleTextWatcher; +import com.panda3ds.pandroid.view.gamesgrid.GamesGridView; +import java.util.ArrayList; +import java.util.List; + + +public class SearchFragment extends Fragment { + private final SearchAgent searchAgent = new SearchAgent(); + private GamesGridView gamesListView; + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_search, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + gamesListView = view.findViewById(R.id.games); + ((AppCompatEditText) view.findViewById(R.id.search_bar)).addTextChangedListener((SimpleTextWatcher) this::search); + } + + @Override + public void onResume() { + super.onResume(); + searchAgent.clearBuffer(); + for (GameMetadata game : GameUtils.getGames()) { + searchAgent.addToBuffer(game.getId(), game.getTitle(), game.getPublisher()); + } + + search(""); + } + + private void search(String query) { + List resultIds = searchAgent.search(query); + ArrayList games = new ArrayList<>(GameUtils.getGames()); + Object[] resultObj = games.stream().filter(gameMetadata -> resultIds.contains(gameMetadata.getId())).toArray(); + + games.clear(); + for (Object res : resultObj) { + games.add((GameMetadata) res); + } + + gamesListView.setGameList(games); + } +} diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/main/SettingsFragment.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/main/SettingsFragment.java new file mode 100644 index 000000000..08f2d70f8 --- /dev/null +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/main/SettingsFragment.java @@ -0,0 +1,20 @@ +package com.panda3ds.pandroid.app.main; + +import android.os.Bundle; + +import androidx.annotation.Nullable; + +import com.panda3ds.pandroid.R; +import com.panda3ds.pandroid.app.PreferenceActivity; +import com.panda3ds.pandroid.app.base.BasePreferenceFragment; +import com.panda3ds.pandroid.app.preferences.InputMapPreferences; +import com.panda3ds.pandroid.app.preferences.AppearancePreferences; + +public class SettingsFragment extends BasePreferenceFragment { + @Override + public void onCreatePreferences(@Nullable Bundle savedInstanceState, @Nullable String rootKey) { + setPreferencesFromResource(R.xml.start_preferences, rootKey); + setItemClick("inputMap", (item) -> PreferenceActivity.launch(requireContext(), InputMapPreferences.class)); + setItemClick("appearance", (item)-> PreferenceActivity.launch(requireContext(), AppearancePreferences.class)); + } +} diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/preferences/AppearancePreferences.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/preferences/AppearancePreferences.java new file mode 100644 index 000000000..63f79ddb4 --- /dev/null +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/preferences/AppearancePreferences.java @@ -0,0 +1,28 @@ +package com.panda3ds.pandroid.app.preferences; + +import android.app.Activity; +import android.os.Bundle; + +import androidx.annotation.Nullable; + +import com.panda3ds.pandroid.R; +import com.panda3ds.pandroid.app.BaseActivity; +import com.panda3ds.pandroid.app.base.BasePreferenceFragment; +import com.panda3ds.pandroid.data.config.GlobalConfig; +import com.panda3ds.pandroid.view.preferences.SingleSelectionPreferences; + +public class AppearancePreferences extends BasePreferenceFragment { + @Override + public void onCreatePreferences(@Nullable Bundle savedInstanceState, @Nullable String rootKey) { + setPreferencesFromResource(R.xml.appearance_preference, rootKey); + + ((BaseActivity) requireActivity()).getSupportActionBar().setTitle(R.string.appearance); + + SingleSelectionPreferences themePreference = findPreference("theme"); + themePreference.setSelectedItem(GlobalConfig.get(GlobalConfig.KEY_APP_THEME)); + themePreference.setOnPreferenceChangeListener((preference, value) -> { + GlobalConfig.set(GlobalConfig.KEY_APP_THEME, (int) value); + return false; + }); + } +} diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/preferences/InputMapActivity.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/preferences/InputMapActivity.java new file mode 100644 index 000000000..dce56a5f5 --- /dev/null +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/preferences/InputMapActivity.java @@ -0,0 +1,76 @@ +package com.panda3ds.pandroid.app.preferences; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.widget.Toast; + +import androidx.activity.result.contract.ActivityResultContract; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.panda3ds.pandroid.R; +import com.panda3ds.pandroid.app.BaseActivity; +import com.panda3ds.pandroid.input.InputEvent; +import com.panda3ds.pandroid.input.InputHandler; + +import java.util.Objects; + +public class InputMapActivity extends BaseActivity { + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_input_map); + } + + @Override + protected void onResume() { + super.onResume(); + + InputHandler.reset(); + InputHandler.setMotionDeadZone(0.8f); + InputHandler.setEventListener(this::onInputEvent); + } + + @Override + protected void onPause() { + super.onPause(); + InputHandler.reset(); + } + + @Override + public boolean dispatchGenericMotionEvent(MotionEvent ev) { + return InputHandler.processMotionEvent(ev); + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + return InputHandler.processKeyEvent(event); + } + + private void onInputEvent(InputEvent event) { + if (Objects.equals(event.getName(), "KEYCODE_BACK")) { + onBackPressed(); + return; + } + setResult(RESULT_OK, new Intent(event.getName())); + Toast.makeText(this, event.getName(), Toast.LENGTH_SHORT).show(); + finish(); + } + + + public static final class Contract extends ActivityResultContract { + @NonNull + @Override + public Intent createIntent(@NonNull Context context, String s) { + return new Intent(context, InputMapActivity.class); + } + + @Override + public String parseResult(int i, @Nullable Intent intent) { + return i == RESULT_OK ? intent.getAction() : null; + } + } +} diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/preferences/InputMapPreferences.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/preferences/InputMapPreferences.java new file mode 100644 index 000000000..b4d148b9f --- /dev/null +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/preferences/InputMapPreferences.java @@ -0,0 +1,104 @@ +package com.panda3ds.pandroid.app.preferences; + +import android.content.Context; +import android.os.Bundle; + +import androidx.activity.result.ActivityResultCallback; +import androidx.activity.result.ActivityResultLauncher; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.preference.Preference; +import androidx.preference.SeekBarPreference; + +import com.panda3ds.pandroid.R; +import com.panda3ds.pandroid.app.BaseActivity; +import com.panda3ds.pandroid.app.base.BasePreferenceFragment; +import com.panda3ds.pandroid.input.InputMap; +import com.panda3ds.pandroid.input.KeyName; + +public class InputMapPreferences extends BasePreferenceFragment implements ActivityResultCallback { + + private ActivityResultLauncher requestKey; + private String currentKey; + + private SeekBarPreference deadZonePreference; + + @Override + public void onCreatePreferences(@Nullable Bundle savedInstanceState, @Nullable String rootKey) { + setPreferencesFromResource(R.xml.input_map_preferences, rootKey); + + ((BaseActivity) requireActivity()).getSupportActionBar().setTitle(R.string.controller_mapping); + + for (KeyName key : KeyName.values()) { + if (key == KeyName.NULL) { + continue; + } + + setItemClick(key.name(), this::onItemPressed); + } + + deadZonePreference = getPreferenceScreen().findPreference("dead_zone"); + + deadZonePreference.setOnPreferenceChangeListener((preference, value) -> { + InputMap.setDeadZone(((int)value / 100.0f)); + refreshList(); + + return false; + }); + + refreshList(); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + } + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + requestKey = registerForActivityResult(new InputMapActivity.Contract(), this); + } + + @Override + public void onDetach() { + super.onDetach(); + + if (requestKey != null) { + requestKey.unregister(); + requestKey = null; + } + } + + private void onItemPressed(Preference pref) { + currentKey = pref.getKey(); + requestKey.launch(null); + } + + @Override + public void onResume() { + super.onResume(); + refreshList(); + } + + private void refreshList() { + deadZonePreference.setValue((int)(InputMap.getDeadZone() * 100)); + deadZonePreference.setSummary(deadZonePreference.getValue() + "%"); + + for (KeyName key : KeyName.values()) { + if (key == KeyName.NULL) { + continue; + } + + findPreference(key.name()).setSummary(InputMap.relative(key)); + } + } + + @Override + public void onActivityResult(String result) { + if (result != null) { + InputMap.set(KeyName.valueOf(currentKey), result); + refreshList(); + } + } +} diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/data/GsonConfigParser.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/data/GsonConfigParser.java new file mode 100644 index 000000000..99c1419b4 --- /dev/null +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/data/GsonConfigParser.java @@ -0,0 +1,38 @@ +package com.panda3ds.pandroid.data; + +import com.google.gson.Gson; +import com.panda3ds.pandroid.lang.Task; +import com.panda3ds.pandroid.utils.FileUtils; + +public class GsonConfigParser { + private final Gson gson = new Gson(); + private final String name; + + public GsonConfigParser(String name) { + this.name = name; + } + + private String getPath() { + return FileUtils.getConfigPath()+ "/" + name + ".json"; + } + + public void save(Object data) { + synchronized (this) { + new Task(() -> { + String json = gson.toJson(data, data.getClass()); + FileUtils.writeTextFile(FileUtils.getConfigPath(), name + ".json", json); + }).runSync(); + } + } + + public T load(Class myClass) { + String[] content = new String[] {"{}"}; + new Task(()->{ + if (FileUtils.exists(getPath())) { + content[0] = FileUtils.readTextFile(getPath()); + } + }).runSync(); + + return gson.fromJson(content[0], myClass); + } +} diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/data/SMDH.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/data/SMDH.java new file mode 100644 index 000000000..e1230f24f --- /dev/null +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/data/SMDH.java @@ -0,0 +1,182 @@ +package com.panda3ds.pandroid.data; + +import android.graphics.Bitmap; +import android.graphics.Color; + +import com.panda3ds.pandroid.data.game.GameRegion; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; + +public class SMDH { + public static final int LANGUAGE_JAPANESE = 0; + public static final int LANGUAGE_ENGLISH = 1; + public static final int LANGUAGE_CHINESE = 6; + public static final int LANGUAGE_KOREAN = 7; + + public static final int REGION_JAPAN_MASK = 0x1; + public static final int REGION_NORTH_AMERICAN_MASK = 0x2; + public static final int REGION_EUROPE_MASK = 0x4; + public static final int REGION_AUSTRALIA_MASK = 0x8; + public static final int REGION_CHINA_MASK = 0x10; + public static final int REGION_KOREAN_MASK = 0x20; + public static final int REGION_TAIWAN_MASK = 0x40; + + private static final int ICON_SIZE = 48; + private static final int META_OFFSET = 0x8; + private static final int META_REGION_OFFSET = 0x2018; + private static final int IMAGE_OFFSET = 0x24C0; + + private int metaLanguage = LANGUAGE_ENGLISH; + private final ByteBuffer smdh; + private final String[] title = new String[12]; + private final String[] publisher = new String[12]; + private final int[] icon; + + private final GameRegion region; + + public SMDH(byte[] source) { + smdh = ByteBuffer.allocate(source.length); + smdh.position(0); + smdh.put(source); + smdh.position(0); + + region = parseRegion(); + icon = parseIcon(); + parseMeta(); + } + + private GameRegion parseRegion() { + GameRegion region; + smdh.position(META_REGION_OFFSET); + + int regionMasks = smdh.get() & 0xFF; + + final boolean japan = (regionMasks & REGION_JAPAN_MASK) != 0; + final boolean northAmerica = (regionMasks & REGION_NORTH_AMERICAN_MASK) != 0; + final boolean europe = (regionMasks & REGION_EUROPE_MASK) != 0; + final boolean australia = (regionMasks & REGION_AUSTRALIA_MASK) != 0; + final boolean china = (regionMasks & REGION_CHINA_MASK) != 0; + final boolean korea = (regionMasks & REGION_KOREAN_MASK) != 0; + final boolean taiwan = (regionMasks & REGION_TAIWAN_MASK) != 0; + + // Depending on the regions allowed in the region mask, pick one of the regions to use + // We prioritize English-speaking regions both here and in the emulator core, since users are most likely to speak English at least + if (northAmerica) { + region = GameRegion.NorthAmerican; + } else if (europe) { + region = GameRegion.Europe; + } else if (australia) { + region = GameRegion.Australia; + } else if (japan) { + region = GameRegion.Japan; + metaLanguage = LANGUAGE_JAPANESE; + } else if (korea) { + region = GameRegion.Korean; + metaLanguage = LANGUAGE_KOREAN; + } else if (china) { + region = GameRegion.China; + metaLanguage = LANGUAGE_CHINESE; + } else if (taiwan) { + region = GameRegion.Taiwan; + metaLanguage = LANGUAGE_CHINESE; + } else { + region = GameRegion.None; + } + + return region; + } + + private void parseMeta() { + byte[] data; + for (int i = 0; i < 12; i++) { + smdh.position(META_OFFSET + (512 * i) + 0x80); + data = new byte[0x100]; + smdh.get(data); + title[i] = convertString(data).replaceAll("\n", " "); + + smdh.position(META_OFFSET + (512 * i) + 0x180); + data = new byte[0x80]; + smdh.get(data); + publisher[i] = convertString(data); + } + } + + // The icons are stored in RGB562 but android need RGB888 + private int[] parseIcon() { + int[] icon = new int[ICON_SIZE * ICON_SIZE]; + smdh.position(0); + + for (int x = 0; x < ICON_SIZE; x++) { + for (int y = 0; y < ICON_SIZE; y++) { + int indexY = y & ~7; + int indexX = x & ~7; + + int interleave = mortonInterleave(x, y); + int offset = (interleave + (indexX * 8)) * 2; + + offset = offset + indexY * ICON_SIZE * 2; + + smdh.position(offset + IMAGE_OFFSET); + + int lowByte = smdh.get() & 0xFF; + int highByte = smdh.get() & 0xFF; + int texel = (highByte << 8) | lowByte; + + // Convert texel from RGB565 to RGB888 + int r = (texel >> 11) & 0x1F; + int g = (texel >> 5) & 0x3F; + int b = texel & 0x1F; + + r = (r << 3) | (r >> 2); + g = (g << 2) | (g >> 4); + b = (b << 3) | (b >> 2); + + icon[x + ICON_SIZE * y] = Color.rgb(r, g, b); + } + } + + return icon; + } + + + public GameRegion getRegion() { + return region; + } + + public Bitmap getBitmapIcon() { + Bitmap bitmap = Bitmap.createBitmap(ICON_SIZE, ICON_SIZE, Bitmap.Config.RGB_565); + bitmap.setPixels(icon, 0, ICON_SIZE, 0, 0, ICON_SIZE, ICON_SIZE); + return bitmap; + } + + public int[] getIcon() { + return icon; + } + + public String getTitle() { + return title[metaLanguage]; + } + + public String getPublisher() { + return publisher[metaLanguage]; + } + + // Strings in SMDH files are stored as UTF-16LE + private static String convertString(byte[] buffer) { + try { + return new String(buffer, 0, buffer.length, StandardCharsets.UTF_16LE) + .replaceAll("\0", ""); + } catch (Exception e) { + return ""; + } + } + + // Reference: https://github.com/wheremyfoodat/Panda3DS/blob/master/src/core/renderer_gl/textures.cpp#L88 + private static int mortonInterleave(int u, int v) { + int[] xlut = {0, 1, 4, 5, 16, 17, 20, 21}; + int[] ylut = {0, 2, 8, 10, 32, 34, 40, 42}; + + return xlut[u % 8] + ylut[v % 8]; + } +} diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/data/config/GlobalConfig.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/data/config/GlobalConfig.java index d25e34c74..d6dbe3b80 100644 --- a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/data/config/GlobalConfig.java +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/data/config/GlobalConfig.java @@ -1,51 +1,59 @@ package com.panda3ds.pandroid.data.config; -import android.content.Context; -import android.content.SharedPreferences; - -import com.panda3ds.pandroid.app.PandroidApplication; +import com.google.gson.internal.LinkedTreeMap; +import com.panda3ds.pandroid.data.GsonConfigParser; import com.panda3ds.pandroid.utils.Constants; import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; public class GlobalConfig { - private static SharedPreferences data; + + private static final GsonConfigParser parser = new GsonConfigParser(Constants.PREF_GLOBAL_CONFIG); + + public static final int THEME_ANDROID = 0; + public static final int THEME_LIGHT = 1; + public static final int THEME_DARK = 2; + public static final int THEME_BLACK = 3; + + public static DataModel data; + + public static final Key KEY_APP_THEME = new Key<>("app.theme", THEME_ANDROID); + public static final Key KEY_SCREEN_GAMEPAD_VISIBLE = new Key<>("app.screen_gamepad.visible", true); public static void initialize() { - data = PandroidApplication.getAppContext() - .getSharedPreferences(Constants.PREF_GLOBAL_CONFIG, Context.MODE_PRIVATE); + data = parser.load(DataModel.class); } public static T get(Key key) { Serializable value; + + if (!data.configs.containsKey(key.name)) { + return key.defaultValue; + } + if (key.defaultValue instanceof String) { - value = data.getString(key.name, (String) key.defaultValue); + value = (String) data.configs.get(key.name); } else if (key.defaultValue instanceof Integer) { - value = data.getInt(key.name, (int) key.defaultValue); + value = ((Number) data.get(key.name)).intValue(); } else if (key.defaultValue instanceof Boolean) { - value = data.getBoolean(key.name, (boolean) key.defaultValue); + value = (boolean) data.get(key.name); } else if (key.defaultValue instanceof Long) { - value = data.getLong(key.name, (long) key.defaultValue); + value = ((Number) data.get(key.name)).longValue(); } else { - value = data.getFloat(key.name, (float) key.defaultValue); + value = ((Number) data.get(key.name)).floatValue(); } return (T) value; } public static synchronized void set(Key key, T value) { - if (value instanceof String) { - data.edit().putString(key.name, (String) value).apply(); - } else if (value instanceof Integer) { - data.edit().putInt(key.name, (int) value).apply(); - } else if (value instanceof Boolean) { - data.edit().putBoolean(key.name, (boolean) value).apply(); - } else if (value instanceof Long) { - data.edit().putLong(key.name, (long) value).apply(); - } else if (value instanceof Float) { - data.edit().putFloat(key.name, (float) value).apply(); - } else { - throw new IllegalArgumentException("Invalid global config value instance"); - } + data.configs.put(key.name, value); + writeChanges(); + } + + private static void writeChanges() { + parser.save(data); } private static class Key { @@ -57,4 +65,12 @@ private Key(String name, T defaultValue) { this.defaultValue = defaultValue; } } + + private static class DataModel { + private final Map configs = new LinkedTreeMap<>(); + + public Object get(String key) { + return configs.get(key); + } + } } diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/data/game/GameMetadata.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/data/game/GameMetadata.java new file mode 100644 index 000000000..512a37259 --- /dev/null +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/data/game/GameMetadata.java @@ -0,0 +1,83 @@ +package com.panda3ds.pandroid.data.game; + +import android.graphics.Bitmap; +import android.util.Log; + +import androidx.annotation.Nullable; + +import com.panda3ds.pandroid.data.SMDH; +import com.panda3ds.pandroid.utils.Constants; +import com.panda3ds.pandroid.utils.GameUtils; + +import java.util.Objects; +import java.util.UUID; + +public class GameMetadata { + private final String id; + private final String romPath; + private final String title; + private final String publisher; + private final GameRegion[] regions; + private transient Bitmap icon; + + private GameMetadata(String id, String romPath, String title, String publisher, Bitmap icon, GameRegion[] regions) { + this.id = id; + this.title = title; + this.publisher = publisher; + this.romPath = romPath; + this.regions = regions; + if (icon != null) { + GameUtils.setGameIcon(id, icon); + } + } + + public GameMetadata(String romPath,String title, String publisher, GameRegion[] regions) { + this(UUID.randomUUID().toString(), romPath, title, publisher, null, regions); + } + + public GameMetadata(String romPath,String title, String publisher) { + this(romPath,title, publisher, new GameRegion[]{GameRegion.None}); + } + + public String getRomPath() { + return romPath; + } + + public String getId() { + return id; + } + + public String getTitle() { + return title; + } + + public String getPublisher() { + return publisher; + } + + public Bitmap getIcon() { + if (icon == null) { + icon = GameUtils.loadGameIcon(id); + } + return icon; + } + + public GameRegion[] getRegions() { + return regions; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (obj instanceof GameMetadata) { + return Objects.equals(((GameMetadata) obj).id, id); + } + return false; + } + + public static GameMetadata applySMDH(GameMetadata meta, SMDH smdh) { + Bitmap icon = smdh.getBitmapIcon(); + GameMetadata newMeta = new GameMetadata(meta.getId(), meta.getRomPath(), smdh.getTitle(), smdh.getPublisher(), icon, new GameRegion[]{smdh.getRegion()}); + icon.recycle(); + return newMeta; + } +} diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/data/game/GameRegion.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/data/game/GameRegion.java new file mode 100644 index 000000000..9b99b095e --- /dev/null +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/data/game/GameRegion.java @@ -0,0 +1,12 @@ +package com.panda3ds.pandroid.data.game; + +public enum GameRegion { + NorthAmerican, + Japan, + Europe, + Australia, + China, + Korean, + Taiwan, + None +} diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/input/InputEvent.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/input/InputEvent.java new file mode 100644 index 000000000..7869e00a5 --- /dev/null +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/input/InputEvent.java @@ -0,0 +1,23 @@ +package com.panda3ds.pandroid.input; + +public class InputEvent { + private final String name; + private final float value; + + public InputEvent(String name, float value) { + this.name = name; + this.value = Math.max(0.0f, Math.min(1.0f, value)); + } + + public boolean isDown() { + return value > 0.0f; + } + + public String getName() { + return name; + } + + public float getValue() { + return value; + } +} diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/input/InputHandler.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/input/InputHandler.java new file mode 100644 index 000000000..390708b99 --- /dev/null +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/input/InputHandler.java @@ -0,0 +1,121 @@ +package com.panda3ds.pandroid.input; + +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.MotionEvent; + +import com.panda3ds.pandroid.lang.Function; + +import java.util.HashMap; + +public class InputHandler { + private static Function eventListener; + private static float motionDeadZone = 0.0f; + + private static final int[] gamepadSources = { + InputDevice.SOURCE_GAMEPAD, + InputDevice.SOURCE_JOYSTICK + }; + + private static final int[] validSources = { + InputDevice.SOURCE_GAMEPAD, + InputDevice.SOURCE_JOYSTICK, + InputDevice.SOURCE_DPAD, + InputDevice.SOURCE_KEYBOARD + }; + + private static final HashMap motionDownEvents = new HashMap<>(); + + private static boolean containsSource(int[] sources, int sourceMask) { + for (int source : sources) { + if ((source & sourceMask) == source) { + return true; + } + } + + return false; + } + + private static boolean isGamepadSource(int sourceMask) { + return containsSource(gamepadSources, sourceMask); + } + + private static boolean isSourceValid(int sourceMasked) { + return containsSource(validSources, sourceMasked); + } + + public static void setEventListener(Function eventListener) { + InputHandler.eventListener = eventListener; + } + + private static void handleEvent(InputEvent event) { + if (eventListener != null) { + eventListener.run(event); + } + } + + public static void setMotionDeadZone(float motionDeadZone) { + InputHandler.motionDeadZone = motionDeadZone; + } + + public static boolean processMotionEvent(MotionEvent event) { + if (!isSourceValid(event.getSource())) { + return false; + } + + if (isGamepadSource(event.getSource())) { + for (InputDevice.MotionRange range : event.getDevice().getMotionRanges()) { + float axisValue = event.getAxisValue(range.getAxis()); + float value = Math.abs(axisValue); + String name = (MotionEvent.axisToString(range.getAxis()) + (axisValue >= 0 ? "+" : "-")).toUpperCase(); + String reverseName = (MotionEvent.axisToString(range.getAxis()) + (axisValue >= 0 ? "-" : "+")).toUpperCase(); + + if (motionDownEvents.containsKey(reverseName)) { + motionDownEvents.remove(reverseName); + handleEvent(new InputEvent(reverseName.toUpperCase(), 0.0f)); + } + + if (value > motionDeadZone) { + motionDownEvents.put(name, value); + handleEvent(new InputEvent(name.toUpperCase(), (value - motionDeadZone) / (1.0f - motionDeadZone))); + } else if (motionDownEvents.containsKey(name)) { + motionDownEvents.remove(name); + handleEvent(new InputEvent(name.toUpperCase(), 0.0f)); + } + + } + } + + return true; + } + + public static boolean processKeyEvent(KeyEvent event) { + if (!isSourceValid(event.getSource())) { + return false; + } + + if (isGamepadSource(event.getSource())) { + // Dpad return motion event + key event, this remove the key event + switch (event.getKeyCode()) { + case KeyEvent.KEYCODE_DPAD_UP: + case KeyEvent.KEYCODE_DPAD_UP_LEFT: + case KeyEvent.KEYCODE_DPAD_UP_RIGHT: + case KeyEvent.KEYCODE_DPAD_DOWN: + case KeyEvent.KEYCODE_DPAD_DOWN_LEFT: + case KeyEvent.KEYCODE_DPAD_DOWN_RIGHT: + case KeyEvent.KEYCODE_DPAD_RIGHT: + case KeyEvent.KEYCODE_DPAD_LEFT: + return true; + } + } + + handleEvent(new InputEvent(KeyEvent.keyCodeToString(event.getKeyCode()), event.getAction() == KeyEvent.ACTION_UP ? 0.0f : 1.0f)); + return true; + } + + public static void reset() { + eventListener = null; + motionDeadZone = 0.0f; + motionDownEvents.clear(); + } +} diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/input/InputMap.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/input/InputMap.java new file mode 100644 index 000000000..ab2cdd048 --- /dev/null +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/input/InputMap.java @@ -0,0 +1,48 @@ +package com.panda3ds.pandroid.input; + +import com.panda3ds.pandroid.data.GsonConfigParser; +import com.panda3ds.pandroid.utils.Constants; + +public class InputMap { + public static final GsonConfigParser parser = new GsonConfigParser(Constants.PREF_INPUT_MAP); + private static DataModel data; + + public static void initialize() { + data = parser.load(DataModel.class); + } + + public static float getDeadZone() { + return data.deadZone; + } + + public static void set(KeyName key, String name) { + data.keys[key.ordinal()] = name; + writeConfig(); + } + + public static String relative(KeyName key) { + return data.keys[key.ordinal()] == null ? "-" : data.keys[key.ordinal()]; + } + + public static KeyName relative(String name) { + for (KeyName key : KeyName.values()) { + if (relative(key).equalsIgnoreCase(name)) + return key; + } + return KeyName.NULL; + } + + public static void setDeadZone(float value) { + data.deadZone = Math.max(0.0f, Math.min(1.0f, value)); + writeConfig(); + } + + private static void writeConfig() { + parser.save(data); + } + + private static class DataModel { + public float deadZone = 0.2f; + public final String[] keys = new String[32]; + } +} diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/input/KeyName.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/input/KeyName.java new file mode 100644 index 000000000..1253529fe --- /dev/null +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/input/KeyName.java @@ -0,0 +1,38 @@ +package com.panda3ds.pandroid.input; + +import com.panda3ds.pandroid.utils.Constants; + +public enum KeyName { + A(Constants.INPUT_KEY_A), + B(Constants.INPUT_KEY_B), + X(Constants.INPUT_KEY_X), + Y(Constants.INPUT_KEY_Y), + UP(Constants.INPUT_KEY_UP), + DOWN(Constants.INPUT_KEY_DOWN), + LEFT(Constants.INPUT_KEY_LEFT), + RIGHT(Constants.INPUT_KEY_RIGHT), + AXIS_LEFT, + AXIS_RIGHT, + AXIS_UP, + AXIS_DOWN, + START(Constants.INPUT_KEY_START), + SELECT(Constants.INPUT_KEY_SELECT), + L(Constants.INPUT_KEY_L), + R(Constants.INPUT_KEY_R), + NULL; + + private final int keyId; + + KeyName() { + this(-1); + } + + KeyName(int keyId) { + this.keyId = keyId; + } + + public int getKeyId() { + return keyId; + } + +} \ No newline at end of file diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/lang/Function.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/lang/Function.java new file mode 100644 index 000000000..25a15875d --- /dev/null +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/lang/Function.java @@ -0,0 +1,5 @@ +package com.panda3ds.pandroid.lang; + +public interface Function { + void run(T arg); +} diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/lang/Task.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/lang/Task.java new file mode 100644 index 000000000..7745883d5 --- /dev/null +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/lang/Task.java @@ -0,0 +1,20 @@ +package com.panda3ds.pandroid.lang; + +public class Task extends Thread { + public Task(Runnable runnable) { + super(runnable); + } + + public void runSync() { + start(); + waitFinish(); + } + + public void waitFinish() { + try { + join(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/utils/Constants.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/utils/Constants.java index 1aac0a4dd..7adf2e479 100644 --- a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/utils/Constants.java +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/utils/Constants.java @@ -19,7 +19,10 @@ public class Constants { public static final int N3DS_HALF_HEIGHT = N3DS_FULL_HEIGHT / 2; public static final String ACTIVITY_PARAMETER_PATH = "path"; + public static final String ACTIVITY_PARAMETER_FRAGMENT = "fragment"; public static final String LOG_TAG = "pandroid"; public static final String PREF_GLOBAL_CONFIG = "app.GlobalConfig"; + public static final String PREF_GAME_UTILS = "app.GameUtils"; + public static final String PREF_INPUT_MAP = "app.InputMap"; } diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/utils/FileUtils.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/utils/FileUtils.java new file mode 100644 index 000000000..a065cd0ca --- /dev/null +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/utils/FileUtils.java @@ -0,0 +1,180 @@ +package com.panda3ds.pandroid.utils; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.system.Os; +import android.util.Log; + +import androidx.documentfile.provider.DocumentFile; + +import com.panda3ds.pandroid.app.PandroidApplication; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +public class FileUtils { + public static final String MODE_READ = "r"; + public static final int CANONICAL_SEARCH_DEEP = 8; + + private static DocumentFile parseFile(String path) { + if (path.startsWith("/")) { + return DocumentFile.fromFile(new File(path)); + } + Uri uri = Uri.parse(path); + return DocumentFile.fromSingleUri(getContext(), uri); + } + + private static Context getContext() { + return PandroidApplication.getAppContext(); + } + + public static String getName(String path) { + return parseFile(path).getName(); + } + + public static String getPrivatePath() { + File file = getContext().getFilesDir(); + if (!file.exists()) { + file.mkdirs(); + } + + return file.getAbsolutePath(); + } + + public static String getConfigPath() { + File file = new File(getPrivatePath(), "config"); + if (!file.exists()) { + file.mkdirs(); + } + + return file.getAbsolutePath(); + } + + public static boolean exists(String path) { + return parseFile(path).exists(); + } + + public static boolean createDir(String path, String name) { + DocumentFile folder = parseFile(path); + if (folder.findFile(name) != null) { + return true; + } + + return folder.createDirectory(name) != null; + } + + public static boolean createFile(String path, String name) { + DocumentFile folder = parseFile(path); + if (folder.findFile(name) != null) { + folder.findFile(name).delete(); + } + + return folder.createFile("", name) != null; + } + + public static boolean writeTextFile(String path, String name, String content) { + try { + createFile(path, name); + OutputStream stream = getOutputStream(path + "/" + name); + stream.write(content.getBytes(StandardCharsets.UTF_8)); + stream.flush(); + stream.close(); + } catch (Exception e) { + Log.e(Constants.LOG_TAG, "Error on write text file: ", e); + return false; + } + + return true; + } + + public static String readTextFile(String path) { + if (!exists(path)) { + return null; + } + + try { + InputStream stream = getInputStream(path); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + int len; + byte[] buffer = new byte[1024 * 8]; + while ((len = stream.read(buffer)) != -1) { + output.write(buffer, 0, len); + } + + stream.close(); + output.flush(); + output.close(); + + byte[] data = output.toByteArray(); + return new String(data, 0, data.length); + } catch (Exception e) { + return null; + } + } + + public static InputStream getInputStream(String path) throws FileNotFoundException { + return getContext().getContentResolver().openInputStream(parseFile(path).getUri()); + } + + public static OutputStream getOutputStream(String path) throws FileNotFoundException { + return getContext().getContentResolver().openOutputStream(parseFile(path).getUri()); + } + + public static void makeUriPermanent(String uri, String mode) { + int flags = Intent.FLAG_GRANT_READ_URI_PERMISSION; + if (mode.toLowerCase().contains("w")) { + flags &= Intent.FLAG_GRANT_WRITE_URI_PERMISSION; + } + + getContext().getContentResolver().takePersistableUriPermission(Uri.parse(uri), flags); + } + + /** + * When call ContentProvider.openFileDescriptor() android opens a file descriptor + * on app process in /proc/self/fd/[file descriptor id] this is a link to real file path + * can use File.getCanonicalPath() for get a link origin, but in some android version + * need use Os.readlink(path) to get a real path. + */ + public static String obtainRealPath(String uri) { + try { + ParcelFileDescriptor parcelDescriptor = getContext().getContentResolver().openFileDescriptor(Uri.parse(uri), "r"); + int fd = parcelDescriptor.getFd(); + File file = new File("/proc/self/fd/" + fd).getAbsoluteFile(); + + for (int i = 0; i < CANONICAL_SEARCH_DEEP; i++) { + try { + String canonical = file.getCanonicalPath(); + if (!Objects.equals(canonical, file.getAbsolutePath())) { + file = new File(canonical).getAbsoluteFile(); + } + } catch (Exception x) { + break; + } + } + + if (!file.getAbsolutePath().startsWith("/proc/self/")) { + parcelDescriptor.close(); + return file.getAbsolutePath(); + } + + String path = Os.readlink(file.getAbsolutePath()); + parcelDescriptor.close(); + + if (new File(path).exists()) { + return path; + } + + return null; + } catch (Exception e) { + return null; + } + } +} diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/utils/GameUtils.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/utils/GameUtils.java new file mode 100644 index 000000000..b763f7b2f --- /dev/null +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/utils/GameUtils.java @@ -0,0 +1,101 @@ +package com.panda3ds.pandroid.utils; + +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.Uri; +import android.util.Log; + +import com.panda3ds.pandroid.app.GameActivity; +import com.panda3ds.pandroid.data.GsonConfigParser; +import com.panda3ds.pandroid.data.game.GameMetadata; + +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public class GameUtils { + private static final Bitmap DEFAULT_ICON = Bitmap.createBitmap(48, 48, Bitmap.Config.ARGB_8888); + public static GsonConfigParser parser = new GsonConfigParser(Constants.PREF_GAME_UTILS); + + private static DataModel data; + + private static GameMetadata currentGame; + + public static void initialize() { + data = parser.load(DataModel.class); + } + + public static GameMetadata findByRomPath(String romPath) { + for (GameMetadata game : data.games) { + if (Objects.equals(romPath, game.getRomPath())) { + return game; + } + } + return null; + } + + public static void launch(Context context, GameMetadata game) { + currentGame = game; + String path = FileUtils.obtainRealPath(game.getRomPath()); + context.startActivity(new Intent(context, GameActivity.class).putExtra(Constants.ACTIVITY_PARAMETER_PATH, path)); + } + + public static GameMetadata getCurrentGame() { + return currentGame; + } + + public static void removeGame(GameMetadata game) { + data.games.remove(game); + writeChanges(); + } + + public static void addGame(GameMetadata game) { + data.games.add(0, game); + writeChanges(); + } + + public static ArrayList getGames() { + return new ArrayList<>(data.games); + } + + private static void writeChanges() { + parser.save(data); + } + + public static void setGameIcon(String id, Bitmap icon) { + try { + String appPath = FileUtils.getPrivatePath(); + FileUtils.createDir(appPath, "cache_icons"); + FileUtils.createFile(appPath + "/cache_icons/", id + ".png"); + + OutputStream output = FileUtils.getOutputStream(appPath + "/cache_icons/" + id + ".png"); + icon.compress(Bitmap.CompressFormat.PNG, 100, output); + output.close(); + } catch (Exception e) { + Log.e(Constants.LOG_TAG, "Error on save game icon: ", e); + } + } + + public static Bitmap loadGameIcon(String id) { + try { + String path = FileUtils.getPrivatePath() + "/cache_icons/" + id + ".png"; + if (FileUtils.exists(path)) { + InputStream stream = FileUtils.getInputStream(path); + Bitmap image = BitmapFactory.decodeStream(stream); + stream.close(); + return image; + } + } catch (Exception e) { + Log.e(Constants.LOG_TAG, "Error on load game icon: ", e); + } + return DEFAULT_ICON; + } + + private static class DataModel { + public final List games = new ArrayList<>(); + } +} diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/utils/PathUtils.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/utils/PathUtils.java deleted file mode 100644 index 9bfaa0e4a..000000000 --- a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/utils/PathUtils.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.panda3ds.pandroid.utils; - -import android.content.ContentUris; -import android.content.Context; -import android.database.Cursor; -import android.net.Uri; -import android.os.Build; -import android.os.Environment; -import android.provider.DocumentsContract; -import android.provider.MediaStore; - -public class PathUtils { - public static String getPath(final Context context, final Uri uri) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT && DocumentsContract.isDocumentUri(context, uri)) { - if (isExternalStorageDocument(uri)) { - final String docId = DocumentsContract.getDocumentId(uri); - final String[] split = docId.split(":"); - final String type = split[0]; - String storageDefinition; - - if ("primary".equalsIgnoreCase(type)) { - return Environment.getExternalStorageDirectory() + "/" + split[1]; - - } else { - if (Environment.isExternalStorageRemovable()) { - storageDefinition = "EXTERNAL_STORAGE"; - - } else { - storageDefinition = "SECONDARY_STORAGE"; - } - - return System.getenv(storageDefinition) + "/" + split[1]; - } - - } else if (isDownloadsDocument(uri)) { - final String id = DocumentsContract.getDocumentId(uri); - final Uri contentUri = ContentUris.withAppendedId(Uri.parse("content://downloads/public_downloads"), Long.valueOf(id)); - - return getDataColumn(context, contentUri, null, null); - } else if (isMediaDocument(uri)) { - final String docId = DocumentsContract.getDocumentId(uri); - final String[] split = docId.split(":"); - final String type = split[0]; - - Uri contentUri = null; - if ("image".equals(type)) { - contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; - } else if ("video".equals(type)) { - contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI; - } else if ("audio".equals(type)) { - contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; - } - - final String selection = "_id=?"; - final String[] selectionArgs = new String[] {split[1]}; - return getDataColumn(context, contentUri, selection, selectionArgs); - } - - } else if ("content".equalsIgnoreCase(uri.getScheme())) { - if (isGooglePhotosUri(uri)) return uri.getLastPathSegment(); - return getDataColumn(context, uri, null, null); - } else if ("file".equalsIgnoreCase(uri.getScheme())) { - return uri.getPath(); - } - - return null; - } - - public static String getDataColumn(Context context, Uri uri, String selection, String[] selectionArgs) { - Cursor cursor = null; - final String column = "_data"; - final String[] projection = {column}; - try { - cursor = context.getContentResolver().query(uri, projection, selection, selectionArgs, null); - if (cursor != null && cursor.moveToFirst()) { - final int column_index = cursor.getColumnIndexOrThrow(column); - return cursor.getString(column_index); - } - } finally { - if (cursor != null) cursor.close(); - } - return null; - } - - public static boolean isExternalStorageDocument(Uri uri) { return "com.android.externalstorage.documents".equals(uri.getAuthority()); } - public static boolean isDownloadsDocument(Uri uri) { return "com.android.providers.downloads.documents".equals(uri.getAuthority()); } - public static boolean isMediaDocument(Uri uri) { return "com.android.providers.media.documents".equals(uri.getAuthority()); } - public static boolean isGooglePhotosUri(Uri uri) { return "com.google.android.apps.photos.content".equals(uri.getAuthority()); } -} \ No newline at end of file diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/utils/SearchAgent.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/utils/SearchAgent.java new file mode 100644 index 000000000..749e1bd62 --- /dev/null +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/utils/SearchAgent.java @@ -0,0 +1,90 @@ +package com.panda3ds.pandroid.utils; + +import java.text.Normalizer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; + +public class SearchAgent { + // Store all results in a hashmap + // Matches IDs -> Result string + private final HashMap searchBuffer = new HashMap<>(); + + // Add search item to list + public void addToBuffer(String id, String... words) { + StringBuilder string = new StringBuilder(); + for (String word : words) { + string.append(normalize(word)).append(" "); + } + + searchBuffer.put(id, string.toString()); + } + + // Convert string to lowercase alphanumeric string, converting all characters to ASCII and turning double spaces into single ones + // For example, é will be converted to e + private String normalize(String string) { + string = Normalizer.normalize(string, Normalizer.Form.NFD).replaceAll("[^\\p{ASCII}]", ""); + + return string.toLowerCase() + .replaceAll("(?!([a-z0-9 ])).*", "") + .replaceAll("\\s\\s", " "); + } + + // Execute search and return array with item id. + public List search(String query) { + String[] words = normalize(query).split("\\s"); + + if (words.length == 0) { + return Collections.emptyList(); + } + + // Map for add all search result: id -> probability + HashMap results = new HashMap<>(); + for (String key : searchBuffer.keySet()) { + int probability = 0; + String value = searchBuffer.get(key); + + for (String word : words) { + if (value.contains(word)) + probability++; + } + + if (probability > 0) { + results.put(key, probability); + } + } + + + // Filter by probability average, ie by how closely they match to our query + // Ex: A = 10% B = 30% C = 70% (formula is (10+30+70)/3=36) + // Afterwards remove all results with probability < 36 + int average = 0; + for (String key : results.keySet()) { + average += results.get(key); + } + average = average / Math.max(1, results.size()); + + int i = 0; + ArrayList resultKeys = new ArrayList<>(Arrays.asList(results.keySet().toArray(new String[0]))); + while ((i < resultKeys.size() && resultKeys.size() > 1)) { + if (results.get(resultKeys.get(i)) < average) { + String key = resultKeys.get(i); + resultKeys.remove(i); + results.remove(key); + i = 0; + continue; + } + + i++; + } + + return Arrays.asList(results.keySet().toArray(new String[0])); + } + + // Clear search buffer + public void clearBuffer() { + searchBuffer.clear(); + } +} diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/view/SimpleTextWatcher.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/view/SimpleTextWatcher.java new file mode 100644 index 000000000..baacd743b --- /dev/null +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/view/SimpleTextWatcher.java @@ -0,0 +1,19 @@ +package com.panda3ds.pandroid.view; + +import android.text.Editable; +import android.text.TextWatcher; + +public interface SimpleTextWatcher extends TextWatcher { + void onChange(String value); + + @Override + default void onTextChanged(CharSequence s, int start, int before, int count) {} + + @Override + default void beforeTextChanged(CharSequence s, int start, int count, int after) {} + + @Override + default void afterTextChanged(Editable s) { + onChange(s.toString()); + } +} diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/view/gamesgrid/GameAdapter.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/view/gamesgrid/GameAdapter.java new file mode 100644 index 000000000..1a3febd4a --- /dev/null +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/view/gamesgrid/GameAdapter.java @@ -0,0 +1,42 @@ +package com.panda3ds.pandroid.view.gamesgrid; + +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.panda3ds.pandroid.R; +import com.panda3ds.pandroid.data.game.GameMetadata; + +import java.util.ArrayList; +import java.util.List; + +class GameAdapter extends RecyclerView.Adapter { + private final ArrayList games = new ArrayList<>(); + + @NonNull + @Override + public ItemHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new ItemHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.holder_game, parent, false)); + } + + @Override + public void onBindViewHolder(@NonNull ItemHolder holder, int position) { + holder.apply(games.get(position)); + } + + public void replace(List games) { + int oldCount = getItemCount(); + this.games.clear(); + notifyItemRangeRemoved(0, oldCount); + this.games.addAll(games); + notifyItemRangeInserted(0, getItemCount()); + } + + @Override + public int getItemCount() { + return games.size(); + } + +} diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/view/gamesgrid/GameIconView.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/view/gamesgrid/GameIconView.java new file mode 100644 index 000000000..e73d8d083 --- /dev/null +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/view/gamesgrid/GameIconView.java @@ -0,0 +1,41 @@ +package com.panda3ds.pandroid.view.gamesgrid; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.AppCompatImageView; + +public class GameIconView extends AppCompatImageView { + public GameIconView(@NonNull Context context) { + super(context); + } + + public GameIconView(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public GameIconView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + int size = getMeasuredWidth(); + setMeasuredDimension(size, size); + } + + @Override + public void setImageBitmap(Bitmap bm) { + super.setImageBitmap(bm); + Drawable bitmapDrawable = getDrawable(); + if (bitmapDrawable instanceof BitmapDrawable) { + bitmapDrawable.setFilterBitmap(false); + } + } +} diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/view/gamesgrid/GamesGridView.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/view/gamesgrid/GamesGridView.java new file mode 100644 index 000000000..d218d4677 --- /dev/null +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/view/gamesgrid/GamesGridView.java @@ -0,0 +1,58 @@ +package com.panda3ds.pandroid.view.gamesgrid; + +import android.content.Context; +import android.util.AttributeSet; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.panda3ds.pandroid.data.game.GameMetadata; + +import java.util.List; + +public class GamesGridView extends RecyclerView { + private int iconSize = 170; + private final GameAdapter adapter; + + public GamesGridView(@NonNull Context context) { + this(context, null); + } + + public GamesGridView(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public GamesGridView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + setLayoutManager(new AutoFitLayout()); + setAdapter(adapter = new GameAdapter()); + } + + public void setGameList(List games) { + adapter.replace(games); + } + + public void setIconSize(int iconSize) { + this.iconSize = iconSize; + requestLayout(); + measure(MeasureSpec.EXACTLY, MeasureSpec.EXACTLY); + } + + private final class AutoFitLayout extends GridLayoutManager { + public AutoFitLayout() { + super(GamesGridView.this.getContext(), 1); + } + + @Override + public void onMeasure(@NonNull Recycler recycler, @NonNull State state, int widthSpec, int heightSpec) { + super.onMeasure(recycler, state, widthSpec, heightSpec); + int width = getMeasuredWidth(); + int iconSize = (int) (GamesGridView.this.iconSize * getResources().getDisplayMetrics().density); + int iconCount = Math.max(1, width / iconSize); + if (getSpanCount() != iconCount) + setSpanCount(iconCount); + } + } +} \ No newline at end of file diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/view/gamesgrid/ItemHolder.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/view/gamesgrid/ItemHolder.java new file mode 100644 index 000000000..54f86daea --- /dev/null +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/view/gamesgrid/ItemHolder.java @@ -0,0 +1,30 @@ +package com.panda3ds.pandroid.view.gamesgrid; + +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.appcompat.widget.AppCompatTextView; +import androidx.recyclerview.widget.RecyclerView; + +import com.panda3ds.pandroid.R; +import com.panda3ds.pandroid.data.game.GameMetadata; +import com.panda3ds.pandroid.utils.GameUtils; + +class ItemHolder extends RecyclerView.ViewHolder { + public ItemHolder(@NonNull View itemView) { + super(itemView); + } + + public void apply(GameMetadata game) { + ((AppCompatTextView) itemView.findViewById(R.id.title)) + .setText(game.getTitle()); + ((GameIconView) itemView.findViewById(R.id.icon)) + .setImageBitmap(game.getIcon()); + ((AppCompatTextView) itemView.findViewById(R.id.description)) + .setText(game.getPublisher()); + + itemView.setOnClickListener((v) -> { + GameUtils.launch(v.getContext(), game); + }); + } +} \ No newline at end of file diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/view/preferences/SingleSelectionPreferences.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/view/preferences/SingleSelectionPreferences.java new file mode 100644 index 000000000..49fabd6a3 --- /dev/null +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/view/preferences/SingleSelectionPreferences.java @@ -0,0 +1,86 @@ +package com.panda3ds.pandroid.view.preferences; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.util.AttributeSet; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.preference.Preference; +import androidx.preference.PreferenceCategory; + +import com.panda3ds.pandroid.R; +import com.panda3ds.pandroid.utils.Constants; + +public class SingleSelectionPreferences extends PreferenceCategory implements Preference.OnPreferenceClickListener { + private final Drawable transparent = new ColorDrawable(Color.TRANSPARENT); + private final Drawable doneDrawable = ContextCompat.getDrawable(getContext(), R.drawable.ic_done); + + public SingleSelectionPreferences(@NonNull Context context) { + super(context); + } + + public SingleSelectionPreferences(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public SingleSelectionPreferences(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public SingleSelectionPreferences(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + { + try { + TypedArray color = getContext().obtainStyledAttributes(new int[]{ + android.R.attr.textColorSecondary + }); + doneDrawable.setTint(color.getColor(0, Color.RED)); + color.recycle(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + color.close(); + } + } catch (Exception e) { + Log.e(Constants.LOG_TAG, "Error on obtain text color secondary: ", e); + } + } + + @Override + public void onAttached() { + super.onAttached(); + + for (int i = 0; i < getPreferenceCount();i++) { + getPreference(i).setOnPreferenceClickListener(this); + } + } + + public void setSelectedItem(int index) { + onPreferenceClick(getPreference(index)); + } + + @Override + public boolean onPreferenceClick(@NonNull Preference preference) { + int index = 0; + + for (int i = 0; i < getPreferenceCount(); i++) { + Preference item = getPreference(i); + if (item == preference) { + index = i; + item.setIcon(R.drawable.ic_done); + } else { + item.setIcon(transparent); + } + } + + callChangeListener(index); + return false; + } +} diff --git a/src/pandroid/app/src/main/res/color/bottom_navigation_indicator_tint.xml b/src/pandroid/app/src/main/res/color/bottom_navigation_indicator_tint.xml new file mode 100644 index 000000000..e0fc91122 --- /dev/null +++ b/src/pandroid/app/src/main/res/color/bottom_navigation_indicator_tint.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/pandroid/app/src/main/res/color/text_secondary_dark.xml b/src/pandroid/app/src/main/res/color/text_secondary_dark.xml new file mode 100644 index 000000000..53a9cd158 --- /dev/null +++ b/src/pandroid/app/src/main/res/color/text_secondary_dark.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/pandroid/app/src/main/res/color/text_secondary_light.xml b/src/pandroid/app/src/main/res/color/text_secondary_light.xml new file mode 100644 index 000000000..351360585 --- /dev/null +++ b/src/pandroid/app/src/main/res/color/text_secondary_light.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/pandroid/app/src/main/res/drawable/ic_done.xml b/src/pandroid/app/src/main/res/drawable/ic_done.xml new file mode 100644 index 000000000..25e52514c --- /dev/null +++ b/src/pandroid/app/src/main/res/drawable/ic_done.xml @@ -0,0 +1,5 @@ + + + diff --git a/src/pandroid/app/src/main/res/drawable/ic_key_a.xml b/src/pandroid/app/src/main/res/drawable/ic_key_a.xml new file mode 100644 index 000000000..3081c4628 --- /dev/null +++ b/src/pandroid/app/src/main/res/drawable/ic_key_a.xml @@ -0,0 +1,5 @@ + + + diff --git a/src/pandroid/app/src/main/res/drawable/ic_search.xml b/src/pandroid/app/src/main/res/drawable/ic_search.xml new file mode 100644 index 000000000..a5687c639 --- /dev/null +++ b/src/pandroid/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,5 @@ + + + diff --git a/src/pandroid/app/src/main/res/drawable/ic_settings.xml b/src/pandroid/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 000000000..298a5a1ff --- /dev/null +++ b/src/pandroid/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,5 @@ + + + diff --git a/src/pandroid/app/src/main/res/drawable/ic_theme.xml b/src/pandroid/app/src/main/res/drawable/ic_theme.xml new file mode 100644 index 000000000..c3d2c7b31 --- /dev/null +++ b/src/pandroid/app/src/main/res/drawable/ic_theme.xml @@ -0,0 +1,5 @@ + + + diff --git a/src/pandroid/app/src/main/res/drawable/ic_videogame.xml b/src/pandroid/app/src/main/res/drawable/ic_videogame.xml new file mode 100644 index 000000000..8693be5f1 --- /dev/null +++ b/src/pandroid/app/src/main/res/drawable/ic_videogame.xml @@ -0,0 +1,5 @@ + + + diff --git a/src/pandroid/app/src/main/res/drawable/search_bar_background.xml b/src/pandroid/app/src/main/res/drawable/search_bar_background.xml new file mode 100644 index 000000000..44a1c5b46 --- /dev/null +++ b/src/pandroid/app/src/main/res/drawable/search_bar_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/pandroid/app/src/main/res/drawable/simple_card_background.xml b/src/pandroid/app/src/main/res/drawable/simple_card_background.xml new file mode 100644 index 000000000..88845ce4e --- /dev/null +++ b/src/pandroid/app/src/main/res/drawable/simple_card_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/pandroid/app/src/main/res/drawable/simple_card_button.xml b/src/pandroid/app/src/main/res/drawable/simple_card_button.xml deleted file mode 100644 index d58e9c4fb..000000000 --- a/src/pandroid/app/src/main/res/drawable/simple_card_button.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/src/pandroid/app/src/main/res/drawable/simple_card_button_left.xml b/src/pandroid/app/src/main/res/drawable/simple_card_button_left.xml deleted file mode 100644 index baf1f2931..000000000 --- a/src/pandroid/app/src/main/res/drawable/simple_card_button_left.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/src/pandroid/app/src/main/res/drawable/simple_card_button_right.xml b/src/pandroid/app/src/main/res/drawable/simple_card_button_right.xml deleted file mode 100644 index 2f69341c2..000000000 --- a/src/pandroid/app/src/main/res/drawable/simple_card_button_right.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/src/pandroid/app/src/main/res/layout-land/activity_main.xml b/src/pandroid/app/src/main/res/layout-land/activity_main.xml new file mode 100644 index 000000000..fa4cfbca4 --- /dev/null +++ b/src/pandroid/app/src/main/res/layout-land/activity_main.xml @@ -0,0 +1,30 @@ + + + + + + + + \ No newline at end of file diff --git a/src/pandroid/app/src/main/res/layout/activity_input_map.xml b/src/pandroid/app/src/main/res/layout/activity_input_map.xml new file mode 100644 index 000000000..cbacc64e1 --- /dev/null +++ b/src/pandroid/app/src/main/res/layout/activity_input_map.xml @@ -0,0 +1,23 @@ + + + + + + + + \ No newline at end of file diff --git a/src/pandroid/app/src/main/res/layout/activity_main.xml b/src/pandroid/app/src/main/res/layout/activity_main.xml index 89a17ce98..6de640209 100644 --- a/src/pandroid/app/src/main/res/layout/activity_main.xml +++ b/src/pandroid/app/src/main/res/layout/activity_main.xml @@ -1,5 +1,5 @@ - - + + -