mirror of
https://git.ryujinx.app/kenji-nx/ryujinx.git
synced 2025-12-16 13:37:03 +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"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
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
|
<uses-feature
|
||||||
android:name="android.hardware.audio.output"
|
android:name="android.hardware.audio.output"
|
||||||
android:required="true" />
|
android:required="true" />
|
||||||
|
|
@ -58,9 +67,17 @@
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
</intent-filter>
|
</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
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="org.kenjinx.android.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:grantUriPermissions="true">
|
android:grantUriPermissions="true">
|
||||||
<meta-data
|
<meta-data
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,10 @@ interface KenjinxNativeJna : Library {
|
||||||
fun deviceResize(width: Int, height: Int)
|
fun deviceResize(width: Int, height: Int)
|
||||||
// Set window handle after each query
|
// Set window handle after each query
|
||||||
fun deviceSetWindowHandle(handle: Long)
|
fun deviceSetWindowHandle(handle: Long)
|
||||||
|
// Amiibo
|
||||||
|
fun amiiboLoadBin(bytes: ByteArray, length: Int): Boolean
|
||||||
|
fun amiiboClear()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val jnaInstance: KenjinxNativeJna = Native.load(
|
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.viewmodels.GameModel
|
||||||
import org.kenjinx.android.views.MainView
|
import org.kenjinx.android.views.MainView
|
||||||
import android.content.pm.ActivityInfo
|
import android.content.pm.ActivityInfo
|
||||||
|
import android.content.res.Configuration
|
||||||
import android.hardware.display.DisplayManager
|
import android.hardware.display.DisplayManager
|
||||||
|
import android.net.Uri
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import java.io.File
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
|
||||||
class MainActivity : BaseActivity() {
|
class MainActivity : BaseActivity() {
|
||||||
private var physicalControllerManager: PhysicalControllerManager =
|
private var physicalControllerManager: PhysicalControllerManager =
|
||||||
|
|
@ -150,6 +155,11 @@ class MainActivity : BaseActivity() {
|
||||||
var AppPath: String = ""
|
var AppPath: String = ""
|
||||||
var StorageHelper: SimpleStorageHelper? = null
|
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
|
@JvmStatic
|
||||||
fun frameEnded() {
|
fun frameEnded() {
|
||||||
mainViewModel?.activity?.apply {
|
mainViewModel?.activity?.apply {
|
||||||
|
|
@ -316,19 +326,50 @@ class MainActivity : BaseActivity() {
|
||||||
private fun handleIntent() {
|
private fun handleIntent() {
|
||||||
when (storedIntent.action) {
|
when (storedIntent.action) {
|
||||||
Intent.ACTION_VIEW, "org.kenjinx.android.LAUNCH_GAME" -> {
|
Intent.ACTION_VIEW, "org.kenjinx.android.LAUNCH_GAME" -> {
|
||||||
val bootPath = storedIntent.getStringExtra("bootPath")
|
val bootPathExtra = storedIntent.getStringExtra(EXTRA_BOOT_PATH)
|
||||||
val forceNceAndPptc = storedIntent.getBooleanExtra("forceNceAndPptc", false)
|
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 chosenUri: Uri? = when {
|
||||||
val uri = bootPath.toUri()
|
!bootPathExtra.isNullOrEmpty() -> bootPathExtra.toUri()
|
||||||
val documentFile = DocumentFile.fromSingleUri(this, uri)
|
dataUri != null -> dataUri
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
if (documentFile != null) {
|
if (chosenUri != null) {
|
||||||
val gameModel = GameModel(documentFile, this)
|
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()
|
gameModel.getGameInfo()
|
||||||
mainViewModel?.loadGameModel?.value = gameModel
|
mainViewModel?.loadGameModel?.value = gameModel
|
||||||
mainViewModel?.bootPath?.value = "gameItem_${gameModel.titleName}"
|
mainViewModel?.bootPath?.value = "gameItem_${gameModel.titleName}"
|
||||||
mainViewModel?.forceNceAndPptc?.value = forceNceAndPptc
|
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) {}
|
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() {
|
fun shutdownAndRestart() {
|
||||||
val packageManager = packageManager
|
val packageManager = packageManager
|
||||||
val intent = packageManager.getLaunchIntentForPackage(packageName)
|
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
|
package org.kenjinx.android.viewmodels
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.net.Uri
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
|
|
@ -51,6 +52,13 @@ class MainViewModel(val activity: MainActivity) {
|
||||||
private var showLoading: MutableState<Boolean>? = null
|
private var showLoading: MutableState<Boolean>? = null
|
||||||
private var refreshUser: 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
|
var gameHost: GameHost? = null
|
||||||
set(value) {
|
set(value) {
|
||||||
field = value
|
field = value
|
||||||
|
|
@ -62,6 +70,12 @@ class MainViewModel(val activity: MainActivity) {
|
||||||
|
|
||||||
init {
|
init {
|
||||||
performanceManager = PerformanceManager(activity)
|
performanceManager = PerformanceManager(activity)
|
||||||
|
|
||||||
|
val prefs = PreferenceManager.getDefaultSharedPreferences(activity)
|
||||||
|
val saved = prefs.getString("defaultGameFolderUri", "") ?: ""
|
||||||
|
if (saved.isNotEmpty()) {
|
||||||
|
defaultGameFolderUri = Uri.parse(saved)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshFirmwareVersion() {
|
fun refreshFirmwareVersion() {
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,18 @@ class QuickSettings(val activity: Activity) {
|
||||||
var useNce: Boolean
|
var useNce: Boolean
|
||||||
var memoryConfiguration: MemoryConfiguration
|
var memoryConfiguration: MemoryConfiguration
|
||||||
var useVirtualController: Boolean
|
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 memoryManagerMode: MemoryManagerMode
|
||||||
var enableShaderCache: Boolean
|
var enableShaderCache: Boolean
|
||||||
var enableTextureRecompression: Boolean
|
var enableTextureRecompression: Boolean
|
||||||
|
|
@ -66,6 +78,18 @@ class QuickSettings(val activity: Activity) {
|
||||||
private var sharedPref: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(activity)
|
private var sharedPref: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(activity)
|
||||||
|
|
||||||
init {
|
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)
|
// --- Load alignment (Default: Sensor)
|
||||||
val oriValue = sharedPref.getInt("orientationPreference", ActivityInfo.SCREEN_ORIENTATION_SENSOR)
|
val oriValue = sharedPref.getInt("orientationPreference", ActivityInfo.SCREEN_ORIENTATION_SENSOR)
|
||||||
orientationPreference = OrientationPreference.fromValue(oriValue)
|
orientationPreference = OrientationPreference.fromValue(oriValue)
|
||||||
|
|
@ -112,6 +136,18 @@ class QuickSettings(val activity: Activity) {
|
||||||
|
|
||||||
fun save() {
|
fun save() {
|
||||||
sharedPref.edit {
|
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
|
// --- Save orientation
|
||||||
putInt("orientationPreference", orientationPreference.value)
|
putInt("orientationPreference", orientationPreference.value)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,9 @@ class SettingsViewModel(val activity: MainActivity) {
|
||||||
sharedPref.edit {
|
sharedPref.edit {
|
||||||
putString("gameFolder", p)
|
putString("gameFolder", p)
|
||||||
}
|
}
|
||||||
|
runCatching {
|
||||||
|
MainActivity.mainViewModel?.defaultGameFolderUri = folder.uri
|
||||||
|
}
|
||||||
activity.storageHelper!!.onFolderSelected = previousFolderCallback
|
activity.storageHelper!!.onFolderSelected = previousFolderCallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,8 @@ import org.kenjinx.android.viewmodels.VSyncMode
|
||||||
import org.kenjinx.android.widgets.SimpleAlertDialog
|
import org.kenjinx.android.widgets.SimpleAlertDialog
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
import android.net.Uri
|
||||||
|
import android.widget.Toast
|
||||||
|
|
||||||
class GameViews {
|
class GameViews {
|
||||||
companion object {
|
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) {
|
if (showStats.value) {
|
||||||
GameStats(mainViewModel)
|
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
|
package org.kenjinx.android.views
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
|
import android.net.Uri
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.Image
|
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.HomeViewModel
|
||||||
import org.kenjinx.android.viewmodels.QuickSettings
|
import org.kenjinx.android.viewmodels.QuickSettings
|
||||||
import org.kenjinx.android.widgets.SimpleAlertDialog
|
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 {
|
class HomeViews {
|
||||||
companion object {
|
companion object {
|
||||||
const val ListImageSize = 150
|
const val ListImageSize = 150
|
||||||
const val GridImageSize = 300
|
const val GridImageSize = 300
|
||||||
|
|
||||||
// --- small version badge bottom left
|
// Small version badge in the bottom-left corner
|
||||||
@Composable
|
@Composable
|
||||||
private fun VersionBadge(modifier: Modifier = Modifier) {
|
private fun VersionBadge(modifier: Modifier = Modifier) {
|
||||||
Text(
|
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)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun Home(
|
fun Home(
|
||||||
|
|
@ -124,7 +149,70 @@ class HomeViews {
|
||||||
var isFabVisible by remember { mutableStateOf(true) }
|
var isFabVisible by remember { mutableStateOf(true) }
|
||||||
val isNavigating = remember { mutableStateOf(false) }
|
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 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 {
|
val nestedScrollConnection = remember {
|
||||||
object : NestedScrollConnection {
|
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()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
|
@ -211,7 +299,7 @@ class HomeViews {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings
|
// Settings button
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (!isNavigating.value) {
|
if (!isNavigating.value) {
|
||||||
|
|
@ -268,6 +356,8 @@ class HomeViews {
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
AnimatedVisibility(visible = isFabVisible) {
|
AnimatedVisibility(visible = isFabVisible) {
|
||||||
|
// NEW: two FABs in a row: Refresh + Import Amiibo
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.requestReload()
|
viewModel.requestReload()
|
||||||
|
|
@ -278,6 +368,16 @@ class HomeViews {
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Default.Refresh, contentDescription = "refresh")
|
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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
floatingActionButtonPosition = FabPosition.End
|
floatingActionButtonPosition = FabPosition.End
|
||||||
|
|
@ -375,6 +475,51 @@ class HomeViews {
|
||||||
val name = viewModel.mainViewModel?.selected?.titleName ?: ""
|
val name = viewModel.mainViewModel?.selected?.titleName ?: ""
|
||||||
DlcViews.Main(titleId, name, openDlcDialog, canClose)
|
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)
|
if (viewModel.mainViewModel?.loadGameModel?.value != null)
|
||||||
|
|
@ -439,6 +584,21 @@ class HomeViews {
|
||||||
contentDescription = "Run"
|
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) }
|
val showAppMenu = remember { mutableStateOf(false) }
|
||||||
Box {
|
Box {
|
||||||
IconButton(onClick = { showAppMenu.value = true }) {
|
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(
|
VersionBadge(
|
||||||
modifier = Modifier.align(Alignment.BottomStart)
|
modifier = Modifier.align(Alignment.BottomStart)
|
||||||
)
|
)
|
||||||
} // End of box
|
} // End of Box
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
|
|
||||||
|
|
@ -688,9 +688,35 @@ namespace LibKenjinx
|
||||||
Logger.Error?.Print(LogClass.Application, $"deviceRecreateSwapchain failed: {ex}");
|
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
|
internal static partial class Logcat
|
||||||
|
|
||||||
{
|
{
|
||||||
[LibraryImport("liblog", StringMarshalling = StringMarshalling.Utf8)]
|
[LibraryImport("liblog", StringMarshalling = StringMarshalling.Utf8)]
|
||||||
private static partial void __android_log_print(LogLevel level, string? tag, string format, string args, IntPtr ptr);
|
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.Common.Utilities;
|
||||||
using Ryujinx.Graphics.GAL.Multithreading;
|
using Ryujinx.Graphics.GAL.Multithreading;
|
||||||
using Ryujinx.HLE;
|
using Ryujinx.HLE;
|
||||||
|
using Ryujinx.HLE.Kenjinx;
|
||||||
using Ryujinx.HLE.FileSystem;
|
using Ryujinx.HLE.FileSystem;
|
||||||
using Ryujinx.HLE.HOS;
|
using Ryujinx.HLE.HOS;
|
||||||
using Ryujinx.HLE.HOS.Services.Account.Acc;
|
using Ryujinx.HLE.HOS.Services.Account.Acc;
|
||||||
|
|
@ -690,9 +691,64 @@ namespace LibKenjinx
|
||||||
uiHandler.SetResponse(isOkPressed, input);
|
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
|
public class SwitchDevice : IDisposable
|
||||||
|
|
||||||
{
|
{
|
||||||
private readonly SystemVersion _firmwareVersion;
|
private readonly SystemVersion _firmwareVersion;
|
||||||
public VirtualFileSystem VirtualFileSystem { get; set; }
|
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.Hid.HidServer;
|
||||||
using Ryujinx.HLE.HOS.Services.Nfc.Nfp.NfpManager;
|
using Ryujinx.HLE.HOS.Services.Nfc.Nfp.NfpManager;
|
||||||
using Ryujinx.Horizon.Common;
|
using Ryujinx.Horizon.Common;
|
||||||
|
using Ryujinx.HLE.Kenjinx;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using Ryujinx.Common.Logging;
|
||||||
using System;
|
using System;
|
||||||
using System.Buffers.Binary;
|
using System.Buffers.Binary;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
|
@ -142,7 +145,26 @@ namespace Ryujinx.HLE.HOS.Services.Nfc.Nfp
|
||||||
{
|
{
|
||||||
context.Device.System.NfpDevices[i].State = NfpDeviceState.SearchingForTag;
|
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;
|
break;
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_cancelTokenSource = new CancellationTokenSource();
|
_cancelTokenSource = new CancellationTokenSource();
|
||||||
|
|
@ -155,7 +177,30 @@ namespace Ryujinx.HLE.HOS.Services.Nfc.Nfp
|
||||||
break;
|
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++)
|
for (int i = 0; i < context.Device.System.NfpDevices.Count; i++)
|
||||||
|
|
||||||
{
|
{
|
||||||
if (context.Device.System.NfpDevices[i].State == NfpDeviceState.TagFound)
|
if (context.Device.System.NfpDevices[i].State == NfpDeviceState.TagFound)
|
||||||
{
|
{
|
||||||
|
|
@ -196,9 +241,54 @@ namespace Ryujinx.HLE.HOS.Services.Nfc.Nfp
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
KenjinxAmiiboShim.Clear();
|
||||||
|
|
||||||
return ResultCode.Success;
|
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)]
|
[CommandCmif(5)]
|
||||||
// Mount(bytes<8, 4>, u32, u32)
|
// Mount(bytes<8, 4>, u32, u32)
|
||||||
public ResultCode Mount(ServiceCtx context)
|
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