mirror of
https://git.ryujinx.app/kenji-nx/ryujinx.git
synced 2025-12-13 04:37:02 +00:00
Merge branch 'libryujinx_bionic_Amiibo+Shortcut' into 'libryujinx_bionic'
Added Shortcut and Amiibo function See merge request kenji-nx/ryujinx!7
This commit is contained in:
commit
9b5a98e4d7
16 changed files with 1296 additions and 30 deletions
|
|
@ -2,6 +2,15 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<queries>
|
||||
<package android:name="org.kenjinx.android"/>
|
||||
|
||||
<intent>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.audio.output"
|
||||
android:required="true" />
|
||||
|
|
@ -58,9 +67,17 @@
|
|||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
-->
|
||||
|
||||
<activity
|
||||
android:name=".ShortcutWizardActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.KenjinxAndroid.Transparent"
|
||||
android:screenOrientation="unspecified"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden|uiMode" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="org.kenjinx.android.fileprovider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
|
|
|
|||
|
|
@ -95,6 +95,10 @@ interface KenjinxNativeJna : Library {
|
|||
fun deviceResize(width: Int, height: Int)
|
||||
// Set window handle after each query
|
||||
fun deviceSetWindowHandle(handle: Long)
|
||||
// Amiibo
|
||||
fun amiiboLoadBin(bytes: ByteArray, length: Int): Boolean
|
||||
fun amiiboClear()
|
||||
|
||||
}
|
||||
|
||||
val jnaInstance: KenjinxNativeJna = Native.load(
|
||||
|
|
|
|||
|
|
@ -29,8 +29,13 @@ import org.kenjinx.android.viewmodels.QuickSettings
|
|||
import org.kenjinx.android.viewmodels.GameModel
|
||||
import org.kenjinx.android.views.MainView
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.res.Configuration
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.net.Uri
|
||||
import android.view.Surface
|
||||
import androidx.preference.PreferenceManager
|
||||
import java.io.File
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
|
||||
class MainActivity : BaseActivity() {
|
||||
private var physicalControllerManager: PhysicalControllerManager =
|
||||
|
|
@ -150,6 +155,11 @@ class MainActivity : BaseActivity() {
|
|||
var AppPath: String = ""
|
||||
var StorageHelper: SimpleStorageHelper? = null
|
||||
|
||||
const val EXTRA_BOOT_PATH = "bootPath"
|
||||
const val EXTRA_FORCE_NCE_PPTC = "forceNceAndPptc"
|
||||
const val EXTRA_TITLE_ID = "titleId"
|
||||
const val EXTRA_TITLE_NAME = "titleName"
|
||||
|
||||
@JvmStatic
|
||||
fun frameEnded() {
|
||||
mainViewModel?.activity?.apply {
|
||||
|
|
@ -316,19 +326,50 @@ class MainActivity : BaseActivity() {
|
|||
private fun handleIntent() {
|
||||
when (storedIntent.action) {
|
||||
Intent.ACTION_VIEW, "org.kenjinx.android.LAUNCH_GAME" -> {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Boolean>? = null
|
||||
private var refreshUser: MutableState<Boolean>? = 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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -214,6 +214,9 @@ class SettingsViewModel(val activity: MainActivity) {
|
|||
sharedPref.edit {
|
||||
putString("gameFolder", p)
|
||||
}
|
||||
runCatching {
|
||||
MainActivity.mainViewModel?.defaultGameFolderUri = folder.uri
|
||||
}
|
||||
activity.storageHelper!!.onFolderSelected = previousFolderCallback
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<byte>();
|
||||
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<byte> PeekInjectedAmiibo()
|
||||
=> s_tag is null ? ReadOnlySpan<byte>.Empty : new ReadOnlySpan<byte>(s_tag);
|
||||
}
|
||||
}
|
||||
148
src/Ryujinx.HLE/Kenjinx/AmiiboBridge.cs
Normal file
148
src/Ryujinx.HLE/Kenjinx/AmiiboBridge.cs
Normal file
|
|
@ -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<object>());
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue