android: Add mandatory firmware and title.keys setup steps

- Downgrade Kotlin and Android plugin versions to ensure build compatibility.
- Add setup steps for title.keys installation and mandatory firmware setup.
- Persist auto-generated keys to improve emulator compatibility.
- Update key manager to write derived keys to filesystem.
- Implement UI handling for mandatory firmware installation checks.
This commit is contained in:
Zephyron 2025-03-13 16:04:11 +10:00
parent dad8859679
commit 1023125be5
8 changed files with 283 additions and 33 deletions

View file

@ -11,10 +11,10 @@ plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("kotlin-parcelize")
kotlin("plugin.serialization") version "2.1.20-RC2"
kotlin("plugin.serialization") version "1.9.20"
id("androidx.navigation.safeargs.kotlin")
id("org.jlleitschuh.gradle.ktlint") version "12.2.0"
id("com.github.triplet.play") version "3.12.1"
id("org.jlleitschuh.gradle.ktlint") version "11.4.0"
id("com.github.triplet.play") version "3.8.6"
}
/**
@ -203,7 +203,7 @@ tasks.getByPath("ktlintMainSourceSetCheck").doFirst { showFormatHelp.invoke() }
tasks.getByPath("loadKtlintReporters").dependsOn("ktlintReset")
ktlint {
version.set("0.49.1")
version.set("0.47.1")
android.set(true)
ignoreFailures.set(false)
disabledRules.set(
@ -228,24 +228,23 @@ play {
}
dependencies {
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.recyclerview:recyclerview:1.4.0")
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
implementation("androidx.fragment:fragment-ktx:1.8.6")
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("androidx.recyclerview:recyclerview:1.3.1")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.fragment:fragment-ktx:1.6.1")
implementation("androidx.documentfile:documentfile:1.0.1")
implementation("com.google.android.material:material:1.12.0")
implementation("com.google.android.material:material:1.9.0")
implementation("androidx.preference:preference-ktx:1.2.1")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7")
implementation("io.coil-kt:coil:2.7.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2")
implementation("io.coil-kt:coil:2.2.2")
implementation("androidx.core:core-splashscreen:1.0.1")
implementation("androidx.window:window:1.3.0")
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
implementation("androidx.window:window:1.2.0-beta03")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
implementation("androidx.navigation:navigation-fragment-ktx:2.8.8")
implementation("androidx.navigation:navigation-ui-ktx:2.8.8")
implementation("androidx.navigation:navigation-fragment-ktx:2.7.4")
implementation("androidx.navigation:navigation-ui-ktx:2.7.4")
implementation("info.debatty:java-string-similarity:2.0.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0")
}
fun runGitCommand(command: List<String>): String {

View file

@ -180,6 +180,62 @@ class SetupFragment : Fragment() {
}
)
)
// Add title.keys installation page
add(
SetupPage(
R.drawable.ic_key,
R.string.install_title_keys,
R.string.install_title_keys_description,
R.drawable.ic_add,
true,
R.string.select_keys,
{
titleKeyCallback = it
getTitleKey.launch(arrayOf("*/*"))
},
true,
R.string.install_title_keys_warning,
R.string.install_title_keys_warning_description,
R.string.install_title_keys_warning_help,
{
val file = File(DirectoryInitialization.userDirectory + "/keys/title.keys")
if (file.exists()) {
StepState.COMPLETE
} else {
StepState.INCOMPLETE
}
}
)
)
// Add firmware installation page (mandatory)
add(
SetupPage(
R.drawable.ic_key,
R.string.install_firmware,
R.string.install_firmware_description,
R.drawable.ic_add,
true,
R.string.select_firmware,
{
firmwareCallback = it
getFirmware.launch(arrayOf("application/zip"))
},
true,
R.string.install_firmware_warning,
R.string.install_firmware_warning_description,
R.string.install_firmware_warning_help,
{
if (NativeLibrary.isFirmwareAvailable()) {
StepState.COMPLETE
} else {
StepState.INCOMPLETE
}
}
)
)
add(
SetupPage(
R.drawable.ic_controller,
@ -268,6 +324,18 @@ class SetupFragment : Fragment() {
return@setOnClickListener
}
// Special handling for firmware page - don't allow skipping
if (currentPage.titleId == R.string.install_firmware && !NativeLibrary.isFirmwareAvailable()) {
SetupWarningDialogFragment.newInstance(
currentPage.warningTitleId,
currentPage.warningDescriptionId,
currentPage.warningHelpLinkId,
index,
allowSkip = false
).show(childFragmentManager, SetupWarningDialogFragment.TAG)
return@setOnClickListener
}
if (!hasBeenWarned[index]) {
SetupWarningDialogFragment.newInstance(
currentPage.warningTitleId,
@ -346,6 +414,30 @@ class SetupFragment : Fragment() {
}
}
private lateinit var titleKeyCallback: SetupCallback
val getTitleKey =
registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
if (result != null) {
mainActivity.processTitleKey(result)
titleKeyCallback.onStepCompleted()
}
}
private lateinit var firmwareCallback: SetupCallback
val getFirmware =
registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
if (result != null) {
mainActivity.getFirmware.launch(arrayOf("application/zip"))
binding.root.postDelayed({
if (NativeLibrary.isFirmwareAvailable()) {
firmwareCallback.onStepCompleted()
}
}, 1000)
}
}
private lateinit var gamesDirCallback: SetupCallback
val getGamesDirectory =

View file

@ -17,6 +17,7 @@ class SetupWarningDialogFragment : DialogFragment() {
private var descriptionId: Int = 0
private var helpLinkId: Int = 0
private var page: Int = 0
private var allowSkip: Boolean = true
private lateinit var setupFragment: SetupFragment
@ -26,17 +27,24 @@ class SetupWarningDialogFragment : DialogFragment() {
descriptionId = requireArguments().getInt(DESCRIPTION)
helpLinkId = requireArguments().getInt(HELP_LINK)
page = requireArguments().getInt(PAGE)
allowSkip = requireArguments().getBoolean(ALLOW_SKIP, true)
setupFragment = requireParentFragment() as SetupFragment
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val builder = MaterialAlertDialogBuilder(requireContext())
.setPositiveButton(R.string.warning_skip) { _: DialogInterface?, _: Int ->
if (allowSkip) {
builder.setPositiveButton(R.string.warning_skip) { _: DialogInterface?, _: Int ->
setupFragment.pageForward()
setupFragment.setPageWarned(page)
}
.setNegativeButton(R.string.warning_cancel, null)
builder.setNegativeButton(R.string.warning_cancel, null)
} else {
// For mandatory steps, only show an OK button that dismisses the dialog
builder.setPositiveButton(R.string.ok, null)
}
if (titleId != 0) {
builder.setTitle(titleId)
@ -48,7 +56,7 @@ class SetupWarningDialogFragment : DialogFragment() {
}
if (helpLinkId != 0) {
builder.setNeutralButton(R.string.warning_help) { _: DialogInterface?, _: Int ->
val helpLink = resources.getString(R.string.install_prod_keys_warning_help)
val helpLink = resources.getString(helpLinkId)
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(helpLink))
startActivity(intent)
}
@ -64,12 +72,14 @@ class SetupWarningDialogFragment : DialogFragment() {
private const val DESCRIPTION = "Description"
private const val HELP_LINK = "HelpLink"
private const val PAGE = "Page"
private const val ALLOW_SKIP = "AllowSkip"
fun newInstance(
titleId: Int,
descriptionId: Int,
helpLinkId: Int,
page: Int
page: Int,
allowSkip: Boolean = true
): SetupWarningDialogFragment {
val dialog = SetupWarningDialogFragment()
val bundle = Bundle()
@ -78,6 +88,7 @@ class SetupWarningDialogFragment : DialogFragment() {
putInt(DESCRIPTION, descriptionId)
putInt(HELP_LINK, helpLinkId)
putInt(PAGE, page)
putBoolean(ALLOW_SKIP, allowSkip)
}
dialog.arguments = bundle
return dialog

View file

@ -377,6 +377,57 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
return false
}
val getTitleKey =
registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
if (result != null) {
processTitleKey(result)
}
}
fun processTitleKey(result: Uri): Boolean {
if (FileUtil.getExtension(result) != "keys") {
MessageDialogFragment.newInstance(
this,
titleId = R.string.reading_keys_failure,
descriptionId = R.string.install_title_keys_failure_extension_description
).show(supportFragmentManager, MessageDialogFragment.TAG)
return false
}
contentResolver.takePersistableUriPermission(
result,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
val dstPath = DirectoryInitialization.userDirectory + "/keys/"
if (FileUtil.copyUriToInternalStorage(
result,
dstPath,
"title.keys"
) != null
) {
if (NativeLibrary.reloadKeys()) {
Toast.makeText(
applicationContext,
R.string.install_keys_success,
Toast.LENGTH_SHORT
).show()
homeViewModel.setCheckKeys(true)
gamesViewModel.reloadGames(true)
return true
} else {
MessageDialogFragment.newInstance(
this,
titleId = R.string.invalid_keys_error,
descriptionId = R.string.install_keys_failure_description,
helpLinkId = R.string.dumping_keys_quickstart_link
).show(supportFragmentManager, MessageDialogFragment.TAG)
return false
}
}
return false
}
val getFirmware =
registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
if (result == null) {

View file

@ -108,11 +108,15 @@
<string name="import_saves">Import</string>
<string name="export_saves">Export</string>
<string name="install_firmware">Install firmware</string>
<string name="install_firmware_description">Firmware must be in a ZIP archive and is needed to boot some games</string>
<string name="install_firmware_description">Required for emulation of system features</string>
<string name="install_firmware_warning">Firmware installation is mandatory</string>
<string name="install_firmware_warning_description">Firmware is required for proper emulation. You must install firmware to continue.</string>
<string name="install_firmware_warning_help">https://citron-emu.org/help/quickstart/#dumping-system-firmware</string>
<string name="firmware_installing">Installing firmware</string>
<string name="firmware_installed_success">Firmware installed successfully</string>
<string name="firmware_installed_failure">Firmware installation failed</string>
<string name="firmware_installed_failure_description">Make sure the firmware nca files are at the root of the zip and try again.</string>
<string name="firmware_installed_success">Firmware successfully installed</string>
<string name="firmware_installed_failure">Failed to install firmware</string>
<string name="firmware_installed_failure_description">The selected file is not a valid firmware archive or is corrupt.</string>
<string name="select_firmware">Select Firmware</string>
<string name="share_log">Share debug logs</string>
<string name="share_log_description">Share citron\'s log file to debug issues</string>
<string name="share_log_missing">No log file found</string>
@ -172,6 +176,14 @@
<string name="cabinet_restorer">Restorer</string>
<string name="cabinet_formatter">Formatter</string>
<!-- Title keys strings -->
<string name="install_title_keys">Install title.keys</string>
<string name="install_title_keys_description">Required for additional game compatibility</string>
<string name="install_title_keys_warning">Skip adding title keys?</string>
<string name="install_title_keys_warning_description">Title keys may be required for some games to function properly.</string>
<string name="install_title_keys_warning_help">https://citron-emu.org/</string>
<string name="install_title_keys_failure_extension_description">Verify your title keys file has a .keys extension and try again.</string>
<!-- About screen strings -->
<string name="gaia_is_not_real">Gaia isn\'t real</string>
<string name="copied_to_clipboard">Copied to clipboard</string>

View file

@ -6,7 +6,7 @@
plugins {
id("com.android.application") version "8.9.0" apply false
id("com.android.library") version "8.9.0" apply false
id("org.jetbrains.kotlin.android") version "2.1.20-RC2" apply false
id("org.jetbrains.kotlin.android") version "1.9.20" apply false
}
tasks.register("clean").configure {
@ -18,6 +18,6 @@ buildscript {
google()
}
dependencies {
classpath("androidx.navigation:navigation-safe-args-gradle-plugin:2.8.8")
classpath("androidx.navigation:navigation-safe-args-gradle-plugin:2.6.0")
}
}

View file

@ -1,5 +1,4 @@
// SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project
// SPDX-FileCopyrightText: Copyright 2025 citron Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#include <algorithm>
@ -649,13 +648,17 @@ void KeyManager::ReloadKeys() {
if (Settings::values.use_dev_keys) {
dev_mode = true;
LoadFromFile(citron_keys_dir / "dev.keys_autogenerated", false);
LoadFromFile(citron_keys_dir / "dev.keys", false);
} else {
dev_mode = false;
LoadFromFile(citron_keys_dir / "prod.keys_autogenerated", false);
LoadFromFile(citron_keys_dir / "prod.keys", false);
}
LoadFromFile(citron_keys_dir / "title.keys_autogenerated", true);
LoadFromFile(citron_keys_dir / "title.keys", true);
LoadFromFile(citron_keys_dir / "console.keys_autogenerated", false);
LoadFromFile(citron_keys_dir / "console.keys", false);
}
@ -844,15 +847,87 @@ Key256 KeyManager::GetBISKey(u8 partition_id) const {
template <size_t Size>
void KeyManager::WriteKeyToFile(KeyCategory category, std::string_view keyname,
const std::array<u8, Size>& key) {
// Function is now a no-op - keys are no longer written to autogenerated files
const auto citron_keys_dir = Common::FS::GetCitronPath(Common::FS::CitronPath::KeysDir);
std::string filename = "title.keys_autogenerated";
if (category == KeyCategory::Standard) {
filename = dev_mode ? "dev.keys_autogenerated" : "prod.keys_autogenerated";
} else if (category == KeyCategory::Console) {
filename = "console.keys_autogenerated";
}
const auto path = citron_keys_dir / filename;
const auto add_info_text = !Common::FS::Exists(path);
Common::FS::IOFile file{path, Common::FS::FileAccessMode::Append,
Common::FS::FileType::TextFile};
if (!file.IsOpen()) {
return;
}
if (add_info_text) {
void(file.WriteString(
"# This file is autogenerated by Citron\n"
"# It serves to store keys that were automatically generated from the normal keys\n"
"# If you are experiencing issues involving keys, it may help to delete this file\n"));
}
void(file.WriteString(fmt::format("\n{} = {}", keyname, Common::HexToString(key))));
LoadFromFile(path, category == KeyCategory::Title);
}
void KeyManager::SetKey(S128KeyType id, Key128 key, u64 field1, u64 field2) {
if (s128_keys.find({id, field1, field2}) != s128_keys.end() || key == Key128{}) {
return;
}
if (id == S128KeyType::Titlekey) {
Key128 rights_id;
std::memcpy(rights_id.data(), &field2, sizeof(u64));
std::memcpy(rights_id.data() + sizeof(u64), &field1, sizeof(u64));
WriteKeyToFile(KeyCategory::Title, Common::HexToString(rights_id), key);
}
auto category = KeyCategory::Standard;
if (id == S128KeyType::Keyblob || id == S128KeyType::KeyblobMAC || id == S128KeyType::TSEC ||
id == S128KeyType::SecureBoot || id == S128KeyType::SDSeed || id == S128KeyType::BIS) {
category = KeyCategory::Console;
}
const auto iter2 = std::find_if(
s128_file_id.begin(), s128_file_id.end(), [&id, &field1, &field2](const auto& elem) {
return std::tie(elem.second.type, elem.second.field1, elem.second.field2) ==
std::tie(id, field1, field2);
});
if (iter2 != s128_file_id.end()) {
WriteKeyToFile(category, iter2->first, key);
}
// Variable cases
if (id == S128KeyType::KeyArea) {
static constexpr std::array<const char*, 3> kak_names = {
"key_area_key_application_{:02X}",
"key_area_key_ocean_{:02X}",
"key_area_key_system_{:02X}",
};
WriteKeyToFile(category, fmt::format(fmt::runtime(kak_names.at(field2)), field1), key);
} else if (id == S128KeyType::Master) {
WriteKeyToFile(category, fmt::format("master_key_{:02X}", field1), key);
} else if (id == S128KeyType::Package1) {
WriteKeyToFile(category, fmt::format("package1_key_{:02X}", field1), key);
} else if (id == S128KeyType::Package2) {
WriteKeyToFile(category, fmt::format("package2_key_{:02X}", field1), key);
} else if (id == S128KeyType::Titlekek) {
WriteKeyToFile(category, fmt::format("titlekek_{:02X}", field1), key);
} else if (id == S128KeyType::Keyblob) {
WriteKeyToFile(category, fmt::format("keyblob_key_{:02X}", field1), key);
} else if (id == S128KeyType::KeyblobMAC) {
WriteKeyToFile(category, fmt::format("keyblob_mac_key_{:02X}", field1), key);
} else if (id == S128KeyType::Source && field1 == static_cast<u64>(SourceKeyType::Keyblob)) {
WriteKeyToFile(category, fmt::format("keyblob_key_source_{:02X}", field2), key);
}
// Store the key in memory but don't write to file
s128_keys[{id, field1, field2}] = key;
}
@ -860,8 +935,14 @@ void KeyManager::SetKey(S256KeyType id, Key256 key, u64 field1, u64 field2) {
if (s256_keys.find({id, field1, field2}) != s256_keys.end() || key == Key256{}) {
return;
}
// Store the key in memory but don't write to file
const auto iter = std::find_if(
s256_file_id.begin(), s256_file_id.end(), [&id, &field1, &field2](const auto& elem) {
return std::tie(elem.second.type, elem.second.field1, elem.second.field2) ==
std::tie(id, field1, field2);
});
if (iter != s256_file_id.end()) {
WriteKeyToFile(KeyCategory::Standard, iter->first, key);
}
s256_keys[{id, field1, field2}] = key;
}
@ -971,6 +1052,8 @@ void KeyManager::DeriveBase() {
// Decrypt keyblob
if (keyblobs[i] == std::array<u8, 0x90>{}) {
keyblobs[i] = DecryptKeyblob(encrypted_keyblobs[i], key);
WriteKeyToFile<0x90>(KeyCategory::Console, fmt::format("keyblob_{:02X}", i),
keyblobs[i]);
}
Key128 package1;
@ -1100,6 +1183,7 @@ void KeyManager::DeriveETicket(PartitionDataManager& data,
data.DecryptProdInfo(GetBISKey(0));
eticket_extended_kek = data.GetETicketExtendedKek();
WriteKeyToFile(KeyCategory::Console, "eticket_extended_kek", eticket_extended_kek);
DeriveETicketRSAKey();
PopulateTickets();
}
@ -1177,6 +1261,8 @@ void KeyManager::PopulateFromPartitionData(PartitionDataManager& data) {
continue;
}
encrypted_keyblobs[i] = data.GetEncryptedKeyblob(i);
WriteKeyToFile<0xB0>(KeyCategory::Console, fmt::format("encrypted_keyblob_{:02X}", i),
encrypted_keyblobs[i]);
}
SetKeyWrapped(S128KeyType::Source, data.GetPackage2KeySource(),

View file

@ -1,5 +1,4 @@
// SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project
// SPDX-FileCopyrightText: Copyright 2025 citron Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once