diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt index 2744b201df..1a56fe9e1b 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt @@ -549,6 +549,21 @@ object NativeLibrary { */ external fun clearFilesystemProvider() + /** + * Gets the current virtual amiibo state reported by the core. + * + * @return Native enum value for the current amiibo state. + */ + external fun getVirtualAmiiboState(): Int + + /** + * Loads amiibo data into the currently running emulation session. + * + * @param data Raw amiibo file contents. + * @return Native enum value representing the load result. + */ + external fun loadAmiibo(data: ByteArray): Int + /** * Checks if all necessary keys are present for decryption */ diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt index 5f49399406..5535e45711 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt @@ -35,6 +35,8 @@ import android.widget.FrameLayout import android.widget.TextView import android.widget.Toast import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.StringRes import androidx.appcompat.widget.PopupMenu import androidx.core.content.res.ResourcesCompat import androidx.core.graphics.Insets @@ -60,6 +62,7 @@ import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.activities.EmulationActivity import org.yuzu.yuzu_emu.databinding.DialogOverlayAdjustBinding import org.yuzu.yuzu_emu.databinding.FragmentEmulationBinding +import org.yuzu.yuzu_emu.features.input.NativeInput import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting import org.yuzu.yuzu_emu.features.settings.model.IntSetting import org.yuzu.yuzu_emu.features.settings.model.Settings @@ -121,6 +124,39 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { private var perfStatsRunnable: Runnable? = null private var socRunnable: Runnable? = null + private var isAmiiboPickerOpen = false + + private val loadAmiiboLauncher = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + isAmiiboPickerOpen = false + val binding = _binding ?: return@registerForActivityResult + binding.inGameMenu.requestFocus() + + if (!isAdded || uri == null) { + return@registerForActivityResult + } + + val data = try { + requireContext().contentResolver.openInputStream(uri)?.use { it.readBytes() } + } catch (e: Exception) { + Log.error("[EmulationFragment] Failed to read amiibo: ${e.message}") + showAmiiboDialog(R.string.amiibo_unknown_error) + return@registerForActivityResult + } + + val amiiboData = data ?: run { + showAmiiboDialog(R.string.amiibo_not_valid) + return@registerForActivityResult + } + + if (amiiboData.isEmpty()) { + showAmiiboDialog(R.string.amiibo_not_valid) + return@registerForActivityResult + } + + val result = NativeLibrary.loadAmiibo(amiiboData) + handleAmiiboLoadResult(result) + } override fun onAttach(context: Context) { super.onAttach(context) @@ -623,6 +659,8 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { true } + R.id.menu_load_amiibo -> handleLoadAmiiboSelection() + R.id.menu_controls -> { val action = HomeNavigationDirections.actionGlobalSettingsActivity( null, @@ -893,6 +931,70 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { } } + private fun handleLoadAmiiboSelection(): Boolean { + val binding = _binding ?: return true + + binding.inGameMenu.requestFocus() + + if (!NativeLibrary.isRunning()) { + showAmiiboDialog(R.string.amiibo_wrong_state) + return true + } + + when (AmiiboState.fromValue(NativeLibrary.getVirtualAmiiboState())) { + AmiiboState.TagNearby -> { + NativeInput.onRemoveNfcTag() + showAmiiboDialog(R.string.amiibo_removed_message) + } + + AmiiboState.WaitingForAmiibo -> { + if (isAmiiboPickerOpen) { + return true + } + + isAmiiboPickerOpen = true + binding.drawerLayout.close() + loadAmiiboLauncher.launch(AMIIBO_MIME_TYPES) + } + + else -> showAmiiboDialog(R.string.amiibo_wrong_state) + } + + return true + } + + private fun handleAmiiboLoadResult(result: Int) { + when (AmiiboLoadResult.fromValue(result)) { + AmiiboLoadResult.Success -> { + if (!isAdded) { + return + } + Toast.makeText( + requireContext(), + getString(R.string.amiibo_load_success), + Toast.LENGTH_SHORT + ).show() + } + + AmiiboLoadResult.UnableToLoad -> showAmiiboDialog(R.string.amiibo_in_use) + AmiiboLoadResult.NotAnAmiibo -> showAmiiboDialog(R.string.amiibo_not_valid) + AmiiboLoadResult.WrongDeviceState -> showAmiiboDialog(R.string.amiibo_wrong_state) + AmiiboLoadResult.Unknown -> showAmiiboDialog(R.string.amiibo_unknown_error) + } + } + + private fun showAmiiboDialog(@StringRes messageRes: Int) { + if (!isAdded) { + return + } + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.amiibo_title) + .setMessage(messageRes) + .setPositiveButton(R.string.ok, null) + .show() + } + override fun onPause() { if (this::emulationState.isInitialized) { if (emulationState.isRunning && emulationActivity?.isInPictureInPictureMode != true) { @@ -906,6 +1008,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { override fun onDestroyView() { super.onDestroyView() _binding = null + isAmiiboPickerOpen = false } override fun onDetach() { @@ -1739,7 +1842,34 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { } } + private enum class AmiiboState(val value: Int) { + Disabled(0), + Initialized(1), + WaitingForAmiibo(2), + TagNearby(3); + + companion object { + fun fromValue(value: Int): AmiiboState = + values().firstOrNull { it.value == value } ?: Disabled + } + } + + private enum class AmiiboLoadResult(val value: Int) { + Success(0), + UnableToLoad(1), + NotAnAmiibo(2), + WrongDeviceState(3), + Unknown(4); + + companion object { + fun fromValue(value: Int): AmiiboLoadResult = + values().firstOrNull { it.value == value } ?: Unknown + } + } + companion object { + private val AMIIBO_MIME_TYPES = + arrayOf("application/octet-stream", "application/x-binary", "*/*") private val perfStatsUpdateHandler = Handler(Looper.myLooper()!!) private val socUpdateHandler = Handler(Looper.myLooper()!!) } diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp index ee9e455718..eebc4d5841 100644 --- a/src/android/app/src/main/jni/native.cpp +++ b/src/android/app/src/main/jni/native.cpp @@ -7,8 +7,10 @@ #include #include +#include #include #include +#include #include #ifdef ARCHITECTURE_arm64 @@ -67,6 +69,7 @@ #include "hid_core/frontend/emulated_controller.h" #include "hid_core/hid_core.h" #include "hid_core/hid_types.h" +#include "input_common/drivers/virtual_amiibo.h" #include "jni/native.h" #include "video_core/renderer_base.h" #include "video_core/renderer_vulkan/renderer_vulkan.h" @@ -1005,6 +1008,44 @@ jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_areKeysPresent(JNIEnv* env, jobje return ContentManager::AreKeysPresent(); } +jint Java_org_yuzu_yuzu_1emu_NativeLibrary_getVirtualAmiiboState(JNIEnv* env, jobject jobj) { + if (!EmulationSession::GetInstance().IsRunning()) { + return static_cast(InputCommon::VirtualAmiibo::State::Disabled); + } + + auto* virtual_amiibo = + EmulationSession::GetInstance().GetInputSubsystem().GetVirtualAmiibo(); + if (virtual_amiibo == nullptr) { + return static_cast(InputCommon::VirtualAmiibo::State::Disabled); + } + + return static_cast(virtual_amiibo->GetCurrentState()); +} + +jint Java_org_yuzu_yuzu_1emu_NativeLibrary_loadAmiibo(JNIEnv* env, jobject jobj, + jbyteArray jdata) { + if (!EmulationSession::GetInstance().IsRunning() || jdata == nullptr) { + return static_cast(InputCommon::VirtualAmiibo::Info::WrongDeviceState); + } + + auto* virtual_amiibo = + EmulationSession::GetInstance().GetInputSubsystem().GetVirtualAmiibo(); + if (virtual_amiibo == nullptr) { + return static_cast(InputCommon::VirtualAmiibo::Info::Unknown); + } + + const jsize length = env->GetArrayLength(jdata); + std::vector bytes(static_cast(length)); + if (length > 0) { + env->GetByteArrayRegion(jdata, 0, length, + reinterpret_cast(bytes.data())); + } + + const auto info = + virtual_amiibo->LoadAmiibo(std::span(bytes.data(), bytes.size())); + return static_cast(info); +} + JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_initMultiplayer( JNIEnv* env, [[maybe_unused]] jobject obj) { diff --git a/src/android/app/src/main/res/menu/menu_in_game.xml b/src/android/app/src/main/res/menu/menu_in_game.xml index d45699638c..70fb48c13b 100644 --- a/src/android/app/src/main/res/menu/menu_in_game.xml +++ b/src/android/app/src/main/res/menu/menu_in_game.xml @@ -33,6 +33,11 @@ android:icon="@drawable/ic_two_users" android:title="@string/multiplayer" /> + + Pause emulation Unpause emulation Overlay options + Load Amiibo Touchscreen Lock drawer Unlock drawer Reset + + Amiibo + The current amiibo has been removed + The current game is not looking for amiibo + The selected file is not a valid amiibo + The selected file is already in use + An unknown error occurred + Amiibo loaded + Software keyboard