diff --git a/src/KenjinxAndroid/app/src/main/AndroidManifest.xml b/src/KenjinxAndroid/app/src/main/AndroidManifest.xml index a19981735..ccdd38f88 100644 --- a/src/KenjinxAndroid/app/src/main/AndroidManifest.xml +++ b/src/KenjinxAndroid/app/src/main/AndroidManifest.xml @@ -2,6 +2,15 @@ + + + + + + + + + @@ -58,9 +67,17 @@ --> + + + { - val bootPath = storedIntent.getStringExtra("bootPath") - val forceNceAndPptc = storedIntent.getBooleanExtra("forceNceAndPptc", false) + val bootPathExtra = storedIntent.getStringExtra(EXTRA_BOOT_PATH) + val forceNceAndPptc = storedIntent.getBooleanExtra(EXTRA_FORCE_NCE_PPTC, false) + val titleId = storedIntent.getStringExtra(EXTRA_TITLE_ID) ?: "" + val titleName = storedIntent.getStringExtra(EXTRA_TITLE_NAME) ?: "" + val dataUri: Uri? = storedIntent.data - if (bootPath != null) { - val uri = bootPath.toUri() - val documentFile = DocumentFile.fromSingleUri(this, uri) + val chosenUri: Uri? = when { + !bootPathExtra.isNullOrEmpty() -> bootPathExtra.toUri() + dataUri != null -> dataUri + else -> null + } - if (documentFile != null) { - val gameModel = GameModel(documentFile, this) + if (chosenUri != null) { + val doc = when (chosenUri.scheme?.lowercase()) { + "content" -> DocumentFile.fromSingleUri(this, chosenUri) + "file" -> chosenUri.path?.let { File(it) }?.let { DocumentFile.fromFile(it) } + else -> { + chosenUri.path?.let { File(it) }?.takeIf { it.exists() }?.let { DocumentFile.fromFile(it) } + ?: DocumentFile.fromSingleUri(this, chosenUri) + } + } + + if (doc != null && doc.exists()) { + val gameModel = GameModel(doc, this) gameModel.getGameInfo() mainViewModel?.loadGameModel?.value = gameModel mainViewModel?.bootPath?.value = "gameItem_${gameModel.titleName}" mainViewModel?.forceNceAndPptc?.value = forceNceAndPptc + storedIntent = Intent() + return + } else { + Log.w("ShortcutDebug", "DocumentFile not found or not accessible: $chosenUri") + } + } + + if (titleId.isNotEmpty() || titleName.isNotEmpty()) { + resolveGameByTitleIdOrName(titleId, titleName)?.let { doc -> + val gameModel = GameModel(doc, this) + gameModel.getGameInfo() + mainViewModel?.loadGameModel?.value = gameModel + mainViewModel?.bootPath?.value = "gameItem_${gameModel.titleName}" + mainViewModel?.forceNceAndPptc?.value = forceNceAndPptc + storedIntent = Intent() + return } } } @@ -347,6 +388,83 @@ class MainActivity : BaseActivity() { try { KenjinxNative.setSurfaceRotationByAndroidRotation(rot) } catch (_: Throwable) {} } + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + val rot = this.display?.rotation + val old = lastKnownRotation + lastKnownRotation = rot + + rotLog("onConfigurationChanged: display.rotation=$rot → ${deg(rot)}°") + + try { KenjinxNative.setSurfaceRotationByAndroidRotation(rot) } catch (_: Throwable) {} + + val pref = QuickSettings(this).orientationPreference + val shouldPropagate = + pref == QuickSettings.OrientationPreference.Sensor || + pref == QuickSettings.OrientationPreference.SensorLandscape + + if (shouldPropagate && isGameRunning) { + handler.post { try { mainViewModel?.gameHost?.onOrientationOrSizeChanged(rot) } catch (_: Throwable) {} } + } + + if (pref == QuickSettings.OrientationPreference.SensorLandscape && old != null && rot != null) { + val isSideFlip = (old == Surface.ROTATION_90 && rot == Surface.ROTATION_270) || + (old == Surface.ROTATION_270 && rot == Surface.ROTATION_90) + if (isSideFlip) doOrientationPulse(rot) + } + } + + // --- Helper for Shortcut-Fallback --- + private fun resolveGameByTitleIdOrName(titleIdHex: String?, displayName: String?): DocumentFile? { + val gamesRoot = getDefaultGamesTree() ?: return null + for (child in gamesRoot.listFiles()) { + if (!child.isFile) continue + if (!displayName.isNullOrBlank()) { + val n = child.name ?: "" + if (n.contains(displayName, ignoreCase = true)) return child + } + if (!titleIdHex.isNullOrBlank()) { + val tid = getTitleIdFast(child) + if (tid != null && tid.equals(titleIdHex, ignoreCase = true)) return child + } + } + if (!titleIdHex.isNullOrBlank()) { + for (child in gamesRoot.listFiles()) { + if (!child.isFile) continue + val tid = getTitleIdFast(child) + if (tid != null && tid.equals(titleIdHex, ignoreCase = true)) return child + } + } + return null + } + + private fun getDefaultGamesTree(): DocumentFile? { + val vm = mainViewModel + if (vm?.defaultGameFolderUri != null) { + return DocumentFile.fromTreeUri(this, vm.defaultGameFolderUri!!) + } + val prefs = PreferenceManager.getDefaultSharedPreferences(this) + val legacyPath = prefs.getString("gameFolder", null) + if (!legacyPath.isNullOrEmpty()) { + // Ohne SAF-URI kein Tree-Listing möglich + } + return null + } + + private fun getTitleIdFast(file: DocumentFile): String? { + val name = file.name ?: return null + val dot = name.lastIndexOf('.') + if (dot <= 0 || dot >= name.length - 1) return null + val ext = name.substring(dot + 1).lowercase() + return try { + contentResolver.openFileDescriptor(file.uri, "r")?.use { pfd -> + val info = org.kenjinx.android.viewmodels.GameInfo() + KenjinxNative.deviceGetGameInfo(pfd.fd, ext, info) + info.TitleId?.lowercase() + } + } catch (_: Exception) { null } + } + fun shutdownAndRestart() { val packageManager = packageManager val intent = packageManager.getLaunchIntentForPackage(packageName) diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/ShortcutHelper.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/ShortcutHelper.kt new file mode 100644 index 000000000..ef01d5c45 --- /dev/null +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/ShortcutHelper.kt @@ -0,0 +1,95 @@ +package org.kenjinx.android + +import android.app.PendingIntent +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.util.Base64 +import android.widget.Toast +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat + +object ShortcutHelper { + + fun createGameShortcut( + context: Context, + title: String, + bootPathUri: String, + useGridIcon: Boolean, + gridIconBitmap: Bitmap? = null, + gridIconBase64: String? = null + ) { + val uri = runCatching { Uri.parse(bootPathUri) }.getOrNull() + + var icon = IconCompat.createWithResource(context, R.mipmap.ic_launcher) + if (useGridIcon) { + val bmp = gridIconBitmap ?: decodeBase64ToBitmap(gridIconBase64) + if (bmp != null) icon = IconCompat.createWithBitmap(bmp) + } + + val launchIntent = Intent(Intent.ACTION_VIEW).apply { + component = ComponentName(context, MainActivity::class.java) + if (uri != null) { + setDataAndType(uri, context.contentResolver.getType(uri) ?: "*/*") + clipData = android.content.ClipData.newUri( + context.contentResolver, + "GameUri", + uri + ) + } + putExtra("bootPath", bootPathUri) + putExtra("forceNceAndPptc", false) + addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + } + + if (uri != null) { + val rw = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + runCatching { context.contentResolver.takePersistableUriPermission(uri, rw) } + runCatching { context.grantUriPermission(context.packageName, uri, rw) } + } + + val shortcutId = makeStableId(title, bootPathUri) + + val shortcut = ShortcutInfoCompat.Builder(context, shortcutId) + .setShortLabel(title) + .setLongLabel(title) + .setIcon(icon) + .setIntent(launchIntent) + .build() + + Toast.makeText(context, "Creating shortcut “$title”…", Toast.LENGTH_SHORT).show() + + val callbackIntent = ShortcutManagerCompat.createShortcutResultIntent(context, shortcut) + val successCallback = PendingIntent.getBroadcast( + context, + 0, + callbackIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + ShortcutManagerCompat.requestPinShortcut(context, shortcut, successCallback.intentSender) + } + + private fun makeStableId(title: String?, bootPath: String?): String { + val safeTitle = (title ?: "").trim() + val safeBoot = (bootPath ?: "").trim() + return "kenjinx_${safeTitle}_${safeBoot}".take(90) + } + + private fun decodeBase64ToBitmap(b64: String?): Bitmap? { + if (b64.isNullOrBlank()) return null + return runCatching { + val bytes = Base64.decode(b64, Base64.DEFAULT) + BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + }.getOrNull() + } +} diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/ShortcutUtils.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/ShortcutUtils.kt new file mode 100644 index 000000000..05887501e --- /dev/null +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/ShortcutUtils.kt @@ -0,0 +1,123 @@ +package org.kenjinx.android + +import android.app.Activity +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.ClipData +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.ActivityInfo +import android.content.pm.ShortcutInfo +import android.content.pm.ShortcutManager +import android.graphics.Bitmap +import android.graphics.drawable.Icon +import android.net.Uri +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.view.MotionEvent +import android.view.View + +object ShortcutUtils { + + fun suggestLabelFromUri(uri: Uri): String { + val last = uri.lastPathSegment ?: return "Start Game" + val raw = last.substringAfterLast("%2F").substringAfterLast("/") + val decoded = Uri.decode(raw) + return decoded.substringBeforeLast('.').ifBlank { "Start Game" } + } + + fun persistReadWrite(activity: Activity, uri: Uri) { + val rw = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + try { activity.contentResolver.takePersistableUriPermission(uri, rw) } catch (_: Exception) {} + try { activity.grantUriPermission(activity.packageName, uri, rw) } catch (_: Exception) {} + try { activity.grantUriPermission("org.kenjinx.android", uri, rw) } catch (_: Exception) {} + } + + fun pinShortcutForGame( + activity: Activity, + gameUri: Uri, + label: String, + iconBitmap: Bitmap? = null, + onCompleted: (() -> Unit)? = null + ): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false + val sm = activity.getSystemService(ShortcutManager::class.java) ?: return false + + val rw = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + val mime = activity.contentResolver.getType(gameUri) ?: "*/*" + val clip = ClipData.newUri(activity.contentResolver, "GameUri", gameUri) + + val launchIntent = Intent(activity, MainActivity::class.java).apply { + action = Intent.ACTION_VIEW + setDataAndType(gameUri, mime) + clipData = clip + putExtra("bootPath", gameUri.toString()) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or rw) + } + + val icon = iconBitmap?.let { Icon.createWithBitmap(it) } + ?: Icon.createWithResource(activity, R.mipmap.ic_launcher) + + val shortcut = ShortcutInfo.Builder(activity, "kenji_game_${gameUri.hashCode()}") + .setShortLabel(label.take(24)) + .setLongLabel(label) + .setIcon(icon) + .setIntent(launchIntent) + .build() + + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + + val mainHandler = Handler(Looper.getMainLooper()) + val decorView = activity.window?.decorView + var restored = false + var touchScheduled = false + var pinResultReceiver: BroadcastReceiver? = null + + fun restoreOrientation(@Suppress("UNUSED_PARAMETER") reason: String) { + if (restored) return + restored = true + try { decorView?.setOnTouchListener(null) } catch (_: Exception) {} + try { pinResultReceiver?.let { activity.unregisterReceiver(it) } } catch (_: Exception) {} + mainHandler.removeCallbacksAndMessages(null) + + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + + onCompleted?.invoke() + } + + val ACTION_PIN_RESULT = "${activity.packageName}.PIN_SHORTCUT_RESULT" + pinResultReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + restoreOrientation("intent-callback") + } + } + val filter = IntentFilter(ACTION_PIN_RESULT) + if (Build.VERSION.SDK_INT >= 33) { + activity.registerReceiver(pinResultReceiver!!, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + @Suppress("UnspecifiedRegisterReceiverFlag") + activity.registerReceiver(pinResultReceiver, filter) + } + + if (sm.isRequestPinShortcutSupported) { + val successIntent = sm.createShortcutResultIntent(shortcut).apply { + action = ACTION_PIN_RESULT + } + val sender = PendingIntent.getBroadcast( + activity, + 0, + successIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ).intentSender + + sm.requestPinShortcut(shortcut, sender) + return true + } else { + sm.addDynamicShortcuts(listOf(shortcut)) + restoreOrientation("no-pin-support") + return true + } + } +} diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/ShortcutWizardActivity.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/ShortcutWizardActivity.kt new file mode 100644 index 000000000..86d33925c --- /dev/null +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/ShortcutWizardActivity.kt @@ -0,0 +1,156 @@ +package org.kenjinx.android + +import android.app.Activity +import android.app.AlertDialog +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Bundle +import android.widget.EditText +import android.widget.Toast +import android.content.pm.ActivityInfo +import android.util.Base64 +import androidx.documentfile.provider.DocumentFile +import org.kenjinx.android.viewmodels.GameInfo + +class ShortcutWizardActivity : Activity() { + + private val REQ_PICK_GAME = 1 + private val REQ_PICK_ICON = 2 + + private var pickedGameUri: Uri? = null + private var pendingLabel: String? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + + requestGameFile() + } + + private fun requestGameFile() { + val pick = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "*/*" + addFlags( + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + } + startActivityForResult(pick, REQ_PICK_GAME) + } + + @Deprecated("simple flow") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + + if (requestCode == REQ_PICK_GAME) { + if (resultCode != RESULT_OK) { finish(); return } + val uri = data?.data ?: run { finish(); return } + pickedGameUri = uri + ShortcutUtils.persistReadWrite(this, uri) + + val suggested = ShortcutUtils.suggestLabelFromUri(uri) + val input = EditText(this).apply { setText(suggested) } + + AlertDialog.Builder(this) + .setTitle("Shortcut name") + .setView(input) + .setPositiveButton("Next (Icon)") { _, _ -> + val label = input.text?.toString()?.takeIf { it.isNotBlank() } ?: suggested + pendingLabel = label + requestIconImage() + } + .setNeutralButton("Use app icon") { _, _ -> + val label = input.text?.toString()?.takeIf { it.isNotBlank() } ?: suggested + val bmp = pickedGameUri?.let { loadGridIconBitmap(it) } + createShortcut(label, bmp) + } + .setNegativeButton("Cancel") { _, _ -> finish() } + .setCancelable(false) + .show() + return + } + + if (requestCode == REQ_PICK_ICON) { + if (resultCode != RESULT_OK) { + val label = pendingLabel ?: "Start Game" + createShortcut(label, null) + return + } + val imageUri = data?.data + val bmp = imageUri?.let { loadBitmap(it) } + val label = pendingLabel ?: "Start Game" + createShortcut(label, bmp) + return + } + + finish() + } + + private fun requestIconImage() { + val pickIcon = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "image/*" + addFlags( + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } + startActivityForResult(Intent.createChooser(pickIcon, "Select icon"), REQ_PICK_ICON) + } + + private fun loadBitmap(uri: Uri): Bitmap? = + try { + contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + contentResolver.openInputStream(uri).use { BitmapFactory.decodeStream(it) } + } catch (_: Exception) { + null + } + + private fun loadGridIconBitmap(gameUri: Uri): Bitmap? { + return try { + val doc = DocumentFile.fromSingleUri(this, gameUri) + val name = doc?.name ?: return null + val ext = name.substringAfterLast('.', "").lowercase() + + val pfd = contentResolver.openFileDescriptor(gameUri, "r") ?: return null + pfd.use { + val info = GameInfo() + KenjinxNative.deviceGetGameInfo(it.fd, ext, info) + val b64 = info.Icon ?: return null + val bytes = Base64.decode(b64, Base64.DEFAULT) + BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + } + } catch (_: Exception) { + null + } + } + + private fun createShortcut(label: String, bmp: Bitmap?) { + val gameUri = pickedGameUri + if (gameUri == null) { finish(); return } + + val ok = ShortcutUtils.pinShortcutForGame( + activity = this, + gameUri = gameUri, + label = label, + iconBitmap = bmp + ) { + finish() + } + + Toast.makeText( + this, + if (ok) "Shortcut “$label” created." else "Shortcut failed.", + Toast.LENGTH_SHORT + ).show() + } + + override fun onResume() { + super.onResume() + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } +} diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/MainViewModel.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/MainViewModel.kt index d7bcb18fa..eb2e79726 100644 --- a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/MainViewModel.kt +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/MainViewModel.kt @@ -1,6 +1,7 @@ package org.kenjinx.android.viewmodels import android.annotation.SuppressLint +import android.net.Uri import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.navigation.NavHostController @@ -51,6 +52,13 @@ class MainViewModel(val activity: MainActivity) { private var showLoading: MutableState? = null private var refreshUser: MutableState? = null + // Default Game Folder + var defaultGameFolderUri: Uri? = null + set(value) { + field = value + val prefs = PreferenceManager.getDefaultSharedPreferences(activity) + prefs.edit().putString("defaultGameFolderUri", value?.toString() ?: "").apply() + } var gameHost: GameHost? = null set(value) { field = value @@ -62,6 +70,12 @@ class MainViewModel(val activity: MainActivity) { init { performanceManager = PerformanceManager(activity) + + val prefs = PreferenceManager.getDefaultSharedPreferences(activity) + val saved = prefs.getString("defaultGameFolderUri", "") ?: "" + if (saved.isNotEmpty()) { + defaultGameFolderUri = Uri.parse(saved) + } } fun refreshFirmwareVersion() { diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/QuickSettings.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/QuickSettings.kt index 762407ecb..645e85fbc 100644 --- a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/QuickSettings.kt +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/QuickSettings.kt @@ -41,6 +41,18 @@ class QuickSettings(val activity: Activity) { var useNce: Boolean var memoryConfiguration: MemoryConfiguration var useVirtualController: Boolean + // Amiibo slots (URIs + names) + var amiibo1Uri: String? + var amiibo1Name: String? + var amiibo2Uri: String? + var amiibo2Name: String? + var amiibo3Uri: String? + var amiibo3Name: String? + var amiibo4Uri: String? + var amiibo4Name: String? + var amiibo5Uri: String? + var amiibo5Name: String? + var memoryManagerMode: MemoryManagerMode var enableShaderCache: Boolean var enableTextureRecompression: Boolean @@ -66,6 +78,18 @@ class QuickSettings(val activity: Activity) { private var sharedPref: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(activity) init { + // Load Amiibo slots + amiibo1Uri = sharedPref.getString("amiibo1Uri", null) + amiibo1Name = sharedPref.getString("amiibo1Name", null) + amiibo2Uri = sharedPref.getString("amiibo2Uri", null) + amiibo2Name = sharedPref.getString("amiibo2Name", null) + amiibo3Uri = sharedPref.getString("amiibo3Uri", null) + amiibo3Name = sharedPref.getString("amiibo3Name", null) + amiibo4Uri = sharedPref.getString("amiibo4Uri", null) + amiibo4Name = sharedPref.getString("amiibo4Name", null) + amiibo5Uri = sharedPref.getString("amiibo5Uri", null) + amiibo5Name = sharedPref.getString("amiibo5Name", null) + // --- Load alignment (Default: Sensor) val oriValue = sharedPref.getInt("orientationPreference", ActivityInfo.SCREEN_ORIENTATION_SENSOR) orientationPreference = OrientationPreference.fromValue(oriValue) @@ -112,6 +136,18 @@ class QuickSettings(val activity: Activity) { fun save() { sharedPref.edit { + // Amiibo slots + putString("amiibo1Uri", amiibo1Uri) + putString("amiibo1Name", amiibo1Name) + putString("amiibo2Uri", amiibo2Uri) + putString("amiibo2Name", amiibo2Name) + putString("amiibo3Uri", amiibo3Uri) + putString("amiibo3Name", amiibo3Name) + putString("amiibo4Uri", amiibo4Uri) + putString("amiibo4Name", amiibo4Name) + putString("amiibo5Uri", amiibo5Uri) + putString("amiibo5Name", amiibo5Name) + // --- Save orientation putInt("orientationPreference", orientationPreference.value) diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/SettingsViewModel.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/SettingsViewModel.kt index 7f522425f..20637f2f0 100644 --- a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/SettingsViewModel.kt +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/SettingsViewModel.kt @@ -214,6 +214,9 @@ class SettingsViewModel(val activity: MainActivity) { sharedPref.edit { putString("gameFolder", p) } + runCatching { + MainActivity.mainViewModel?.defaultGameFolderUri = folder.uri + } activity.storageHelper!!.onFolderSelected = previousFolderCallback } diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/GameViews.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/GameViews.kt index 8ae382828..5c662e0fb 100644 --- a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/GameViews.kt +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/GameViews.kt @@ -48,6 +48,8 @@ import org.kenjinx.android.viewmodels.VSyncMode import org.kenjinx.android.widgets.SimpleAlertDialog import java.util.Locale import kotlin.math.roundToInt +import android.net.Uri +import android.widget.Toast class GameViews { companion object { @@ -107,6 +109,8 @@ class GameViews { } } + fun qsLabel(name: String?, slot: Int): String = + if (name.isNullOrBlank()) "Slot $slot" else name if (showStats.value) { GameStats(mainViewModel) @@ -245,6 +249,110 @@ class GameViews { ) } } + + // MINIMAL ADD: Amiibo slot buttons + Column(modifier = Modifier.padding(bottom = 8.dp)) { + Text(text = "Amiibo Slots") + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + androidx.compose.material3.Button(onClick = { + val qs = QuickSettings(mainViewModel.activity) + val u = qs.amiibo1Uri + val name = qs.amiibo1Name ?: "Slot 1" + if (u.isNullOrEmpty()) { + Toast.makeText(mainViewModel.activity, "Slot 1 is empty.", Toast.LENGTH_SHORT).show() + } else { + try { + val bytes = mainViewModel.activity.contentResolver.openInputStream(Uri.parse(u))?.use { it.readBytes() } + if (bytes != null && bytes.isNotEmpty()) { + val ok = KenjinxNative.amiiboLoadBin(bytes, bytes.size) + if (ok) Toast.makeText(mainViewModel.activity, "Loaded: $name", Toast.LENGTH_SHORT).show() + else Toast.makeText(mainViewModel.activity, "Load failed (check log)", Toast.LENGTH_SHORT).show() + } else Toast.makeText(mainViewModel.activity, "File not readable.", Toast.LENGTH_SHORT).show() + } catch (t: Throwable) { Toast.makeText(mainViewModel.activity, "Error: ${t.message}", Toast.LENGTH_SHORT).show() } + } + }) { androidx.compose.material3.Text(qsLabel(QuickSettings(mainViewModel.activity).amiibo1Name, 1)) } + + androidx.compose.material3.Button(onClick = { + val qs = QuickSettings(mainViewModel.activity) + val u = qs.amiibo2Uri + val name = qs.amiibo2Name ?: "Slot 2" + if (u.isNullOrEmpty()) { + Toast.makeText(mainViewModel.activity, "Slot 2 is empty.", Toast.LENGTH_SHORT).show() + } else { + try { + val bytes = mainViewModel.activity.contentResolver.openInputStream(Uri.parse(u))?.use { it.readBytes() } + if (bytes != null && bytes.isNotEmpty()) { + val ok = KenjinxNative.amiiboLoadBin(bytes, bytes.size) + if (ok) Toast.makeText(mainViewModel.activity, "Loaded: $name", Toast.LENGTH_SHORT).show() + else Toast.makeText(mainViewModel.activity, "Load failed (check log)", Toast.LENGTH_SHORT).show() + } else Toast.makeText(mainViewModel.activity, "File not readable.", Toast.LENGTH_SHORT).show() + } catch (t: Throwable) { Toast.makeText(mainViewModel.activity, "Error: ${t.message}", Toast.LENGTH_SHORT).show() } + } + }) { androidx.compose.material3.Text(qsLabel(QuickSettings(mainViewModel.activity).amiibo2Name, 2)) } + + androidx.compose.material3.Button(onClick = { + val qs = QuickSettings(mainViewModel.activity) + val u = qs.amiibo3Uri + val name = qs.amiibo3Name ?: "Slot 3" + if (u.isNullOrEmpty()) { + Toast.makeText(mainViewModel.activity, "Slot 3 is empty.", Toast.LENGTH_SHORT).show() + } else { + try { + val bytes = mainViewModel.activity.contentResolver.openInputStream(Uri.parse(u))?.use { it.readBytes() } + if (bytes != null && bytes.isNotEmpty()) { + val ok = KenjinxNative.amiiboLoadBin(bytes, bytes.size) + if (ok) Toast.makeText(mainViewModel.activity, "Loaded: $name", Toast.LENGTH_SHORT).show() + else Toast.makeText(mainViewModel.activity, "Load failed (check log)", Toast.LENGTH_SHORT).show() + } else Toast.makeText(mainViewModel.activity, "File not readable.", Toast.LENGTH_SHORT).show() + } catch (t: Throwable) { Toast.makeText(mainViewModel.activity, "Error: ${t.message}", Toast.LENGTH_SHORT).show() } + } + }) { androidx.compose.material3.Text(qsLabel(QuickSettings(mainViewModel.activity).amiibo3Name, 3)) } + } + + Row(modifier = Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + androidx.compose.material3.Button(onClick = { + val qs = QuickSettings(mainViewModel.activity) + val u = qs.amiibo4Uri + val name = qs.amiibo4Name ?: "Slot 4" + if (u.isNullOrEmpty()) { + Toast.makeText(mainViewModel.activity, "Slot 4 is empty.", Toast.LENGTH_SHORT).show() + } else { + try { + val bytes = mainViewModel.activity.contentResolver.openInputStream(Uri.parse(u))?.use { it.readBytes() } + if (bytes != null && bytes.isNotEmpty()) { + val ok = KenjinxNative.amiiboLoadBin(bytes, bytes.size) + if (ok) Toast.makeText(mainViewModel.activity, "Loaded: $name", Toast.LENGTH_SHORT).show() + else Toast.makeText(mainViewModel.activity, "Load failed (check log)", Toast.LENGTH_SHORT).show() + } else Toast.makeText(mainViewModel.activity, "File not readable.", Toast.LENGTH_SHORT).show() + } catch (t: Throwable) { Toast.makeText(mainViewModel.activity, "Error: ${t.message}", Toast.LENGTH_SHORT).show() } + } + }) { androidx.compose.material3.Text(qsLabel(QuickSettings(mainViewModel.activity).amiibo4Name, 4)) } + + androidx.compose.material3.Button(onClick = { + val qs = QuickSettings(mainViewModel.activity) + val u = qs.amiibo5Uri + val name = qs.amiibo5Name ?: "Slot 5" + if (u.isNullOrEmpty()) { + Toast.makeText(mainViewModel.activity, "Slot 5 is empty.", Toast.LENGTH_SHORT).show() + } else { + try { + val bytes = mainViewModel.activity.contentResolver.openInputStream(Uri.parse(u))?.use { it.readBytes() } + if (bytes != null && bytes.isNotEmpty()) { + val ok = KenjinxNative.amiiboLoadBin(bytes, bytes.size) + if (ok) Toast.makeText(mainViewModel.activity, "Loaded: $name", Toast.LENGTH_SHORT).show() + else Toast.makeText(mainViewModel.activity, "Load failed (check log)", Toast.LENGTH_SHORT).show() + } else Toast.makeText(mainViewModel.activity, "File not readable.", Toast.LENGTH_SHORT).show() + } catch (t: Throwable) { Toast.makeText(mainViewModel.activity, "Error: ${t.message}", Toast.LENGTH_SHORT).show() } + } + }) { androidx.compose.material3.Text(qsLabel(QuickSettings(mainViewModel.activity).amiibo5Name, 5)) } + + androidx.compose.material3.OutlinedButton(onClick = { + KenjinxNative.amiiboClear() + Toast.makeText(mainViewModel.activity, "Amiibo cleared", Toast.LENGTH_SHORT).show() + }) { androidx.compose.material3.Text("Clear") } + } + } } } } diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/HomeViews.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/HomeViews.kt index fd9e3c206..09de9830c 100644 --- a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/HomeViews.kt +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/HomeViews.kt @@ -1,7 +1,11 @@ package org.kenjinx.android.views +import android.app.Activity +import android.content.Intent import android.content.res.Resources +import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.net.Uri import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image @@ -87,13 +91,21 @@ import org.kenjinx.android.viewmodels.GameModel import org.kenjinx.android.viewmodels.HomeViewModel import org.kenjinx.android.viewmodels.QuickSettings import org.kenjinx.android.widgets.SimpleAlertDialog +import org.kenjinx.android.ShortcutUtils +import org.kenjinx.android.ShortcutWizardActivity +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.TextButton +import androidx.documentfile.provider.DocumentFile class HomeViews { companion object { const val ListImageSize = 150 const val GridImageSize = 300 - // --- small version badge bottom left + // Small version badge in the bottom-left corner @Composable private fun VersionBadge(modifier: Modifier = Modifier) { Text( @@ -104,6 +116,19 @@ class HomeViews { ) } + // Helper for the shortcut flow + private fun resolveGameUri(gm: GameModel): Uri? = gm.file.uri + + private fun decodeGameIcon(gm: GameModel): Bitmap? { + return try { + val b64 = gm.icon ?: return null + val bytes = Base64.getDecoder().decode(b64) + BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + } catch (_: Throwable) { + null + } + } + @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun Home( @@ -124,7 +149,70 @@ class HomeViews { var isFabVisible by remember { mutableStateOf(true) } val isNavigating = remember { mutableStateOf(false) } + // NEW: Amiibo slot picker state + val showAmiiboSlotDialog = remember { mutableStateOf(false) } + val pendingSlot = remember { mutableStateOf(1) } + + // Shortcut dialog state + val showShortcutDialog = remember { mutableStateOf(false) } + val shortcutName = remember { mutableStateOf("") } + val context = LocalContext.current + val activity = LocalContext.current as? Activity + + // NEW: Launcher for Amiibo (OpenDocument) + val pickAmiiboLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + if (uri != null && activity != null) { + try { + activity.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } catch (_: Exception) {} + val name = DocumentFile.fromSingleUri(activity, uri)?.name ?: "amiibo.bin" + val qs = QuickSettings(activity) + when (pendingSlot.value) { + 1 -> { qs.amiibo1Uri = uri.toString(); qs.amiibo1Name = name } + 2 -> { qs.amiibo2Uri = uri.toString(); qs.amiibo2Name = name } + 3 -> { qs.amiibo3Uri = uri.toString(); qs.amiibo3Name = name } + 4 -> { qs.amiibo4Uri = uri.toString(); qs.amiibo4Name = name } + 5 -> { qs.amiibo5Uri = uri.toString(); qs.amiibo5Name = name } + } + qs.save() + Toast.makeText(activity, "Amiibo saved to slot ${pendingSlot.value}", Toast.LENGTH_SHORT).show() + } + } + + // Launcher for custom icon (OpenDocument) + val pickImageLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + val gm = viewModel.mainViewModel?.selected + if (uri != null && gm != null && activity != null) { + val bmp = runCatching { + context.contentResolver.openInputStream(uri).use { BitmapFactory.decodeStream(it) } + }.getOrNull() + + val label = shortcutName.value.ifBlank { gm.titleName ?: "Start Game" } + val gameUri = resolveGameUri(gm) + if (gameUri != null) { + ShortcutUtils.persistReadWrite(activity, gameUri) + + ShortcutUtils.pinShortcutForGame( + activity = activity, + gameUri = gameUri, + label = label, + iconBitmap = bmp + ) { + + } + } else { + showError.value = "Shortcut failed (no game URI found)." + } + } + } val nestedScrollConnection = remember { object : NestedScrollConnection { @@ -136,7 +224,7 @@ class HomeViews { } } - // --- Box around scaffold so we can overlay the badge + // Wrap the scaffold so we can overlay the version badge Box(Modifier.fillMaxSize()) { Scaffold( modifier = Modifier.fillMaxSize(), @@ -211,7 +299,7 @@ class HomeViews { } } - // Settings + // Settings button IconButton( onClick = { if (!isNavigating.value) { @@ -241,7 +329,7 @@ class HomeViews { Icon(Icons.Filled.Settings, contentDescription = "Settings") } - } + } OutlinedTextField( value = query.value, @@ -256,27 +344,39 @@ class HomeViews { singleLine = true, shape = RoundedCornerShape(8.dp), colors = OutlinedTextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - errorContainerColor = Color.Transparent, - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.outline, - ) + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + errorContainerColor = Color.Transparent, + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline, + ) ) } }, floatingActionButton = { AnimatedVisibility(visible = isFabVisible) { - FloatingActionButton( - onClick = { - viewModel.requestReload() - viewModel.ensureReloadIfNecessary() - }, - shape = MaterialTheme.shapes.small, - containerColor = MaterialTheme.colorScheme.tertiary - ) { - Icon(Icons.Default.Refresh, contentDescription = "refresh") + // NEW: two FABs in a row: Refresh + Import Amiibo + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + FloatingActionButton( + onClick = { + viewModel.requestReload() + viewModel.ensureReloadIfNecessary() + }, + shape = MaterialTheme.shapes.small, + containerColor = MaterialTheme.colorScheme.tertiary + ) { + Icon(Icons.Default.Refresh, contentDescription = "refresh") + } + FloatingActionButton( + onClick = { showAmiiboSlotDialog.value = true }, + shape = MaterialTheme.shapes.small + ) { + Icon( + org.kenjinx.android.Icons.folderOpen(MaterialTheme.colorScheme.onSurface), + contentDescription = "Import Amiibo" + ) + } } } }, @@ -375,6 +475,51 @@ class HomeViews { val name = viewModel.mainViewModel?.selected?.titleName ?: "" DlcViews.Main(titleId, name, openDlcDialog, canClose) } + + // NEW: Amiibo slot chooser dialog (outside of game) + if (showAmiiboSlotDialog.value) { + androidx.compose.material3.AlertDialog( + onDismissRequest = { showAmiiboSlotDialog.value = false }, + title = { Text("Import Amiibo") }, + text = { + Column { + Text("Choose a slot to save this Amiibo:", modifier = Modifier.padding(bottom = 8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { + pendingSlot.value = 1 + pickAmiiboLauncher.launch(arrayOf("application/octet-stream", "*/*")) + showAmiiboSlotDialog.value = false + }) { Text("Slot 1") } + TextButton(onClick = { + pendingSlot.value = 2 + pickAmiiboLauncher.launch(arrayOf("application/octet-stream", "*/*")) + showAmiiboSlotDialog.value = false + }) { Text("Slot 2") } + TextButton(onClick = { + pendingSlot.value = 3 + pickAmiiboLauncher.launch(arrayOf("application/octet-stream", "*/*")) + showAmiiboSlotDialog.value = false + }) { Text("Slot 3") } + } + Row(modifier = Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { + pendingSlot.value = 4 + pickAmiiboLauncher.launch(arrayOf("application/octet-stream", "*/*")) + showAmiiboSlotDialog.value = false + }) { Text("Slot 4") } + TextButton(onClick = { + pendingSlot.value = 5 + pickAmiiboLauncher.launch(arrayOf("application/octet-stream", "*/*")) + showAmiiboSlotDialog.value = false + }) { Text("Slot 5") } + } + } + }, + confirmButton = { + TextButton(onClick = { showAmiiboSlotDialog.value = false }) { Text("Close") } + } + ) + } } if (viewModel.mainViewModel?.loadGameModel?.value != null) @@ -439,6 +584,21 @@ class HomeViews { contentDescription = "Run" ) } + + // Create shortcut + IconButton(onClick = { + val gm = viewModel.mainViewModel?.selected + if (gm != null) { + shortcutName.value = gm.titleName ?: "" + showShortcutDialog.value = true + } + }) { + Icon( + Icons.Filled.Add, + contentDescription = "Create Shortcut" + ) + } + val showAppMenu = remember { mutableStateOf(false) } Box { IconButton(onClick = { showAppMenu.value = true }) { @@ -500,11 +660,77 @@ class HomeViews { } ) - // --- Version badge bottom left above the entire content + // Shortcut dialog + if (showShortcutDialog.value) { + val gm = viewModel.mainViewModel?.selected + AlertDialog( + onDismissRequest = { showShortcutDialog.value = false }, + title = { Text("Create shortcut") }, + text = { + Column { + OutlinedTextField( + value = shortcutName.value, + onValueChange = { shortcutName.value = it }, + label = { Text("Name") }, + singleLine = true + ) + Text( + text = "Choose icon:", + modifier = Modifier.padding(top = 12.dp) + ) + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) { + TextButton(onClick = { + // Use app icon (from grid image) + if (gm != null && activity != null) { + val gameUri = resolveGameUri(gm) + if (gameUri != null) { + // Persist rights for the game file + ShortcutUtils.persistReadWrite(activity, gameUri) + + val bmp = decodeGameIcon(gm) + val label = shortcutName.value.ifBlank { gm.titleName ?: "Start Game" } + + ShortcutUtils.pinShortcutForGame( + activity = activity, + gameUri = gameUri, + label = label, + iconBitmap = bmp + ) { } + showShortcutDialog.value = false + } else { + showShortcutDialog.value = false + } + } else { + showShortcutDialog.value = false + } + }) { Text("App icon") } + + TextButton(onClick = { + // Pick a custom icon + pickImageLauncher.launch(arrayOf("image/*")) + showShortcutDialog.value = false + }) { Text("Custom icon") } + } + } + }, + confirmButton = { + TextButton(onClick = { showShortcutDialog.value = false }) { + Text("Close") + } + } + ) + } + + // Version badge at the bottom-left above all content VersionBadge( modifier = Modifier.align(Alignment.BottomStart) ) - } // End of box + } // End of Box } @OptIn(ExperimentalFoundationApi::class) diff --git a/src/LibKenjinx/Android/JniExportedMethods.cs b/src/LibKenjinx/Android/JniExportedMethods.cs index 9d64bdba2..f06e3ca6e 100644 --- a/src/LibKenjinx/Android/JniExportedMethods.cs +++ b/src/LibKenjinx/Android/JniExportedMethods.cs @@ -688,9 +688,35 @@ namespace LibKenjinx Logger.Error?.Print(LogClass.Application, $"deviceRecreateSwapchain failed: {ex}"); } } + + // ===== Amiibo JNI Exports ===== + [UnmanagedCallersOnly(EntryPoint = "amiiboLoadBin")] + public static bool JniAmiiboLoadBin(IntPtr dataPtr, int length) + { + if (dataPtr == IntPtr.Zero || length <= 0) return false; + try + { + byte[] buf = new byte[length]; + Marshal.Copy(dataPtr, buf, 0, length); + return AmiiboLoadFromBytes(buf); + } + catch + { + return false; + } + } + + [UnmanagedCallersOnly(EntryPoint = "amiiboClear")] + public static void JniAmiiboClear() + { + AmiiboClear(); + } + // ===== End Amiibo JNI Exports ===== + } internal static partial class Logcat + { [LibraryImport("liblog", StringMarshalling = StringMarshalling.Utf8)] private static partial void __android_log_print(LogLevel level, string? tag, string format, string args, IntPtr ptr); diff --git a/src/LibKenjinx/LibKenjinx.cs b/src/LibKenjinx/LibKenjinx.cs index 3f151cdbe..d05c4af55 100644 --- a/src/LibKenjinx/LibKenjinx.cs +++ b/src/LibKenjinx/LibKenjinx.cs @@ -20,6 +20,7 @@ using Ryujinx.Common.Logging.Targets; using Ryujinx.Common.Utilities; using Ryujinx.Graphics.GAL.Multithreading; using Ryujinx.HLE; +using Ryujinx.HLE.Kenjinx; using Ryujinx.HLE.FileSystem; using Ryujinx.HLE.HOS; using Ryujinx.HLE.HOS.Services.Account.Acc; @@ -690,9 +691,64 @@ namespace LibKenjinx uiHandler.SetResponse(isOkPressed, input); } } + + // ===== Amiibo Helpers (Kenjinx) ===== + public static bool AmiiboLoadFromBytes(byte[] data) + { + if (data == null || data.Length == 0) + { + Logger.Warning?.Print(LogClass.Service, "[Amiibo] Load aborted: empty data."); + return false; + } + + var dev = SwitchDevice?.EmulationContext; + if (dev == null) + { + Logger.Warning?.Print(LogClass.Service, "[Amiibo] Load aborted: no active EmulationContext."); + return false; + } + + try + { + var ok = AmiiboBridge.TryLoadVirtualAmiibo(dev, data, out string msg); + if (ok) + Logger.Info?.Print(LogClass.Service, $"[Amiibo] Loaded {data.Length} bytes. {msg}"); + else + Logger.Warning?.Print(LogClass.Service, $"[Amiibo] Injection failed. {msg}"); + return ok; + } + catch (Exception ex) + { + Logger.Error?.Print(LogClass.Service, $"[Amiibo] Exception: {ex}"); + return false; + } + } + + public static void AmiiboClear() + { + var dev = SwitchDevice?.EmulationContext; + if (dev == null) + { + Logger.Warning?.Print(LogClass.Service, "[Amiibo] Clear aborted: no active EmulationContext."); + return; + } + + try + { + AmiiboBridge.ClearVirtualAmiibo(dev); + Logger.Info?.Print(LogClass.Service, "[Amiibo] Cleared."); + } + catch (Exception ex) + { + Logger.Error?.Print(LogClass.Service, $"[Amiibo] Clear exception: {ex}"); + } + } + // ===== End Amiibo Helpers ===== + } public class SwitchDevice : IDisposable + { private readonly SystemVersion _firmwareVersion; public VirtualFileSystem VirtualFileSystem { get; set; } diff --git a/src/Ryujinx.HLE/HOS/Services/Nfc/Nfp/NfpManager/INfp.cs b/src/Ryujinx.HLE/HOS/Services/Nfc/Nfp/NfpManager/INfp.cs index 6c03b759a..69fa487f0 100644 --- a/src/Ryujinx.HLE/HOS/Services/Nfc/Nfp/NfpManager/INfp.cs +++ b/src/Ryujinx.HLE/HOS/Services/Nfc/Nfp/NfpManager/INfp.cs @@ -7,6 +7,9 @@ using Ryujinx.HLE.HOS.Services.Hid; using Ryujinx.HLE.HOS.Services.Hid.HidServer; using Ryujinx.HLE.HOS.Services.Nfc.Nfp.NfpManager; using Ryujinx.Horizon.Common; +using Ryujinx.HLE.Kenjinx; +using System.Security.Cryptography; +using Ryujinx.Common.Logging; using System; using System.Buffers.Binary; using System.Globalization; @@ -142,7 +145,26 @@ namespace Ryujinx.HLE.HOS.Services.Nfc.Nfp { context.Device.System.NfpDevices[i].State = NfpDeviceState.SearchingForTag; + if (KenjinxAmiiboShim.TryConsume(out var tagBytes)) + { + var dev = context.Device.System.NfpDevices[i]; + + if (TryGetFigureIdFrom1Dc(tagBytes, out var fid, out var raw)) + { + dev.AmiiboId = fid; + Logger.Info?.PrintMsg(LogClass.ServiceNfp, $"Kenjinx: raw@1DC={raw} → FigureID={fid}"); + } + else + { + dev.AmiiboId = MakeAmiiboIdFromBytes(tagBytes); + Logger.Info?.PrintMsg(LogClass.ServiceNfp, $"Kenjinx: FigureID={dev.AmiiboId} (fallback, keine 1DC-Bytes)"); + } + + dev.UseRandomUuid = false; + dev.State = NfpDeviceState.TagFound; + } break; + } } _cancelTokenSource = new CancellationTokenSource(); @@ -155,7 +177,30 @@ namespace Ryujinx.HLE.HOS.Services.Nfc.Nfp break; } + for (int d = 0; d < context.Device.System.NfpDevices.Count; d++) + { + var dev = context.Device.System.NfpDevices[d]; + + if (dev.State == NfpDeviceState.SearchingForTag && KenjinxAmiiboShim.TryConsume(out var tagBytesLoop)) + { + if (TryGetFigureIdFrom1Dc(tagBytesLoop, out var fid, out var raw)) + { + dev.AmiiboId = fid; + Logger.Info?.PrintMsg(LogClass.ServiceNfp, $"Kenjinx: raw@1DC={raw} → FigureID={fid}"); + } + else + { + dev.AmiiboId = MakeAmiiboIdFromBytes(tagBytesLoop); + Logger.Info?.PrintMsg(LogClass.ServiceNfp, $"Kenjinx: FigureID={dev.AmiiboId} (fallback, keine 1DC-Bytes)"); + } + + dev.UseRandomUuid = false; + dev.State = NfpDeviceState.TagFound; + } + } + for (int i = 0; i < context.Device.System.NfpDevices.Count; i++) + { if (context.Device.System.NfpDevices[i].State == NfpDeviceState.TagFound) { @@ -196,9 +241,54 @@ namespace Ryujinx.HLE.HOS.Services.Nfc.Nfp break; } } + + KenjinxAmiiboShim.Clear(); + return ResultCode.Success; } + private static bool TryGetFigureIdFrom1Dc(byte[] data, out string figureId, out string debugRaw) + { + figureId = string.Empty; + debugRaw = string.Empty; + + const int off = 0x1DC; + const int len = 8; + + if (data == null || data.Length < off + len) + { + return false; + } + + var s = data.AsSpan(off, len); + + debugRaw = $"{s[0]:X2}-{s[1]:X2}-{s[2]:X2}-{s[3]:X2}-{s[4]:X2}-{s[5]:X2}-{s[6]:X2}-{s[7]:X2}"; + + figureId = + $"{s[0]:x2}{s[1]:x2}{s[2]:x2}{s[3]:x2}{s[4]:x2}{s[5]:x2}{s[6]:x2}{s[7]:x2}"; + + return true; + } + + private static string MakeAmiiboIdFromBytes(byte[] data) + { + if (data == null || data.Length == 0) return "0000000000000000"; + + using var sha = SHA1.Create(); + var hash = sha.ComputeHash(data); // 20 Bytes + char[] buf = new char[16]; + for (int i = 0, k = 0; i < 8; i++) + { + byte b = hash[i]; + buf[k++] = GetHex((b >> 4) & 0xF); + buf[k++] = GetHex(b & 0xF); + } + return new string(buf); + } + + private static char GetHex(int v) => (char)(v < 10 ? ('0' + v) : ('a' + (v - 10))); + + [CommandCmif(5)] // Mount(bytes<8, 4>, u32, u32) public ResultCode Mount(ServiceCtx context) diff --git a/src/Ryujinx.HLE/HOS/Services/Nfc/Nfp/NfpManager/KenjinxAmiiboShim.cs b/src/Ryujinx.HLE/HOS/Services/Nfc/Nfp/NfpManager/KenjinxAmiiboShim.cs new file mode 100644 index 000000000..af4c9334a --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Nfc/Nfp/NfpManager/KenjinxAmiiboShim.cs @@ -0,0 +1,46 @@ +#nullable enable +using System; +using System.Runtime.CompilerServices; + +namespace Ryujinx.HLE.HOS.Services.Nfc.Nfp +{ + public static class KenjinxAmiiboShim + { + private static byte[]? s_tag; + + [MethodImpl(MethodImplOptions.NoInlining)] + public static bool InjectAmiibo(byte[] tagBytes) + { + if (tagBytes is null || tagBytes.Length == 0) return false; + s_tag = (byte[])tagBytes.Clone(); + System.Diagnostics.Debug.WriteLine($"[Kenjinx] KenjinxAmiiboShim.InjectAmiibo bytes={tagBytes.Length}"); + return true; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public static void ClearAmiibo() + { + s_tag = null; + System.Diagnostics.Debug.WriteLine("[Kenjinx] KenjinxAmiiboShim.ClearAmiibo"); + } + + public static bool TryConsume(out byte[] data) + { + if (s_tag is null) + { + data = Array.Empty(); + return false; + } + data = s_tag; + s_tag = null; + return true; + } + + public static void Clear() => ClearAmiibo(); + + public static bool HasInjectedAmiibo => s_tag is not null; + + public static ReadOnlySpan PeekInjectedAmiibo() + => s_tag is null ? ReadOnlySpan.Empty : new ReadOnlySpan(s_tag); + } +} diff --git a/src/Ryujinx.HLE/Kenjinx/AmiiboBridge.cs b/src/Ryujinx.HLE/Kenjinx/AmiiboBridge.cs new file mode 100644 index 000000000..f4a7442c3 --- /dev/null +++ b/src/Ryujinx.HLE/Kenjinx/AmiiboBridge.cs @@ -0,0 +1,148 @@ +#nullable enable +using System; +using System.Reflection; +using Ryujinx.HLE; + +namespace Ryujinx.HLE.Kenjinx +{ + public static class AmiiboBridge + { + public static bool TryLoadVirtualAmiibo(Switch device, byte[] data, out string message) + { + message = "NFP bridge not wired."; + if (device?.System is null) + { + message = "No System on Switch device."; + return false; + } + + var shimType = Type.GetType( + "Ryujinx.HLE.HOS.Services.Nfc.Nfp.KenjinxAmiiboShim, Ryujinx.HLE", + throwOnError: false + ); + if (shimType != null) + { + var m = shimType.GetMethod( + "InjectAmiibo", + BindingFlags.Public | BindingFlags.Static, + binder: null, + types: new[] { typeof(byte[]) }, + modifiers: null + ); + + if (m != null) + { + var ok = (bool)(m.Invoke(null, new object[] { data }) ?? false); + if (ok) + { + message = "Injected via KenjinxAmiiboShim.InjectAmiibo"; + return true; + } + } + } + + var nfpManagerType = Type.GetType( + "Ryujinx.HLE.HOS.Services.Nfc.Nfp.NfpManager, Ryujinx.HLE", + throwOnError: false + ); + if (nfpManagerType != null) + { + var m = nfpManagerType.GetMethod( + "InjectAmiibo", + BindingFlags.Public | BindingFlags.Static, + binder: null, + types: new[] { typeof(byte[]) }, + modifiers: null + ); + + if (m != null) + { + var ok = m.Invoke(null, new object[] { data }); + message = "Injected via NfpManager.InjectAmiibo(static)"; + return true; + } + } + + var sys = device.System; + var nfpMgr = + GetField(sys, "NfpManager") ?? + GetField(sys, "_nfpManager") ?? + GetField(sys, "NfcManager") ?? + GetField(sys, "_nfcManager"); + + if (nfpMgr != null) + { + var mi = + GetMethod(nfpMgr, "InjectAmiibo", new[] { typeof(byte[]) }) ?? + GetMethod(nfpMgr, "LoadAmiiboFromBytes", new[] { typeof(byte[]) }) ?? + GetMethod(nfpMgr, "LoadVirtualAmiibo", new[] { typeof(byte[]) }) ?? + GetMethod(nfpMgr, "ScanAmiiboFromBuffer", new[] { typeof(byte[]) }); + + if (mi != null) + { + mi.Invoke(nfpMgr, new object[] { data }); + message = $"Injected via {mi.DeclaringType?.Name}.{mi.Name}"; + return true; + } + } + + message = "No NFP manager API found. Add a fixed InjectAmiibo(byte[]) to your NFP layer (see TODO in AmiiboBridge comments)."; + return false; + } + + public static void ClearVirtualAmiibo(Switch device) + { + if (device?.System is null) return; + + var shimType = Type.GetType( + "Ryujinx.HLE.HOS.Services.Nfc.Nfp.KenjinxAmiiboShim, Ryujinx.HLE", + throwOnError: false + ); + shimType? + .GetMethod("ClearAmiibo", BindingFlags.Public | BindingFlags.Static, null, Type.EmptyTypes, null) + ?.Invoke(null, null); + + var nfpManagerType = Type.GetType( + "Ryujinx.HLE.HOS.Services.Nfc.Nfp.NfpManager, Ryujinx.HLE", + throwOnError: false + ); + nfpManagerType? + .GetMethod("ClearAmiibo", BindingFlags.Public | BindingFlags.Static, null, Type.EmptyTypes, null) + ?.Invoke(null, null); + + var sys = device.System; + var nfpMgr = + GetField(sys, "NfpManager") ?? + GetField(sys, "_nfpManager") ?? + GetField(sys, "NfcManager") ?? + GetField(sys, "_nfcManager"); + + if (nfpMgr != null) + { + var mi = + GetMethod(nfpMgr, "ClearAmiibo", Type.EmptyTypes) ?? + GetMethod(nfpMgr, "ResetVirtualAmiibo", Type.EmptyTypes); + + mi?.Invoke(nfpMgr, Array.Empty()); + } + } + + private static object? GetField(object target, string name) + { + var t = target.GetType(); + var f = t.GetField(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.FlattenHierarchy); + return f?.GetValue(target); + } + + private static MethodInfo? GetMethod(object target, string name, Type[]? sig) + { + var t = target.GetType(); + return t.GetMethod( + name, + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.FlattenHierarchy, + binder: null, + types: sig ?? Type.EmptyTypes, + modifiers: null); + } + } +}