NCA decryption (#99)

* NCA decryption
* Remove unnecessary new lines
* Remove loader error dialog
* Always show ROMs
* Address CRs
* Add subtitle padding in grid mode
This commit is contained in:
Willi Ye 2020-09-14 15:53:40 +02:00 committed by GitHub
parent 65019375ca
commit 4076d84efc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 1154 additions and 439 deletions

View File

@ -24,6 +24,8 @@ include_directories("libraries/oboe/include")
include_directories("libraries/vkhpp/include") include_directories("libraries/vkhpp/include")
set(CMAKE_POLICY_DEFAULT_CMP0048 NEW) set(CMAKE_POLICY_DEFAULT_CMP0048 NEW)
find_package(mbedtls REQUIRED CONFIG)
include_directories(${source_DIR}/skyline) include_directories(${source_DIR}/skyline)
add_library(skyline SHARED add_library(skyline SHARED
@ -38,6 +40,8 @@ add_library(skyline SHARED
${source_DIR}/skyline/audio/track.cpp ${source_DIR}/skyline/audio/track.cpp
${source_DIR}/skyline/audio/resampler.cpp ${source_DIR}/skyline/audio/resampler.cpp
${source_DIR}/skyline/audio/adpcm_decoder.cpp ${source_DIR}/skyline/audio/adpcm_decoder.cpp
${source_DIR}/skyline/crypto/aes_cipher.cpp
${source_DIR}/skyline/crypto/key_store.cpp
${source_DIR}/skyline/gpu.cpp ${source_DIR}/skyline/gpu.cpp
${source_DIR}/skyline/gpu/macro_interpreter.cpp ${source_DIR}/skyline/gpu/macro_interpreter.cpp
${source_DIR}/skyline/gpu/memory_manager.cpp ${source_DIR}/skyline/gpu/memory_manager.cpp
@ -144,12 +148,13 @@ add_library(skyline SHARED
${source_DIR}/skyline/services/prepo/IPrepoService.cpp ${source_DIR}/skyline/services/prepo/IPrepoService.cpp
${source_DIR}/skyline/vfs/os_filesystem.cpp ${source_DIR}/skyline/vfs/os_filesystem.cpp
${source_DIR}/skyline/vfs/partition_filesystem.cpp ${source_DIR}/skyline/vfs/partition_filesystem.cpp
${source_DIR}/skyline/vfs/ctr_encrypted_backing.cpp
${source_DIR}/skyline/vfs/rom_filesystem.cpp ${source_DIR}/skyline/vfs/rom_filesystem.cpp
${source_DIR}/skyline/vfs/os_backing.cpp ${source_DIR}/skyline/vfs/os_backing.cpp
${source_DIR}/skyline/vfs/nacp.cpp ${source_DIR}/skyline/vfs/nacp.cpp
${source_DIR}/skyline/vfs/nca.cpp ${source_DIR}/skyline/vfs/nca.cpp
) )
target_link_libraries(skyline vulkan android fmt tinyxml2 oboe lz4_static) target_link_libraries(skyline vulkan android fmt tinyxml2 oboe lz4_static mbedtls::mbedcrypto)
set(CMAKE_CXX17_EXTENSION_COMPILE_OPTION "-std=c++2a") set(CMAKE_CXX17_EXTENSION_COMPILE_OPTION "-std=c++2a")
target_compile_options(skyline PRIVATE -Wno-c++17-extensions -Wall -Wno-reorder -Wno-missing-braces -Wno-unused-variable -Wno-unused-private-field) target_compile_options(skyline PRIVATE -Wno-c++17-extensions -Wall -Wno-reorder -Wno-missing-braces -Wno-unused-variable -Wno-unused-private-field)

View File

@ -40,6 +40,9 @@ android {
shrinkResources false shrinkResources false
} }
} }
androidExtensions {
experimental = true
}
externalNativeBuild { externalNativeBuild {
cmake { cmake {
version "3.10.2+" version "3.10.2+"
@ -63,7 +66,11 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.documentfile:documentfile:1.0.1' implementation 'androidx.documentfile:documentfile:1.0.1'
implementation 'info.debatty:java-string-similarity:1.2.1' implementation 'info.debatty:java-string-similarity:1.2.1'
implementation(name: 'mbedtls', ext: 'aar')
} }
repositories { repositories {
mavenCentral() mavenCentral()
flatDir {
dirs 'libraries'
}
} }

BIN
app/libraries/mbedtls.aar Normal file

Binary file not shown.

View File

@ -1,9 +1,3 @@
# Skyline Proguard Rules # Skyline Proguard Rules
# For more details, see # For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html # http://developer.android.com/guide/developing/tools/proguard.html
-keep class emu.skyline.loader.AppEntry {
void writeObject(java.io.ObjectOutputStream);
void readObject(java.io.ObjectInputStream);
}
-keep class emu.skyline.GameActivity { *; }

View File

@ -3,7 +3,6 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
package="emu.skyline"> package="emu.skyline">
<uses-permission android:name="android.permission.READ_INTERNAL_STORAGE" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-feature <uses-feature
@ -47,6 +46,11 @@
android:name="android.support.PARENT_ACTIVITY" android:name="android.support.PARENT_ACTIVITY"
android:value="emu.skyline.SettingsActivity" /> android:value="emu.skyline.SettingsActivity" />
</activity> </activity>
<activity android:name="emu.skyline.preference.FileActivity">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value="emu.skyline.SettingsActivity" />
</activity>
<activity <activity
android:name="emu.skyline.input.ControllerActivity" android:name="emu.skyline.input.ControllerActivity"
android:exported="true"> android:exported="true">

View File

@ -1,6 +1,8 @@
// SPDX-License-Identifier: MPL-2.0 // SPDX-License-Identifier: MPL-2.0
// Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/) // Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/)
#include "skyline/crypto/key_store.h"
#include "skyline/vfs/nca.h"
#include "skyline/vfs/os_backing.h" #include "skyline/vfs/os_backing.h"
#include "skyline/loader/nro.h" #include "skyline/loader/nro.h"
#include "skyline/loader/nso.h" #include "skyline/loader/nso.h"
@ -8,54 +10,53 @@
#include "skyline/loader/nsp.h" #include "skyline/loader/nsp.h"
#include "skyline/jvm.h" #include "skyline/jvm.h"
extern "C" JNIEXPORT jlong JNICALL Java_emu_skyline_loader_RomFile_initialize(JNIEnv *env, jobject thiz, jint jformat, jint fd) { extern "C" JNIEXPORT jint JNICALL Java_emu_skyline_loader_RomFile_populate(JNIEnv *env, jobject thiz, jint jformat, jint fd, jstring appFilesPathJstring) {
skyline::loader::RomFormat format = static_cast<skyline::loader::RomFormat>(jformat); skyline::loader::RomFormat format{static_cast<skyline::loader::RomFormat>(jformat)};
auto appFilesPath{env->GetStringUTFChars(appFilesPathJstring, nullptr)};
auto keyStore{std::make_shared<skyline::crypto::KeyStore>(appFilesPath)};
env->ReleaseStringUTFChars(appFilesPathJstring, appFilesPath);
std::unique_ptr<skyline::loader::Loader> loader;
try { try {
auto backing = std::make_shared<skyline::vfs::OsBacking>(fd); auto backing{std::make_shared<skyline::vfs::OsBacking>(fd)};
switch (format) { switch (format) {
case skyline::loader::RomFormat::NRO: case skyline::loader::RomFormat::NRO:
return reinterpret_cast<jlong>(new skyline::loader::NroLoader(backing)); loader = std::make_unique<skyline::loader::NroLoader>(backing);
break;
case skyline::loader::RomFormat::NSO: case skyline::loader::RomFormat::NSO:
return reinterpret_cast<jlong>(new skyline::loader::NsoLoader(backing)); loader = std::make_unique<skyline::loader::NsoLoader>(backing);
break;
case skyline::loader::RomFormat::NCA: case skyline::loader::RomFormat::NCA:
return reinterpret_cast<jlong>(new skyline::loader::NcaLoader(backing)); loader = std::make_unique<skyline::loader::NcaLoader>(backing, keyStore);
break;
case skyline::loader::RomFormat::NSP: case skyline::loader::RomFormat::NSP:
return reinterpret_cast<jlong>(new skyline::loader::NspLoader(backing)); loader = std::make_unique<skyline::loader::NspLoader>(backing, keyStore);
break;
default: default:
return 0; return static_cast<jint>(skyline::loader::LoaderResult::ParsingError);
} }
} catch (const skyline::loader::loader_exception &e) {
return static_cast<jint>(e.error);
} catch (const std::exception &e) { } catch (const std::exception &e) {
return 0; return static_cast<jint>(skyline::loader::LoaderResult::ParsingError);
}
} }
extern "C" JNIEXPORT jboolean JNICALL Java_emu_skyline_loader_RomFile_hasAssets(JNIEnv *env, jobject thiz, jlong instance) { jclass clazz{env->GetObjectClass(thiz)};
return reinterpret_cast<skyline::loader::Loader *>(instance)->nacp != nullptr; jfieldID applicationNameField{env->GetFieldID(clazz, "applicationName", "Ljava/lang/String;")};
jfieldID applicationAuthorField{env->GetFieldID(clazz, "applicationAuthor", "Ljava/lang/String;")};
jfieldID rawIconField{env->GetFieldID(clazz, "rawIcon", "[B")};
if (loader->nacp) {
env->SetObjectField(thiz, applicationNameField, env->NewStringUTF(loader->nacp->applicationName.c_str()));
env->SetObjectField(thiz, applicationAuthorField, env->NewStringUTF(loader->nacp->applicationPublisher.c_str()));
auto icon{loader->GetIcon()};
jbyteArray iconByteArray{env->NewByteArray(icon.size())};
env->SetByteArrayRegion(iconByteArray, 0, icon.size(), reinterpret_cast<const jbyte *>(icon.data()));
env->SetObjectField(thiz, rawIconField, iconByteArray);
} }
extern "C" JNIEXPORT jbyteArray JNICALL Java_emu_skyline_loader_RomFile_getIcon(JNIEnv *env, jobject thiz, jlong instance) { return static_cast<jint>(skyline::loader::LoaderResult::Success);
std::vector<skyline::u8> buffer = reinterpret_cast<skyline::loader::Loader *>(instance)->GetIcon();
jbyteArray result = env->NewByteArray(buffer.size());
env->SetByteArrayRegion(result, 0, buffer.size(), reinterpret_cast<const jbyte *>(buffer.data()));
return result;
}
extern "C" JNIEXPORT jstring JNICALL Java_emu_skyline_loader_RomFile_getApplicationName(JNIEnv *env, jobject thiz, jlong instance) {
std::string applicationName = reinterpret_cast<skyline::loader::Loader *>(instance)->nacp->applicationName;
return env->NewStringUTF(applicationName.c_str());
}
extern "C" JNIEXPORT jstring JNICALL Java_emu_skyline_loader_RomFile_getApplicationPublisher(JNIEnv *env, jobject thiz, jlong instance) {
std::string applicationPublisher = reinterpret_cast<skyline::loader::Loader *>(instance)->nacp->applicationPublisher;
return env->NewStringUTF(applicationPublisher.c_str());
}
extern "C" JNIEXPORT void JNICALL Java_emu_skyline_loader_RomFile_destroy(JNIEnv *env, jobject thiz, jlong instance) {
delete reinterpret_cast<skyline::loader::NroLoader *>(instance);
} }

View File

@ -62,6 +62,19 @@ namespace skyline {
constexpr u16 DockedResolutionH = 1080; //!< The height component of the docked resolution constexpr u16 DockedResolutionH = 1080; //!< The height component of the docked resolution
// Time // Time
constexpr u64 NsInSecond = 1000000000; //!< This is the amount of nanoseconds in a second constexpr u64 NsInSecond = 1000000000; //!< This is the amount of nanoseconds in a second
}
/**
* @brief This is a std::runtime_error with libfmt formatting
*/
class exception : public std::runtime_error {
public:
/**
* @param formatStr The exception string to be written, with libfmt formatting
* @param args The arguments based on format_str
*/
template<typename S, typename... Args>
inline exception(const S &formatStr, Args &&... args) : runtime_error(fmt::format(formatStr, args...)) {}
}; };
namespace util { namespace util {
@ -159,6 +172,28 @@ namespace skyline {
return object; return object;
} }
constexpr u8 HexDigitToByte(char digit) {
if (digit >= '0' && digit <= '9')
return digit - '0';
else if (digit >= 'a' && digit <= 'f')
return digit - 'a' + 10;
else if (digit >= 'A' && digit <= 'F')
return digit - 'A' + 10;
throw exception(fmt::format("Invalid hex char {}", digit));
}
template<size_t Size>
constexpr std::array<u8, Size> HexStringToArray(const std::string_view &hexString) {
if (hexString.size() != Size * 2)
throw exception("Invalid size");
std::array<u8, Size> result;
for (size_t i{}; i < Size; ++i) {
size_t hexStrIndex{i * 2};
result[i] = (HexDigitToByte(hexString[hexStrIndex]) << 4) | HexDigitToByte(hexString[hexStrIndex + 1]);
}
return result;
}
} }
/** /**
@ -350,19 +385,6 @@ namespace skyline {
void List(const std::shared_ptr<Logger> &logger); void List(const std::shared_ptr<Logger> &logger);
}; };
/**
* @brief This is a std::runtime_error with libfmt formatting
*/
class exception : public std::runtime_error {
public:
/**
* @param formatStr The exception string to be written, with libfmt formatting
* @param args The arguments based on format_str
*/
template<typename S, typename... Args>
inline exception(const S &formatStr, Args &&... args) : runtime_error(fmt::format(formatStr, args...)) {}
};
class NCE; class NCE;
class JvmManager; class JvmManager;
namespace gpu { namespace gpu {

View File

@ -0,0 +1,71 @@
// SPDX-License-Identifier: MPL-2.0
// Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/)
#include "aes_cipher.h"
namespace skyline::crypto {
AesCipher::AesCipher(std::span<u8> key, mbedtls_cipher_type_t type) {
mbedtls_cipher_init(&decryptContext);
if (mbedtls_cipher_setup(&decryptContext, mbedtls_cipher_info_from_type(type)) != 0)
throw exception("Failed to setup decryption context");
if (mbedtls_cipher_setkey(&decryptContext, key.data(), key.size() * 8, MBEDTLS_DECRYPT) != 0)
throw exception("Failed to set key for decryption context");
}
AesCipher::~AesCipher() {
mbedtls_cipher_free(&decryptContext);
}
void AesCipher::SetIV(const std::array<u8, 0x10> &iv) {
if (mbedtls_cipher_set_iv(&decryptContext, iv.data(), iv.size()) != 0)
throw exception("Failed to set IV for decryption context");
}
void AesCipher::Decrypt(u8 *destination, u8 *source, size_t size) {
std::optional<std::vector<u8>> buf{};
u8 *targetDestination = [&]() {
if (destination == source) {
if (size > maxBufferSize) {
buf.emplace(size);
return buf->data();
} else {
if (size > buffer.size())
buffer.resize(size);
return buffer.data();
}
}
return destination;
}();
mbedtls_cipher_reset(&decryptContext);
size_t outputSize{};
if (mbedtls_cipher_get_cipher_mode(&decryptContext) == MBEDTLS_MODE_XTS) {
mbedtls_cipher_update(&decryptContext, source, size, targetDestination, &outputSize);
} else {
u32 blockSize{mbedtls_cipher_get_block_size(&decryptContext)};
for (size_t offset{}; offset < size; offset += blockSize) {
size_t length{size - offset > blockSize ? blockSize : size - offset};
mbedtls_cipher_update(&decryptContext, source + offset, length, targetDestination + offset, &outputSize);
}
}
if (buf)
std::memcpy(destination, buf->data(), size);
else if (source == destination)
std::memcpy(destination, buffer.data(), size);
}
void AesCipher::XtsDecrypt(u8 *destination, u8 *source, size_t size, size_t sector, size_t sectorSize) {
if (size % sectorSize)
throw exception("Size must be multiple of sector size");
for (size_t i{}; i < size; i += sectorSize) {
SetIV(GetTweak(sector++));
Decrypt(destination + i, source + i, sectorSize);
}
}
}

View File

@ -0,0 +1,73 @@
// SPDX-License-Identifier: MPL-2.0
// Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/)
#pragma once
#include <array>
#include <span>
#include <mbedtls/cipher.h>
#include <common.h>
namespace skyline::crypto {
/**
* @brief Wrapper for mbedtls for AES decryption using a cipher
*/
class AesCipher {
private:
mbedtls_cipher_context_t decryptContext;
/**
* @brief Buffer should grow bigger than 1 MiB
*/
static constexpr size_t maxBufferSize = 1024 * 1024;
/**
* @brief Buffer declared as class variable to avoid constant memory allocation
*/
std::vector<u8> buffer;
/**
* @brief Calculates IV for XTS, basically just big to little endian conversion.
*/
inline static std::array<u8, 0x10> GetTweak(size_t sector) {
std::array<u8, 0x10> tweak{};
size_t le{__builtin_bswap64(sector)};
std::memcpy(tweak.data() + 8, &le, 8);
return tweak;
}
public:
AesCipher(std::span<u8> key, mbedtls_cipher_type_t type);
~AesCipher();
/**
* @brief Sets initilization vector
*/
void SetIV(const std::array<u8, 0x10> &iv);
/**
* @note destination and source can be the same
*/
void Decrypt(u8 *destination, u8 *source, size_t size);
/**
* @brief Decrypts data and writes back to it
*/
inline void Decrypt(std::span<u8> data) {
Decrypt(data.data(), data.data(), data.size());
}
/**
* @brief Decrypts data with XTS. IV will get calculated with the given sector
*/
void XtsDecrypt(u8 *destination, u8 *source, size_t size, size_t sector, size_t sectorSize);
/**
* @brief Decrypts data with XTS and writes back to it
*/
inline void XtsDecrypt(std::span<u8> data, size_t sector, size_t sectorSize) {
XtsDecrypt(data.data(), data.data(), data.size(), sector, sectorSize);
}
};
}

View File

@ -0,0 +1,60 @@
// SPDX-License-Identifier: MPL-2.0
// Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/)
#include <functional>
#include <vfs/os_filesystem.h>
#include "key_store.h"
namespace skyline::crypto {
KeyStore::KeyStore(const std::string &rootPath) {
vfs::OsFileSystem root(rootPath);
if (root.FileExists("title.keys"))
ReadPairs(root.OpenFile("title.keys"), &KeyStore::PopulateTitleKeys);
if (root.FileExists("prod.keys"))
ReadPairs(root.OpenFile("prod.keys"), &KeyStore::PopulateKeys);
}
void KeyStore::ReadPairs(const std::shared_ptr<vfs::Backing> &backing, ReadPairsCallback callback) {
std::vector<char> fileContent(backing->size);
backing->Read(fileContent.data(), 0, fileContent.size());
auto lineStart{fileContent.begin()};
std::vector<char>::iterator lineEnd;
while ((lineEnd = std::find(lineStart, fileContent.end(), '\n')) != fileContent.end()) {
auto keyEnd{std::find(lineStart, lineEnd, '=')};
if (keyEnd == lineEnd) {
throw exception("Invalid key file");
}
std::string_view key(&*lineStart, keyEnd - lineStart);
std::string_view value(&*(keyEnd + 1), lineEnd - keyEnd - 1);
(this->*callback)(key, value);
lineStart = lineEnd + 1;
}
}
void KeyStore::PopulateTitleKeys(std::string_view keyName, std::string_view value) {
Key128 key{util::HexStringToArray<16>(keyName)};
Key128 valueArray{util::HexStringToArray<16>(value)};
titleKeys.insert({std::move(key), std::move(valueArray)});
}
void KeyStore::PopulateKeys(std::string_view keyName, std::string_view value) {
{
auto it{key256Names.find(keyName)};
if (it != key256Names.end()) {
it->second = headerKey = util::HexStringToArray<32>(value);
return;
}
}
if (keyName.size() > 2) {
auto it = indexedKey128Names.find(keyName.substr(0, keyName.size() - 2));
if (it != indexedKey128Names.end()) {
size_t index{std::stoul(std::string(keyName.substr(it->first.size())), nullptr, 16)};
it->second[index] = util::HexStringToArray<16>(value);
}
}
}
}

View File

@ -0,0 +1,59 @@
// SPDX-License-Identifier: MPL-2.0
// Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/)
#pragma once
#include <array>
#include <vfs/backing.h>
#include "common.h"
namespace skyline::crypto {
/**
* @brief The KeyStore class looks for title.keys and prod.keys files in rootPath
* @note Both files are created on kotlin side, prod.keys contains keys that are used to decrypt ROMs and title key, decrypted title keys are used for ctr backing.
*/
class KeyStore {
public:
KeyStore(const std::string &rootPath);
using Key128 = std::array<u8, 16>;
using Key256 = std::array<u8, 32>;
using IndexedKeys128 = std::array<std::optional<Key128>, 20>;
std::optional<Key256> headerKey;
IndexedKeys128 titleKek;
IndexedKeys128 areaKeyApplication;
IndexedKeys128 areaKeyOcean;
IndexedKeys128 areaKeySystem;
private:
std::map<Key128, Key128> titleKeys;
std::unordered_map<std::string_view, std::optional<Key256> &> key256Names{
{"header_key", headerKey}
};
std::unordered_map<std::string_view, IndexedKeys128 &> indexedKey128Names{
{"titlekek_", titleKek},
{"key_area_key_application_", areaKeyApplication},
{"key_area_key_ocean_", areaKeyOcean},
{"key_area_key_system_", areaKeySystem}
};
using ReadPairsCallback = void (skyline::crypto::KeyStore::*)(std::string_view, std::string_view);
void ReadPairs(const std::shared_ptr<vfs::Backing> &backing, ReadPairsCallback callback);
void PopulateTitleKeys(std::string_view keyName, std::string_view value);
void PopulateKeys(std::string_view keyName, std::string_view value);
public:
inline std::optional<Key128> GetTitleKey(const Key128 &title) {
auto it{titleKeys.find(title)};
if (it == titleKeys.end())
return std::nullopt;
return it->second;
}
};
}

View File

@ -20,6 +20,29 @@ namespace skyline::loader {
NSP, //!< The NSP format from "nspwn" exploit: https://switchbrew.org/wiki/Switch_System_Flaws NSP, //!< The NSP format from "nspwn" exploit: https://switchbrew.org/wiki/Switch_System_Flaws
}; };
/**
* @brief This enumerates all possible results when parsing ROM files
* @note This needs to be synchronized with emu.skyline.loader.LoaderResult
*/
enum class LoaderResult : int8_t {
Success,
ParsingError,
MissingHeaderKey,
MissingTitleKey,
MissingTitleKek,
MissingKeyArea
};
/**
* @brief An exception used specifically for errors related to loaders, it's used to communicate errors to the Kotlin-side of the loader
*/
class loader_exception : public exception {
public:
const LoaderResult error;
loader_exception(LoaderResult error, const std::string &message = "No message") : exception("Loader exception {}: {}", error, message), error(error) {}
};
/** /**
* @brief The Loader class provides an abstract interface for ROM loaders * @brief The Loader class provides an abstract interface for ROM loaders
*/ */

View File

@ -8,7 +8,7 @@
#include "nca.h" #include "nca.h"
namespace skyline::loader { namespace skyline::loader {
NcaLoader::NcaLoader(const std::shared_ptr<vfs::Backing> &backing) : nca(backing) { NcaLoader::NcaLoader(const std::shared_ptr<vfs::Backing> &backing, const std::shared_ptr<crypto::KeyStore> &keyStore) : nca(backing, keyStore) {
if (nca.exeFs == nullptr) if (nca.exeFs == nullptr)
throw exception("Only NCAs with an ExeFS can be loaded directly"); throw exception("Only NCAs with an ExeFS can be loaded directly");
} }

View File

@ -5,6 +5,7 @@
#include <common.h> #include <common.h>
#include <vfs/nca.h> #include <vfs/nca.h>
#include <crypto/key_store.h>
#include "loader.h" #include "loader.h"
namespace skyline::loader { namespace skyline::loader {
@ -16,7 +17,7 @@ namespace skyline::loader {
vfs::NCA nca; //!< The backing NCA of the loader vfs::NCA nca; //!< The backing NCA of the loader
public: public:
NcaLoader(const std::shared_ptr<vfs::Backing> &backing); NcaLoader(const std::shared_ptr<vfs::Backing> &backing, const std::shared_ptr<crypto::KeyStore> &keyStore);
/** /**
* @brief This loads an ExeFS into memory * @brief This loads an ExeFS into memory

View File

@ -5,20 +5,22 @@
#include "nsp.h" #include "nsp.h"
namespace skyline::loader { namespace skyline::loader {
NspLoader::NspLoader(const std::shared_ptr<vfs::Backing> &backing) : nsp(std::make_shared<vfs::PartitionFileSystem>(backing)) { NspLoader::NspLoader(const std::shared_ptr<vfs::Backing> &backing, const std::shared_ptr<crypto::KeyStore> &keyStore) : nsp(std::make_shared<vfs::PartitionFileSystem>(backing)) {
auto root = nsp->OpenDirectory("", {false, true}); auto root{nsp->OpenDirectory("", {false, true})};
for (const auto &entry : root->Read()) { for (const auto &entry : root->Read()) {
if (entry.name.substr(entry.name.find_last_of(".") + 1) != "nca") if (entry.name.substr(entry.name.find_last_of(".") + 1) != "nca")
continue; continue;
try { try {
auto nca = vfs::NCA(nsp->OpenFile(entry.name)); auto nca{vfs::NCA(nsp->OpenFile(entry.name), keyStore)};
if (nca.contentType == vfs::NcaContentType::Program && nca.romFs != nullptr && nca.exeFs != nullptr) if (nca.contentType == vfs::NcaContentType::Program && nca.romFs != nullptr && nca.exeFs != nullptr)
programNca = std::move(nca); programNca = std::move(nca);
else if (nca.contentType == vfs::NcaContentType::Control && nca.romFs != nullptr) else if (nca.contentType == vfs::NcaContentType::Control && nca.romFs != nullptr)
controlNca = std::move(nca); controlNca = std::move(nca);
} catch (const loader_exception &e) {
throw loader_exception(e.error);
} catch (const std::exception &e) { } catch (const std::exception &e) {
continue; continue;
} }
@ -40,7 +42,7 @@ namespace skyline::loader {
if (romFs == nullptr) if (romFs == nullptr)
return std::vector<u8>(); return std::vector<u8>();
auto root = controlRomFs->OpenDirectory("", {false, true}); auto root{controlRomFs->OpenDirectory("", {false, true})};
std::shared_ptr<vfs::Backing> icon; std::shared_ptr<vfs::Backing> icon;
// Use the first icon file available // Use the first icon file available

View File

@ -7,6 +7,7 @@
#include <vfs/nca.h> #include <vfs/nca.h>
#include <vfs/rom_filesystem.h> #include <vfs/rom_filesystem.h>
#include <vfs/partition_filesystem.h> #include <vfs/partition_filesystem.h>
#include <crypto/key_store.h>
#include "loader.h" #include "loader.h"
namespace skyline::loader { namespace skyline::loader {
@ -21,7 +22,7 @@ namespace skyline::loader {
std::optional<vfs::NCA> controlNca; //!< The main control NCA within the NSP std::optional<vfs::NCA> controlNca; //!< The main control NCA within the NSP
public: public:
NspLoader(const std::shared_ptr<vfs::Backing> &backing); NspLoader(const std::shared_ptr<vfs::Backing> &backing, const std::shared_ptr<crypto::KeyStore> &keyStore);
std::vector<u8> GetIcon(); std::vector<u8> GetIcon();

View File

@ -13,16 +13,17 @@ namespace skyline::kernel {
OS::OS(std::shared_ptr<JvmManager> &jvmManager, std::shared_ptr<Logger> &logger, std::shared_ptr<Settings> &settings, const std::string &appFilesPath) : state(this, process, jvmManager, settings, logger), memory(state), serviceManager(state), appFilesPath(appFilesPath) {} OS::OS(std::shared_ptr<JvmManager> &jvmManager, std::shared_ptr<Logger> &logger, std::shared_ptr<Settings> &settings, const std::string &appFilesPath) : state(this, process, jvmManager, settings, logger), memory(state), serviceManager(state), appFilesPath(appFilesPath) {}
void OS::Execute(int romFd, loader::RomFormat romType) { void OS::Execute(int romFd, loader::RomFormat romType) {
auto romFile = std::make_shared<vfs::OsBacking>(romFd); auto romFile{std::make_shared<vfs::OsBacking>(romFd)};
auto keyStore{std::make_shared<crypto::KeyStore>(appFilesPath)};
if (romType == loader::RomFormat::NRO) { if (romType == loader::RomFormat::NRO) {
state.loader = std::make_shared<loader::NroLoader>(romFile); state.loader = std::make_shared<loader::NroLoader>(romFile);
} else if (romType == loader::RomFormat::NSO) { } else if (romType == loader::RomFormat::NSO) {
state.loader = std::make_shared<loader::NsoLoader>(romFile); state.loader = std::make_shared<loader::NsoLoader>(romFile);
} else if (romType == loader::RomFormat::NCA) { } else if (romType == loader::RomFormat::NCA) {
state.loader = std::make_shared<loader::NcaLoader>(romFile); state.loader = std::make_shared<loader::NcaLoader>(romFile, keyStore);
} else if (romType == loader::RomFormat::NSP) { } else if (romType == loader::RomFormat::NSP) {
state.loader = std::make_shared<loader::NspLoader>(romFile); state.loader = std::make_shared<loader::NspLoader>(romFile, keyStore);
} else { } else {
throw exception("Unsupported ROM extension."); throw exception("Unsupported ROM extension.");
} }
@ -36,16 +37,16 @@ namespace skyline::kernel {
} }
std::shared_ptr<type::KProcess> OS::CreateProcess(u64 entry, u64 argument, size_t stackSize) { std::shared_ptr<type::KProcess> OS::CreateProcess(u64 entry, u64 argument, size_t stackSize) {
auto stack = std::make_shared<type::KSharedMemory>(state, memory.stack.address, stackSize, memory::Permission{true, true, false}, memory::states::Stack, MAP_NORESERVE | MAP_STACK, true); auto stack{std::make_shared<type::KSharedMemory>(state, memory.stack.address, stackSize, memory::Permission{true, true, false}, memory::states::Stack, MAP_NORESERVE | MAP_STACK, true)};
stack->guest = stack->kernel; stack->guest = stack->kernel;
if (mprotect(reinterpret_cast<void *>(stack->guest.address), PAGE_SIZE, PROT_NONE)) if (mprotect(reinterpret_cast<void *>(stack->guest.address), PAGE_SIZE, PROT_NONE))
throw exception("Failed to create guard pages"); throw exception("Failed to create guard pages");
auto tlsMem = std::make_shared<type::KSharedMemory>(state, 0, (sizeof(ThreadContext) + (PAGE_SIZE - 1)) & ~(PAGE_SIZE - 1), memory::Permission{true, true, false}, memory::states::Reserved); auto tlsMem{std::make_shared<type::KSharedMemory>(state, 0, (sizeof(ThreadContext) + (PAGE_SIZE - 1)) & ~(PAGE_SIZE - 1), memory::Permission{true, true, false}, memory::states::Reserved)};
tlsMem->guest = tlsMem->kernel; tlsMem->guest = tlsMem->kernel;
auto pid = clone(reinterpret_cast<int (*)(void *)>(&guest::GuestEntry), reinterpret_cast<void *>(stack->guest.address + stackSize), CLONE_FILES | CLONE_FS | CLONE_SETTLS | SIGCHLD, reinterpret_cast<void *>(entry), nullptr, reinterpret_cast<void *>(tlsMem->guest.address)); auto pid{clone(reinterpret_cast<int (*)(void *)>(&guest::GuestEntry), reinterpret_cast<void *>(stack->guest.address + stackSize), CLONE_FILES | CLONE_FS | CLONE_SETTLS | SIGCHLD, reinterpret_cast<void *>(entry), nullptr, reinterpret_cast<void *>(tlsMem->guest.address))};
if (pid == -1) if (pid == -1)
throw exception("Call to clone() has failed: {}", strerror(errno)); throw exception("Call to clone() has failed: {}", strerror(errno));

View File

@ -0,0 +1,48 @@
// SPDX-License-Identifier: MPL-2.0
// Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/)
#include "ctr_encrypted_backing.h"
namespace skyline::vfs {
constexpr size_t SectorSize = 0x10;
CtrEncryptedBacking::CtrEncryptedBacking(crypto::KeyStore::Key128 &ctr, crypto::KeyStore::Key128 &key, const std::shared_ptr<Backing> &backing, size_t baseOffset) : Backing({true, false, false}), ctr(ctr), cipher(key, MBEDTLS_CIPHER_AES_128_CTR), backing(backing), baseOffset(baseOffset) {}
void CtrEncryptedBacking::UpdateCtr(u64 offset) {
offset >>= 4;
size_t le{__builtin_bswap64(offset)};
std::memcpy(ctr.data() + 8, &le, 8);
cipher.SetIV(ctr);
}
size_t CtrEncryptedBacking::Read(u8 *output, size_t offset, size_t size) {
if (size == 0)
return 0;
size_t sectorOffset{offset % SectorSize};
if (sectorOffset == 0) {
UpdateCtr(baseOffset + offset);
size_t read{backing->Read(output, offset, size)};
if (read != size)
return 0;
cipher.Decrypt({output, size});
return size;
}
size_t sectorStart{offset - sectorOffset};
std::vector<u8> blockBuf(SectorSize);
size_t read{backing->Read(blockBuf.data(), sectorStart, SectorSize)};
if (read != SectorSize)
return 0;
UpdateCtr(baseOffset + sectorStart);
cipher.Decrypt(blockBuf);
if (size + sectorOffset < SectorSize) {
std::memcpy(output, blockBuf.data() + sectorOffset, size);
return size;
}
size_t readInBlock{SectorSize - sectorOffset};
std::memcpy(output, blockBuf.data() + sectorOffset, readInBlock);
return readInBlock + Read(output + readInBlock, offset + readInBlock, size - readInBlock);
}
}

View File

@ -0,0 +1,37 @@
// SPDX-License-Identifier: MPL-2.0
// Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/)
#pragma once
#include <crypto/aes_cipher.h>
#include <crypto/key_store.h>
#include "backing.h"
namespace skyline::vfs {
/**
* @brief This backing is used to decrypt AES-CTR data
*/
class CtrEncryptedBacking : public Backing {
private:
crypto::KeyStore::Key128 ctr;
crypto::AesCipher cipher;
std::shared_ptr<Backing> backing;
/**
* @brief Offset of file is used to calculate the IV
*/
size_t baseOffset;
/**
* @brief Calculates IV based on the offset
*/
void UpdateCtr(u64 offset);
public:
CtrEncryptedBacking(crypto::KeyStore::Key128 &ctr, crypto::KeyStore::Key128 &key, const std::shared_ptr<Backing> &backing, size_t baseOffset);
size_t Read(u8 *output, size_t offset, size_t size) override;
};
}

View File

@ -1,6 +1,9 @@
// SPDX-License-Identifier: MPL-2.0 // SPDX-License-Identifier: MPL-2.0
// Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/) // Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/)
#include <crypto/aes_cipher.h>
#include <loader/loader.h>
#include "ctr_encrypted_backing.h"
#include "region_backing.h" #include "region_backing.h"
#include "partition_filesystem.h" #include "partition_filesystem.h"
#include "nca.h" #include "nca.h"
@ -8,17 +11,31 @@
#include "directory.h" #include "directory.h"
namespace skyline::vfs { namespace skyline::vfs {
NCA::NCA(const std::shared_ptr<vfs::Backing> &backing) : backing(backing) { using namespace loader;
NCA::NCA(const std::shared_ptr<vfs::Backing> &backing, const std::shared_ptr<crypto::KeyStore> &keyStore) : backing(backing), keyStore(keyStore) {
backing->Read(&header); backing->Read(&header);
if (header.magic != util::MakeMagic<u32>("NCA3")) {
if (!keyStore->headerKey)
throw loader_exception(LoaderResult::MissingHeaderKey);
crypto::AesCipher cipher(*keyStore->headerKey, MBEDTLS_CIPHER_AES_128_XTS);
cipher.XtsDecrypt({reinterpret_cast<u8 *>(&header), sizeof(NcaHeader)}, 0, 0x200);
// Check if decryption was successful
if (header.magic != util::MakeMagic<u32>("NCA3")) if (header.magic != util::MakeMagic<u32>("NCA3"))
throw exception("Attempting to load an encrypted or invalid NCA"); throw loader_exception(LoaderResult::ParsingError);
encrypted = true;
}
contentType = header.contentType; contentType = header.contentType;
rightsIdEmpty = header.rightsId == crypto::KeyStore::Key128{};
for (size_t i = 0; i < header.sectionHeaders.size(); i++) { for (size_t i{}; i < header.sectionHeaders.size(); i++) {
auto &sectionHeader = header.sectionHeaders.at(i); auto &sectionHeader{header.sectionHeaders.at(i)};
auto &sectionEntry = header.fsEntries.at(i); auto &sectionEntry{header.fsEntries.at(i)};
if (sectionHeader.fsType == NcaSectionFsType::PFS0 && sectionHeader.hashType == NcaSectionHashType::HierarchicalSha256) if (sectionHeader.fsType == NcaSectionFsType::PFS0 && sectionHeader.hashType == NcaSectionHashType::HierarchicalSha256)
ReadPfs0(sectionHeader, sectionEntry); ReadPfs0(sectionHeader, sectionEntry);
@ -27,11 +44,11 @@ namespace skyline::vfs {
} }
} }
void NCA::ReadPfs0(const NcaSectionHeader &header, const NcaFsEntry &entry) { void NCA::ReadPfs0(const NcaSectionHeader &sectionHeader, const NcaFsEntry &entry) {
size_t offset = static_cast<size_t>(entry.startOffset) * constant::MediaUnitSize + header.sha256HashInfo.pfs0Offset; size_t offset{static_cast<size_t>(entry.startOffset) * constant::MediaUnitSize + sectionHeader.sha256HashInfo.pfs0Offset};
size_t size = constant::MediaUnitSize * static_cast<size_t>(entry.endOffset - entry.startOffset); size_t size{constant::MediaUnitSize * static_cast<size_t>(entry.endOffset - entry.startOffset)};
auto pfs = std::make_shared<PartitionFileSystem>(std::make_shared<RegionBacking>(backing, offset, size)); auto pfs{std::make_shared<PartitionFileSystem>(CreateBacking(sectionHeader, std::make_shared<RegionBacking>(backing, offset, size), offset))};
if (contentType == NcaContentType::Program) { if (contentType == NcaContentType::Program) {
// An ExeFS must always contain an NPDM and a main NSO, whereas the logo section will always contain a logo and a startup movie // An ExeFS must always contain an NPDM and a main NSO, whereas the logo section will always contain a logo and a startup movie
@ -44,10 +61,95 @@ namespace skyline::vfs {
} }
} }
void NCA::ReadRomFs(const NcaSectionHeader &header, const NcaFsEntry &entry) { void NCA::ReadRomFs(const NcaSectionHeader &sectionHeader, const NcaFsEntry &entry) {
size_t offset = static_cast<size_t>(entry.startOffset) * constant::MediaUnitSize + header.integrityHashInfo.levels.back().offset; size_t offset{static_cast<size_t>(entry.startOffset) * constant::MediaUnitSize + sectionHeader.integrityHashInfo.levels.back().offset};
size_t size = header.integrityHashInfo.levels.back().size; size_t size{sectionHeader.integrityHashInfo.levels.back().size};
romFs = std::make_shared<RegionBacking>(backing, offset, size); romFs = CreateBacking(sectionHeader, std::make_shared<RegionBacking>(backing, offset, size), offset);
}
std::shared_ptr<Backing> NCA::CreateBacking(const NcaSectionHeader &sectionHeader, std::shared_ptr<Backing> rawBacking, size_t offset) {
if (!encrypted)
return rawBacking;
switch (sectionHeader.encryptionType) {
case NcaSectionEncryptionType::None:
return rawBacking;
case NcaSectionEncryptionType::CTR:
case NcaSectionEncryptionType::BKTR: {
auto key{!rightsIdEmpty ? GetTitleKey() : GetKeyAreaKey(sectionHeader.encryptionType)};
std::array<u8, 0x10> ctr{};
u32 secureValueLE{__builtin_bswap32(sectionHeader.secureValue)};
u32 generationLE{__builtin_bswap32(sectionHeader.generation)};
std::memcpy(ctr.data(), &secureValueLE, 4);
std::memcpy(ctr.data() + 4, &generationLE, 4);
return std::make_shared<CtrEncryptedBacking>(ctr, key, std::move(rawBacking), offset);
}
default:
return nullptr;
}
}
u8 NCA::GetKeyGeneration() {
u8 legacyGen{static_cast<u8>(header.legacyKeyGenerationType)};
u8 gen{static_cast<u8>(header.keyGenerationType)};
gen = std::max<u8>(legacyGen, gen);
return gen > 0 ? gen - 1 : gen;
}
crypto::KeyStore::Key128 NCA::GetTitleKey() {
u8 keyGeneration{GetKeyGeneration()};
auto titleKey{keyStore->GetTitleKey(header.rightsId)};
auto &titleKek{keyStore->titleKek[keyGeneration]};
if (!titleKey)
throw loader_exception(LoaderResult::MissingTitleKey);
if (!titleKek)
throw loader_exception(LoaderResult::MissingTitleKek);
crypto::AesCipher cipher(*titleKek, MBEDTLS_CIPHER_AES_128_ECB);
cipher.Decrypt(*titleKey);
return *titleKey;
}
crypto::KeyStore::Key128 NCA::GetKeyAreaKey(NCA::NcaSectionEncryptionType type) {
auto keyArea{[&](crypto::KeyStore::IndexedKeys128 &keys) {
u8 keyGeneration{GetKeyGeneration()};
auto &keyArea{keys[keyGeneration]};
if (!keyArea)
throw loader_exception(LoaderResult::MissingKeyArea);
size_t keyAreaIndex;
switch (type) {
case NcaSectionEncryptionType::XTS:
keyAreaIndex = 0;
break;
case NcaSectionEncryptionType::CTR:
case NcaSectionEncryptionType::BKTR:
keyAreaIndex = 2;
break;
default:
throw exception("Unsupported NcaSectionEncryptionType");
}
crypto::KeyStore::Key128 decryptedKeyArea;
crypto::AesCipher cipher(*keyArea, MBEDTLS_CIPHER_AES_128_ECB);
cipher.Decrypt(decryptedKeyArea.data(), header.encryptedKeyArea[keyAreaIndex].data(), decryptedKeyArea.size());
return decryptedKeyArea;
}};
switch (header.keyAreaEncryptionKeyType) {
case NcaKeyAreaEncryptionKeyType::Application:
return keyArea(keyStore->areaKeyApplication);
case NcaKeyAreaEncryptionKeyType::Ocean:
return keyArea(keyStore->areaKeyOcean);
case NcaKeyAreaEncryptionKeyType::System:
return keyArea(keyStore->areaKeySystem);
}
} }
} }

View File

@ -4,6 +4,8 @@
#pragma once #pragma once
#include <array> #include <array>
#include <crypto/key_store.h>
#include <crypto/aes_cipher.h>
#include "filesystem.h" #include "filesystem.h"
namespace skyline { namespace skyline {
@ -195,10 +197,21 @@ namespace skyline {
static_assert(sizeof(NcaHeader) == 0xC00); static_assert(sizeof(NcaHeader) == 0xC00);
std::shared_ptr<Backing> backing; //!< The backing for the NCA std::shared_ptr<Backing> backing; //!< The backing for the NCA
std::shared_ptr<crypto::KeyStore> keyStore;
bool encrypted{false};
bool rightsIdEmpty;
void ReadPfs0(const NcaSectionHeader &header, const NcaFsEntry &entry); void ReadPfs0(const NcaSectionHeader &sectionHeader, const NcaFsEntry &entry);
void ReadRomFs(const NcaSectionHeader &header, const NcaFsEntry &entry); void ReadRomFs(const NcaSectionHeader &sectionHeader, const NcaFsEntry &entry);
std::shared_ptr<Backing> CreateBacking(const NcaSectionHeader &sectionHeader, std::shared_ptr<Backing> rawBacking, size_t offset);
u8 GetKeyGeneration();
crypto::KeyStore::Key128 GetTitleKey();
crypto::KeyStore::Key128 GetKeyAreaKey(NcaSectionEncryptionType type);
public: public:
std::shared_ptr<FileSystem> exeFs; //!< The PFS0 filesystem for this NCA's ExeFS section std::shared_ptr<FileSystem> exeFs; //!< The PFS0 filesystem for this NCA's ExeFS section
@ -207,7 +220,7 @@ namespace skyline {
std::shared_ptr<Backing> romFs; //!< The backing for this NCA's RomFS section std::shared_ptr<Backing> romFs; //!< The backing for this NCA's RomFS section
NcaContentType contentType; //!< The content type of the NCA NcaContentType contentType; //!< The content type of the NCA
NCA(const std::shared_ptr<vfs::Backing> &backing); NCA(const std::shared_ptr<vfs::Backing> &backing, const std::shared_ptr<crypto::KeyStore> &keyStore);
}; };
} }
} }

View File

@ -10,7 +10,7 @@
#include "os_filesystem.h" #include "os_filesystem.h"
namespace skyline::vfs { namespace skyline::vfs {
OsFileSystem::OsFileSystem(const std::string &basePath) : FileSystem(), basePath(std::move(basePath)) { OsFileSystem::OsFileSystem(const std::string &basePath) : FileSystem(), basePath(basePath) {
if (!DirectoryExists(basePath)) if (!DirectoryExists(basePath))
if (!CreateDirectory(basePath, true)) if (!CreateDirectory(basePath, true))
throw exception("Error creating the OS filesystem backing directory"); throw exception("Error creating the OS filesystem backing directory");

View File

@ -19,12 +19,13 @@ namespace skyline::vfs {
size_t stringTableOffset = sizeof(FsHeader) + (header.numFiles * entrySize); size_t stringTableOffset = sizeof(FsHeader) + (header.numFiles * entrySize);
fileDataOffset = stringTableOffset + header.stringTableSize; fileDataOffset = stringTableOffset + header.stringTableSize;
std::vector<char> stringTable(header.stringTableSize); std::vector<char> stringTable(header.stringTableSize + 1);
backing->Read(stringTable.data(), stringTableOffset, header.stringTableSize); backing->Read(stringTable.data(), stringTableOffset, header.stringTableSize);
stringTable[header.stringTableSize] = 0;
for (u32 i = 0; i < header.numFiles; i++) { for (u32 entryOffset{sizeof(FsHeader)}; entryOffset < header.numFiles * entrySize; entryOffset += entrySize) {
PartitionFileEntry entry{}; PartitionFileEntry entry;
backing->Read(&entry, sizeof(FsHeader) + i * entrySize); backing->Read(&entry, entryOffset);
std::string name(&stringTable[entry.stringTableOffset]); std::string name(&stringTable[entry.stringTableOffset]);
fileMap.emplace(name, std::move(entry)); fileMap.emplace(name, std::move(entry));

View File

@ -12,7 +12,7 @@ namespace skyline::vfs {
class RegionBacking : public Backing { class RegionBacking : public Backing {
private: private:
std::shared_ptr<vfs::Backing> backing; //!< The parent backing std::shared_ptr<vfs::Backing> backing; //!< The parent backing
size_t offset; //!< The offset of the region in the parent backing size_t baseOffset; //!< The offset of the region in the parent backing
public: public:
/** /**
@ -20,7 +20,7 @@ namespace skyline::vfs {
* @param offset The offset of the region start within the parent backing * @param offset The offset of the region start within the parent backing
* @param size The size of the region in the parent backing * @param size The size of the region in the parent backing
*/ */
RegionBacking(const std::shared_ptr<vfs::Backing> &backing, size_t offset, size_t size, Mode mode = {true, false, false}) : Backing(mode, size), backing(backing), offset(offset) {}; RegionBacking(const std::shared_ptr<vfs::Backing> &backing, size_t offset, size_t size, Mode mode = {true, false, false}) : Backing(mode, size), backing(backing), baseOffset(offset) {};
inline size_t Read(u8 *output, size_t offset, size_t size) { inline size_t Read(u8 *output, size_t offset, size_t size) {
if (!mode.read) if (!mode.read)
@ -28,7 +28,7 @@ namespace skyline::vfs {
size = std::min(offset + size, this->size) - offset; size = std::min(offset + size, this->size) - offset;
return backing->Read(output, this->offset + offset, size); return backing->Read(output, baseOffset + offset, size);
} }
}; };
} }

View File

@ -20,14 +20,28 @@ import androidx.core.graphics.drawable.toBitmap
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import emu.skyline.data.AppItem import emu.skyline.data.AppItem
import emu.skyline.loader.LoaderResult
import kotlinx.android.synthetic.main.app_dialog.* import kotlinx.android.synthetic.main.app_dialog.*
/** /**
* This dialog is used to show extra game metadata and provide extra options such as pinning the game to the home screen * This dialog is used to show extra game metadata and provide extra options such as pinning the game to the home screen
* */
class AppDialog : BottomSheetDialogFragment() {
companion object {
/**
* @param item This is used to hold the [AppItem] between instances * @param item This is used to hold the [AppItem] between instances
*/ */
class AppDialog(val item : AppItem) : BottomSheetDialogFragment() { fun newInstance(item : AppItem) : AppDialog {
val args = Bundle()
args.putSerializable("item", item)
val fragment = AppDialog()
fragment.arguments = args
return fragment
}
}
private lateinit var item : AppItem
/** /**
* This inflates the layout of the dialog after initial view creation * This inflates the layout of the dialog after initial view creation
@ -36,6 +50,12 @@ class AppDialog(val item : AppItem) : BottomSheetDialogFragment() {
return requireActivity().layoutInflater.inflate(R.layout.app_dialog, container) return requireActivity().layoutInflater.inflate(R.layout.app_dialog, container)
} }
override fun onCreate(savedInstanceState : Bundle?) {
super.onCreate(savedInstanceState)
item = arguments!!.getSerializable("item") as AppItem
}
/** /**
* This expands the bottom sheet so that it's fully visible and map the B button to back * This expands the bottom sheet so that it's fully visible and map the B button to back
*/ */
@ -64,13 +84,11 @@ class AppDialog(val item : AppItem) : BottomSheetDialogFragment() {
game_icon.setImageBitmap(item.icon ?: missingIcon) game_icon.setImageBitmap(item.icon ?: missingIcon)
game_title.text = item.title game_title.text = item.title
game_subtitle.text = item.subTitle ?: getString(R.string.metadata_missing) game_subtitle.text = item.subTitle ?: item.loaderResultString(requireContext())
game_play.isEnabled = item.loaderResult == LoaderResult.Success
game_play.setOnClickListener { game_play.setOnClickListener {
val intent = Intent(activity, EmulationActivity::class.java) startActivity(Intent(activity, EmulationActivity::class.java).apply { data = item.uri })
intent.data = item.uri
startActivity(intent)
} }
val shortcutManager = requireActivity().getSystemService(ShortcutManager::class.java) val shortcutManager = requireActivity().getSystemService(ShortcutManager::class.java)

View File

@ -21,6 +21,10 @@ import java.io.File
import kotlin.math.abs import kotlin.math.abs
class EmulationActivity : AppCompatActivity(), SurfaceHolder.Callback, View.OnTouchListener { class EmulationActivity : AppCompatActivity(), SurfaceHolder.Callback, View.OnTouchListener {
companion object {
private val Tag = EmulationActivity::class.java.name
}
init { init {
System.loadLibrary("skyline") // libskyline.so System.loadLibrary("skyline") // libskyline.so
} }
@ -287,7 +291,7 @@ class EmulationActivity : AppCompatActivity(), SurfaceHolder.Callback, View.OnTo
* This sets [surface] to [holder].surface and passes it into libskyline * This sets [surface] to [holder].surface and passes it into libskyline
*/ */
override fun surfaceCreated(holder : SurfaceHolder) { override fun surfaceCreated(holder : SurfaceHolder) {
Log.d("surfaceCreated", "Holder: $holder") Log.d(Tag, "surfaceCreated Holder: $holder")
surface = holder.surface surface = holder.surface
setSurface(surface) setSurface(surface)
surfaceReady.open() surfaceReady.open()
@ -297,14 +301,14 @@ class EmulationActivity : AppCompatActivity(), SurfaceHolder.Callback, View.OnTo
* This is purely used for debugging surface changes * This is purely used for debugging surface changes
*/ */
override fun surfaceChanged(holder : SurfaceHolder, format : Int, width : Int, height : Int) { override fun surfaceChanged(holder : SurfaceHolder, format : Int, width : Int, height : Int) {
Log.d("surfaceChanged", "Holder: $holder, Format: $format, Width: $width, Height: $height") Log.d(Tag, "surfaceChanged Holder: $holder, Format: $format, Width: $width, Height: $height")
} }
/** /**
* This sets [surface] to null and passes it into libskyline * This sets [surface] to null and passes it into libskyline
*/ */
override fun surfaceDestroyed(holder : SurfaceHolder) { override fun surfaceDestroyed(holder : SurfaceHolder) {
Log.d("surfaceDestroyed", "Holder: $holder") Log.d(Tag, "surfaceDestroyed Holder: $holder")
surfaceReady.close() surfaceReady.close()
surface = null surface = null
setSurface(surface) setSurface(surface)

View File

@ -0,0 +1,86 @@
/*
* SPDX-License-Identifier: MPL-2.0
* Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/)
*/
package emu.skyline
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import java.io.File
object KeyReader {
private val Tag = KeyReader::class.java.simpleName
enum class KeyType(val keyName : String, val fileName : String) {
Title("title_keys", "title.keys"), Prod("prod_keys", "prod.keys");
companion object {
fun parse(keyName : String) = values().first { it.keyName == keyName }
}
}
/**
* Reads keys file, trims and writes to internal app data storage, it makes sure file is properly formatted
*/
fun import(context : Context, uri : Uri, keyType : KeyType) : Boolean {
Log.i(Tag, "Parsing ${keyType.name}")
if (!DocumentFile.isDocumentUri(context, uri))
return false
val fileName = DocumentFile.fromSingleUri(context, uri)!!.name
if ("keys" != fileName?.substringAfterLast('.')) return false
val tmpOutputFile = File("${context.filesDir.canonicalFile}/${keyType.fileName}.tmp")
val inputStream = context.contentResolver.openInputStream(uri)
val outputStream = tmpOutputFile.bufferedWriter()
val valid = inputStream!!.bufferedReader().useLines {
for (line in it) {
val pair = line.split("=")
if (pair.size != 2)
return@useLines false
val key = pair[0].trim()
val value = pair[1].trim()
when (keyType) {
KeyType.Title -> {
if (key.length != 32 && !isHexString(key))
return@useLines false
if (value.length != 32 && !isHexString(value))
return@useLines false
}
KeyType.Prod -> {
if (!key.contains("_"))
return@useLines false
if (!isHexString(value))
return@useLines false
}
}
outputStream.append("$key=$value\n")
}
true
}
outputStream.flush()
outputStream.close()
if (valid)
tmpOutputFile.renameTo(File("${tmpOutputFile.parent}/${keyType.fileName}"))
return valid
}
private fun isHexString(str : String) : Boolean {
for (c in str) {
if (!(c in '0'..'9' || c in 'a'..'f' || c in 'A'..'F')) {
return false
}
}
return true
}
}

View File

@ -30,16 +30,18 @@ import emu.skyline.adapter.AppAdapter
import emu.skyline.adapter.GridLayoutSpan import emu.skyline.adapter.GridLayoutSpan
import emu.skyline.adapter.LayoutType import emu.skyline.adapter.LayoutType
import emu.skyline.data.AppItem import emu.skyline.data.AppItem
import emu.skyline.loader.LoaderResult
import emu.skyline.loader.RomFile import emu.skyline.loader.RomFile
import emu.skyline.loader.RomFormat import emu.skyline.loader.RomFormat
import kotlinx.android.synthetic.main.main_activity.* import kotlinx.android.synthetic.main.main_activity.*
import kotlinx.android.synthetic.main.titlebar.* import kotlinx.android.synthetic.main.titlebar.*
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.concurrent.thread import kotlin.concurrent.thread
import kotlin.math.ceil import kotlin.math.ceil
class MainActivity : AppCompatActivity(), View.OnClickListener { class MainActivity : AppCompatActivity() {
/** /**
* This is used to get/set shared preferences * This is used to get/set shared preferences
*/ */
@ -50,8 +52,10 @@ class MainActivity : AppCompatActivity(), View.OnClickListener {
*/ */
private lateinit var adapter : AppAdapter private lateinit var adapter : AppAdapter
private var reloading = AtomicBoolean()
/** /**
* This adds all files in [directory] with [extension] as an entry in [adapter] using [loader] to load metadata * This adds all files in [directory] with [extension] as an entry in [adapter] using [RomFile] to load metadata
*/ */
private fun addEntries(extension : String, romFormat : RomFormat, directory : DocumentFile, found : Boolean = false) : Boolean { private fun addEntries(extension : String, romFormat : RomFormat, directory : DocumentFile, found : Boolean = false) : Boolean {
var foundCurrent = found var foundCurrent = found
@ -61,26 +65,17 @@ class MainActivity : AppCompatActivity(), View.OnClickListener {
foundCurrent = addEntries(extension, romFormat, file, foundCurrent) foundCurrent = addEntries(extension, romFormat, file, foundCurrent)
} else { } else {
if (extension.equals(file.name?.substringAfterLast("."), ignoreCase = true)) { if (extension.equals(file.name?.substringAfterLast("."), ignoreCase = true)) {
val romFd = contentResolver.openFileDescriptor(file.uri, "r")!! RomFile(this, romFormat, file.uri).let { romFile ->
val romFile = RomFile(this, romFormat, romFd)
if (romFile.valid()) {
romFile.use {
val entry = romFile.getAppEntry(file.uri)
val finalFoundCurrent = foundCurrent val finalFoundCurrent = foundCurrent
runOnUiThread { runOnUiThread {
if (!finalFoundCurrent) adapter.addHeader(romFormat.name) if (!finalFoundCurrent) adapter.addHeader(romFormat.name)
adapter.addItem(AppItem(entry)) adapter.addItem(AppItem(romFile.appEntry))
} }
foundCurrent = true foundCurrent = true
} }
} }
romFd.close()
}
} }
} }
@ -102,6 +97,7 @@ class MainActivity : AppCompatActivity(), View.OnClickListener {
} }
} }
if (reloading.getAndSet(true)) return
thread(start = true) { thread(start = true) {
val snackbar = Snackbar.make(coordinatorLayout, getString(R.string.searching_roms), Snackbar.LENGTH_INDEFINITE) val snackbar = Snackbar.make(coordinatorLayout, getString(R.string.searching_roms), Snackbar.LENGTH_INDEFINITE)
runOnUiThread { runOnUiThread {
@ -148,6 +144,8 @@ class MainActivity : AppCompatActivity(), View.OnClickListener {
snackbar.dismiss() snackbar.dismiss()
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
} }
reloading.set(false)
} }
} }
@ -171,10 +169,18 @@ class MainActivity : AppCompatActivity(), View.OnClickListener {
else -> AppCompatDelegate.MODE_NIGHT_UNSPECIFIED else -> AppCompatDelegate.MODE_NIGHT_UNSPECIFIED
}) })
refresh_fab.setOnClickListener(this) refresh_fab.setOnClickListener { refreshAdapter(false) }
settings_fab.setOnClickListener(this)
open_fab.setOnClickListener(this) settings_fab.setOnClickListener { startActivityForResult(Intent(this, SettingsActivity::class.java), 3) }
log_fab.setOnClickListener(this)
open_fab.setOnClickListener { startActivity(Intent(this, LogActivity::class.java)) }
log_fab.setOnClickListener {
startActivityForResult(Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
}, 2)
}
setupAppList() setupAppList()
@ -217,7 +223,7 @@ class MainActivity : AppCompatActivity(), View.OnClickListener {
val layoutType = LayoutType.values()[sharedPreferences.getString("layout_type", "1")!!.toInt()] val layoutType = LayoutType.values()[sharedPreferences.getString("layout_type", "1")!!.toInt()]
adapter = AppAdapter(layoutType = layoutType, gridSpan = gridSpan, onClick = selectStartGame, onLongClick = selectShowGameDialog) adapter = AppAdapter(layoutType = layoutType, onClick = ::selectStartGame, onLongClick = ::selectShowGameDialog)
app_list.adapter = adapter app_list.adapter = adapter
app_list.layoutManager = when (layoutType) { app_list.layoutManager = when (layoutType) {
@ -263,37 +269,15 @@ class MainActivity : AppCompatActivity(), View.OnClickListener {
return super.onCreateOptionsMenu(menu) return super.onCreateOptionsMenu(menu)
} }
/** private fun selectStartGame(appItem : AppItem) {
* This handles on-click interaction with [R.id.refresh_fab], [R.id.settings_fab], [R.id.log_fab], [R.id.open_fab] if (sharedPreferences.getBoolean("select_action", false))
*/ AppDialog.newInstance(appItem).show(supportFragmentManager, "game")
override fun onClick(view : View) { else if (appItem.loaderResult == LoaderResult.Success)
when (view.id) { startActivity(Intent(this, EmulationActivity::class.java).apply { data = appItem.uri })
R.id.refresh_fab -> refreshAdapter(false)
R.id.settings_fab -> startActivityForResult(Intent(this, SettingsActivity::class.java), 3)
R.id.log_fab -> startActivity(Intent(this, LogActivity::class.java))
R.id.open_fab -> {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.type = "*/*"
startActivityForResult(intent, 2)
}
}
} }
private val selectStartGame : (appItem : AppItem) -> Unit = { private fun selectShowGameDialog(appItem : AppItem) {
if (sharedPreferences.getBoolean("select_action", false)) { AppDialog.newInstance(appItem).show(supportFragmentManager, "game")
AppDialog(it).show(supportFragmentManager, "game")
} else {
startActivity(Intent(this, EmulationActivity::class.java).apply { data = it.uri })
}
}
private val selectShowGameDialog : (appItem : AppItem) -> Unit = {
AppDialog(it).show(supportFragmentManager, "game")
} }
/** /**
@ -363,8 +347,8 @@ class MainActivity : AppCompatActivity(), View.OnClickListener {
val gridCardMagin = resources.getDimensionPixelSize(R.dimen.app_card_margin_half) val gridCardMagin = resources.getDimensionPixelSize(R.dimen.app_card_margin_half)
when (layoutType) { when (layoutType) {
LayoutType.List -> app_list.setPadding(0, 0, 0, 0) LayoutType.List -> app_list.post { app_list.setPadding(0, 0, 0, fab_parent.height) }
LayoutType.Grid, LayoutType.GridCompact -> app_list.setPadding(gridCardMagin, 0, gridCardMagin, 0) LayoutType.Grid, LayoutType.GridCompact -> app_list.post { app_list.setPadding(gridCardMagin, 0, gridCardMagin, fab_parent.height) }
} }
} }
} }

View File

@ -9,10 +9,13 @@ import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.KeyEvent import android.view.KeyEvent
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceGroup
import emu.skyline.input.InputManager import emu.skyline.input.InputManager
import emu.skyline.preference.ActivityResultDelegate
import emu.skyline.preference.ControllerPreference import emu.skyline.preference.ControllerPreference
import emu.skyline.preference.DocumentActivity
import kotlinx.android.synthetic.main.settings_activity.*
import kotlinx.android.synthetic.main.titlebar.* import kotlinx.android.synthetic.main.titlebar.*
class SettingsActivity : AppCompatActivity() { class SettingsActivity : AppCompatActivity() {
@ -26,11 +29,6 @@ class SettingsActivity : AppCompatActivity() {
*/ */
lateinit var inputManager : InputManager lateinit var inputManager : InputManager
/**
* The key of the element to force a refresh when [onActivityResult] is called
*/
var refreshKey : String? = null
/** /**
* This initializes all of the elements in the activity and displays the settings fragment * This initializes all of the elements in the activity and displays the settings fragment
*/ */
@ -51,30 +49,27 @@ class SettingsActivity : AppCompatActivity() {
} }
/** /**
* This is used to refresh the preferences after [emu.skyline.preference.FolderActivity] or [emu.skyline.input.ControllerActivity] has returned * This is used to refresh the preferences after [DocumentActivity] or [emu.skyline.input.ControllerActivity] has returned
*/ */
public override fun onActivityResult(requestCode : Int, resultCode : Int, data : Intent?) { public override fun onActivityResult(requestCode : Int, resultCode : Int, data : Intent?) {
super.onActivityResult(requestCode, resultCode, data) super.onActivityResult(requestCode, resultCode, data)
refreshKey?.let { preferenceFragment.delegateActivityResult(requestCode, resultCode, data)
inputManager.syncObjects()
preferenceFragment.refreshPreference(refreshKey!!)
refreshKey = null settings
}
} }
/** /**
* This fragment is used to display all of the preferences and handle refreshing the preferences * This fragment is used to display all of the preferences
*/ */
class PreferenceFragment : PreferenceFragmentCompat() { class PreferenceFragment : PreferenceFragmentCompat() {
private var requestCodeCounter = 0
/** /**
* This forces refreshing a certain preference by indirectly calling [Preference.notifyChanged] * Delegates activity result to all preferences which implement [ActivityResultDelegate]
*/ */
fun refreshPreference(key : String) { fun delegateActivityResult(requestCode : Int, resultCode : Int, data : Intent?) {
val preference = preferenceManager.findPreference<Preference>(key)!! preferenceScreen.delegateActivityResult(requestCode, resultCode, data)
preference.isSelectable = !preference.isSelectable
preference.isSelectable = !preference.isSelectable
} }
/** /**
@ -82,6 +77,25 @@ class SettingsActivity : AppCompatActivity() {
*/ */
override fun onCreatePreferences(savedInstanceState : Bundle?, rootKey : String?) { override fun onCreatePreferences(savedInstanceState : Bundle?, rootKey : String?) {
setPreferencesFromResource(R.xml.preferences, rootKey) setPreferencesFromResource(R.xml.preferences, rootKey)
preferenceScreen.assignActivityRequestCode()
}
private fun PreferenceGroup.assignActivityRequestCode() {
for (i in 0 until preferenceCount) {
when (val pref = getPreference(i)) {
is PreferenceGroup -> pref.assignActivityRequestCode()
is ActivityResultDelegate -> pref.requestCode = requestCodeCounter++
}
}
}
private fun PreferenceGroup.delegateActivityResult(requestCode : Int, resultCode : Int, data : Intent?) {
for (i in 0 until preferenceCount) {
when (val pref = getPreference(i)) {
is PreferenceGroup -> pref.delegateActivityResult(requestCode, resultCode, data)
is ActivityResultDelegate -> pref.onActivityResult(requestCode, resultCode, data)
}
}
} }
} }

View File

@ -36,10 +36,9 @@ private typealias InteractionFunction = (appItem : AppItem) -> Unit
/** /**
* This adapter is used to display all found applications using their metadata * This adapter is used to display all found applications using their metadata
*/ */
internal class AppAdapter(val layoutType : LayoutType, private val gridSpan : Int, private val onClick : InteractionFunction, private val onLongClick : InteractionFunction) : HeaderAdapter<AppItem, BaseHeader, RecyclerView.ViewHolder>() { internal class AppAdapter(val layoutType : LayoutType, private val onClick : InteractionFunction, private val onLongClick : InteractionFunction) : HeaderAdapter<AppItem, BaseHeader, RecyclerView.ViewHolder>() {
private lateinit var context : Context private lateinit var context : Context
private val missingIcon by lazy { ContextCompat.getDrawable(context, R.drawable.default_icon)!!.toBitmap(256, 256) } private val missingIcon by lazy { ContextCompat.getDrawable(context, R.drawable.default_icon)!!.toBitmap(256, 256) }
private val missingString by lazy { context.getString(R.string.metadata_missing) }
/** /**
* This adds a header to the view with the contents of [string] * This adds a header to the view with the contents of [string]
@ -105,7 +104,7 @@ internal class AppAdapter(val layoutType : LayoutType, private val gridSpan : In
if (item is AppItem && holder is ItemViewHolder) { if (item is AppItem && holder is ItemViewHolder) {
holder.title.text = item.title holder.title.text = item.title
holder.subtitle.text = item.subTitle ?: missingString holder.subtitle.text = item.subTitle ?: item.loaderResultString(holder.subtitle.context)
holder.icon.setImageBitmap(item.icon ?: missingIcon) holder.icon.setImageBitmap(item.icon ?: missingIcon)

View File

@ -5,9 +5,12 @@
package emu.skyline.data package emu.skyline.data
import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import emu.skyline.R
import emu.skyline.loader.AppEntry import emu.skyline.loader.AppEntry
import emu.skyline.loader.LoaderResult
/** /**
* This class is a wrapper around [AppEntry], it is used for passing around game metadata * This class is a wrapper around [AppEntry], it is used for passing around game metadata
@ -43,6 +46,20 @@ class AppItem(val meta : AppEntry) : BaseItem() {
private val type : String private val type : String
get() = meta.format.name get() = meta.format.name
val loaderResult get() = meta.loaderResult
fun loaderResultString(context : Context) = context.getString(when (meta.loaderResult) {
LoaderResult.Success -> R.string.metadata_missing
LoaderResult.ParsingError -> R.string.invalid_file
LoaderResult.MissingTitleKey -> R.string.missing_title_key
LoaderResult.MissingHeaderKey,
LoaderResult.MissingTitleKek,
LoaderResult.MissingKeyArea -> R.string.incomplete_prod_keys
})
/** /**
* The name and author is used as the key * The name and author is used as the key
*/ */

View File

@ -61,23 +61,19 @@ class InputManager constructor(val context : Context) {
* This function syncs the class with data from [file] * This function syncs the class with data from [file]
*/ */
fun syncObjects() { fun syncObjects() {
val fileInput = FileInputStream(file) ObjectInputStream(FileInputStream(file)).use {
val objectInput = ObjectInputStream(fileInput) @Suppress("UNCHECKED_CAST")
controllers = it.readObject() as HashMap<Int, Controller>
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
controllers = objectInput.readObject() as HashMap<Int, Controller> eventMap = it.readObject() as HashMap<HostEvent?, GuestEvent?>
}
@Suppress("UNCHECKED_CAST")
eventMap = objectInput.readObject() as HashMap<HostEvent?, GuestEvent?>
} }
/** /**
* This function syncs [file] with data from the class and eliminates unused value from the map * This function syncs [file] with data from the class and eliminates unused value from the map
*/ */
fun syncFile() { fun syncFile() {
val fileOutput = FileOutputStream(file)
val objectOutput = ObjectOutputStream(fileOutput)
for (controller in controllers.values) { for (controller in controllers.values) {
for (button in ButtonId.values()) { for (button in ButtonId.values()) {
if (button != ButtonId.Menu && !(controller.type.buttons.contains(button) || controller.type.sticks.any { it.button == button })) { if (button != ButtonId.Menu && !(controller.type.buttons.contains(button) || controller.type.sticks.any { it.button == button })) {
@ -100,9 +96,11 @@ class InputManager constructor(val context : Context) {
} }
} }
objectOutput.writeObject(controllers) ObjectOutputStream(FileOutputStream(file)).use {
objectOutput.writeObject(eventMap) it.writeObject(controllers)
it.writeObject(eventMap)
objectOutput.flush() it.flush()
}
} }
} }

View File

@ -10,12 +10,7 @@ import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.ParcelFileDescriptor
import android.provider.OpenableColumns import android.provider.OpenableColumns
import java.io.IOException
import java.io.ObjectInputStream
import java.io.ObjectOutputStream
import java.io.Serializable import java.io.Serializable
import java.util.* import java.util.*
@ -47,165 +42,80 @@ fun getRomFormat(uri : Uri, contentResolver : ContentResolver) : RomFormat {
} }
/** /**
* This class is used to hold an application's metadata in a serializable way * An enumeration of all possible results when populating [RomFile]
*/ */
class AppEntry : Serializable { enum class LoaderResult(val value : Int) {
/** Success(0),
* The name of the application ParsingError(1),
*/ MissingHeaderKey(2),
var name : String MissingTitleKey(3),
MissingTitleKek(4),
MissingKeyArea(5);
/** companion object {
* The author of the application, if it can be extracted from the metadata fun get(value : Int) = values().first { value == it.value }
*/ }
var author : String? = null
var icon : Bitmap? = null
/**
* The format of the application ROM
*/
var format : RomFormat
/**
* The URI of the application ROM
*/
var uri : Uri
constructor(name : String, author : String, format : RomFormat, uri : Uri, icon : Bitmap?) {
this.name = name
this.author = author
this.icon = icon
this.format = format
this.uri = uri
} }
constructor(context : Context, format : RomFormat, uri : Uri) { /**
this.name = context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> * This class is used to hold an application's metadata in a serializable way
*/
class AppEntry(val name : String, val author : String?, val icon : Bitmap?, val format : RomFormat, val uri : Uri, val loaderResult : LoaderResult) : Serializable {
constructor(context : Context, format : RomFormat, uri : Uri, loaderResult : LoaderResult) : this(context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex : Int = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) val nameIndex : Int = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
cursor.moveToFirst() cursor.moveToFirst()
cursor.getString(nameIndex) cursor.getString(nameIndex)
}!!.dropLast(format.name.length + 1) }!!.dropLast(format.name.length + 1), null, null, format, uri, loaderResult)
this.format = format
this.uri = uri
}
/**
* This serializes this object into an OutputStream
*
* @param output The stream to which the object is written into
*/
@Throws(IOException::class)
private fun writeObject(output : ObjectOutputStream) {
output.writeUTF(name)
output.writeObject(format)
output.writeUTF(uri.toString())
output.writeBoolean(author != null)
if (author != null)
output.writeUTF(author)
output.writeBoolean(icon != null)
if (icon != null) {
@Suppress("DEPRECATION")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
icon!!.compress(Bitmap.CompressFormat.WEBP_LOSSY, 100, output)
else
icon!!.compress(Bitmap.CompressFormat.WEBP, 100, output)
}
}
/**
* This initializes the object from an InputStream
*
* @param input The stream from which the object data is retrieved from
*/
@Throws(IOException::class, ClassNotFoundException::class)
private fun readObject(input : ObjectInputStream) {
name = input.readUTF()
format = input.readObject() as RomFormat
uri = Uri.parse(input.readUTF())
if (input.readBoolean())
author = input.readUTF()
if (input.readBoolean())
icon = BitmapFactory.decodeStream(input)
}
} }
/** /**
* This class is used as interface between libskyline and Kotlin for loaders * This class is used as interface between libskyline and Kotlin for loaders
*/ */
internal class RomFile(val context : Context, val format : RomFormat, val file : ParcelFileDescriptor) : AutoCloseable { internal class RomFile(context : Context, format : RomFormat, uri : Uri) {
/** /**
* This is a pointer to the corresponding C++ Loader class * @note This field is filled in by native code
*/ */
var instance : Long private var applicationName : String? = null
/**
* @note This field is filled in by native code
*/
private var applicationAuthor : String? = null
/**
* @note This field is filled in by native code
*/
private var rawIcon : ByteArray? = null
val appEntry : AppEntry
var result = LoaderResult.Success
val valid : Boolean
get() = result == LoaderResult.Success
init { init {
System.loadLibrary("skyline") System.loadLibrary("skyline")
instance = initialize(format.ordinal, file.fd) context.contentResolver.openFileDescriptor(uri, "r")!!.use {
result = LoaderResult.get(populate(format.ordinal, it.fd, context.filesDir.canonicalPath + "/"))
}
appEntry = applicationName?.let { name ->
applicationAuthor?.let { author ->
rawIcon?.let { icon ->
AppEntry(name, author, BitmapFactory.decodeByteArray(icon, 0, icon.size), format, uri, result)
}
}
} ?: AppEntry(context, format, uri, result)
} }
/** /**
* This allocates and initializes a new loader object * Parses ROM and writes its metadata to [applicationName], [applicationAuthor] and [rawIcon]
* @param format The format of the ROM * @param format The format of the ROM
* @param romFd A file descriptor of the ROM * @param romFd A file descriptor of the ROM
* @param appFilesPath Path to internal app data storage, needed to read imported keys
* @return A pointer to the newly allocated object, or 0 if the ROM is invalid * @return A pointer to the newly allocated object, or 0 if the ROM is invalid
*/ */
private external fun initialize(format : Int, romFd : Int) : Long private external fun populate(format : Int, romFd : Int, appFilesPath : String) : Int
/**
* @return Whether the ROM contains assets, such as an icon or author information
*/
private external fun hasAssets(instance : Long) : Boolean
/**
* @return A ByteArray containing the application's icon as a bitmap
*/
private external fun getIcon(instance : Long) : ByteArray
/**
* @return A String containing the name of the application
*/
private external fun getApplicationName(instance : Long) : String
/**
* @return A String containing the publisher of the application
*/
private external fun getApplicationPublisher(instance : Long) : String
/**
* This destroys an existing loader object and frees it's resources
*/
private external fun destroy(instance : Long)
/**
* This is used to get the [AppEntry] for the specified ROM
*/
fun getAppEntry(uri : Uri) : AppEntry {
return if (hasAssets(instance)) {
val rawIcon = getIcon(instance)
val icon = if (rawIcon.isNotEmpty()) BitmapFactory.decodeByteArray(rawIcon, 0, rawIcon.size) else null
AppEntry(getApplicationName(instance), getApplicationPublisher(instance), format, uri, icon)
} else {
AppEntry(context, format, uri)
}
}
/**
* This checks if the currently loaded ROM is valid
*/
fun valid() : Boolean {
return instance != 0L
}
/**
* This destroys the C++ loader object
*/
override fun close() {
if (valid()) {
destroy(instance)
instance = 0
}
}
} }

View File

@ -0,0 +1,17 @@
/*
* SPDX-License-Identifier: MPL-2.0
* Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/)
*/
package emu.skyline.preference
import android.content.Intent
/**
* Some preferences need results from activities, this delegates the results to them
*/
interface ActivityResultDelegate {
var requestCode : Int
fun onActivityResult(requestCode : Int, resultCode : Int, data : Intent?)
}

View File

@ -14,17 +14,22 @@ import androidx.preference.Preference.SummaryProvider
import emu.skyline.R import emu.skyline.R
import emu.skyline.SettingsActivity import emu.skyline.SettingsActivity
import emu.skyline.input.ControllerActivity import emu.skyline.input.ControllerActivity
import emu.skyline.input.InputManager
/** /**
* This preference is used to launch [ControllerActivity] using a preference * This preference is used to launch [ControllerActivity] using a preference
*/ */
class ControllerPreference : Preference { class ControllerPreference @JvmOverloads constructor(context : Context?, attrs : AttributeSet? = null, defStyleAttr : Int = R.attr.preferenceStyle) : Preference(context, attrs, defStyleAttr), ActivityResultDelegate {
/** /**
* The index of the controller this preference manages * The index of the controller this preference manages
*/ */
private var index : Int = -1 private var index = -1
constructor(context : Context?, attrs : AttributeSet?, defStyleAttr : Int) : super(context, attrs, defStyleAttr) { private var inputManager : InputManager? = null
override var requestCode = 0
init {
for (i in 0 until attrs!!.attributeCount) { for (i in 0 until attrs!!.attributeCount) {
val attr = attrs.getAttributeName(i) val attr = attrs.getAttributeName(i)
@ -42,23 +47,23 @@ class ControllerPreference : Preference {
title = "${context?.getString(R.string.config_controller)} #${index + 1}" title = "${context?.getString(R.string.config_controller)} #${index + 1}"
if (context is SettingsActivity) if (context is SettingsActivity) {
summaryProvider = SummaryProvider<ControllerPreference> { _ -> context.inputManager.controllers[index]?.type?.stringRes?.let { context.getString(it) } } inputManager = context.inputManager
summaryProvider = SummaryProvider<ControllerPreference> { context.inputManager.controllers[index]?.type?.stringRes?.let { context.getString(it) } }
}
} }
constructor(context : Context?, attrs : AttributeSet?) : this(context, attrs, R.attr.preferenceStyle)
constructor(context : Context?) : this(context, null)
/** /**
* This launches [ControllerActivity] on click to configure the controller * This launches [ControllerActivity] on click to configure the controller
*/ */
override fun onClick() { override fun onClick() {
if (context is SettingsActivity) (context as Activity).startActivityForResult(Intent(context, ControllerActivity::class.java).apply { putExtra("index", index) }, requestCode)
(context as SettingsActivity).refreshKey = key }
val intent = Intent(context, ControllerActivity::class.java) override fun onActivityResult(requestCode : Int, resultCode : Int, data : Intent?) {
intent.putExtra("index", index) if (this.requestCode == requestCode) {
(context as Activity).startActivityForResult(intent, 0) inputManager?.syncObjects()
notifyChanged()
}
} }
} }

View File

@ -0,0 +1,55 @@
/*
* SPDX-License-Identifier: MPL-2.0
* Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/)
*/
package emu.skyline.preference
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.preference.PreferenceManager
/**
* This activity is used to launch a document picker and saves the result to preferences
*/
abstract class DocumentActivity : AppCompatActivity() {
companion object {
const val KEY_NAME = "key_name"
}
private lateinit var keyName : String
protected abstract val actionIntent : Intent
/**
* This launches the [Intent.ACTION_OPEN_DOCUMENT_TREE] intent on creation
*/
override fun onCreate(state : Bundle?) {
super.onCreate(state)
keyName = intent.getStringExtra(KEY_NAME)!!
this.startActivityForResult(actionIntent, 1)
}
/**
* This changes the search location preference if the [Intent.ACTION_OPEN_DOCUMENT_TREE] has returned and [finish]es the activity
*/
public override fun onActivityResult(requestCode : Int, resultCode : Int, data : Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode == Activity.RESULT_OK && requestCode == 1) {
val uri = data!!.data!!
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
PreferenceManager.getDefaultSharedPreferences(this).edit()
.putString(keyName, uri.toString())
.putBoolean("refresh_required", true)
.apply()
}
finish()
}
}

View File

@ -0,0 +1,18 @@
/*
* SPDX-License-Identifier: MPL-2.0
* Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/)
*/
package emu.skyline.preference
import android.content.Intent
/**
* Launches document picker to select one file
*/
class FileActivity : DocumentActivity() {
override val actionIntent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
}
}

View File

@ -0,0 +1,41 @@
/*
* SPDX-License-Identifier: MPL-2.0
* Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/)
*/
package emu.skyline.preference
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.AttributeSet
import androidx.preference.Preference
import androidx.preference.PreferenceManager
import com.google.android.material.snackbar.Snackbar
import emu.skyline.KeyReader
import emu.skyline.R
import emu.skyline.SettingsActivity
import kotlinx.android.synthetic.main.settings_activity.*
/**
* Launches [FileActivity] and process the selected file for key import
*/
class FilePreference @JvmOverloads constructor(context : Context?, attrs : AttributeSet? = null, defStyleAttr : Int = androidx.preference.R.attr.preferenceStyle) : Preference(context, attrs, defStyleAttr), ActivityResultDelegate {
override var requestCode = 0
override fun onClick() = (context as Activity).startActivityForResult(Intent(context, FileActivity::class.java).apply { putExtra(DocumentActivity.KEY_NAME, key) }, requestCode)
override fun onActivityResult(requestCode : Int, resultCode : Int, data : Intent?) {
if (this.requestCode == requestCode) {
if (key == "prod_keys" || key == "title_keys") {
val success = KeyReader.import(
context,
Uri.parse(PreferenceManager.getDefaultSharedPreferences(context).getString(key, "")),
KeyReader.KeyType.parse(key)
)
Snackbar.make((context as SettingsActivity).settings, if (success) R.string.import_keys_success else R.string.import_keys_failed, Snackbar.LENGTH_LONG).show()
}
}
}
}

View File

@ -5,45 +5,11 @@
package emu.skyline.preference package emu.skyline.preference
import android.app.Activity
import android.content.Intent import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.preference.PreferenceManager
/** /**
* This activity is used to select a new search location and set preferences to reflect that * Launches document picker to select a folder
*/ */
class FolderActivity : AppCompatActivity() { class FolderActivity : DocumentActivity() {
/** override val actionIntent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
* This launches the [Intent.ACTION_OPEN_DOCUMENT_TREE] intent on creation
*/
override fun onCreate(state : Bundle?) {
super.onCreate(state)
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
this.startActivityForResult(intent, 1)
}
/**
* This changes the search location preference if the [Intent.ACTION_OPEN_DOCUMENT_TREE] has returned and [finish]es the activity
*/
public override fun onActivityResult(requestCode : Int, resultCode : Int, data : Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode == Activity.RESULT_OK) {
if (requestCode == 1) {
val uri = data!!.data!!
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
PreferenceManager.getDefaultSharedPreferences(this).edit()
.putString("search_location", uri.toString())
.putBoolean("refresh_required", true)
.apply()
}
}
finish()
}
} }

View File

@ -13,43 +13,27 @@ import android.util.AttributeSet
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.Preference.SummaryProvider import androidx.preference.Preference.SummaryProvider
import androidx.preference.R import androidx.preference.R
import emu.skyline.SettingsActivity
/** /**
* This preference shows the decoded URI of it's preference and launches [FolderActivity] * This preference shows the decoded URI of it's preference and launches [DocumentActivity]
*/ */
class FolderPreference : Preference { class FolderPreference @JvmOverloads constructor(context : Context?, attrs : AttributeSet? = null, defStyleAttr : Int = R.attr.preferenceStyle) : Preference(context, attrs, defStyleAttr), ActivityResultDelegate {
/** override var requestCode = 0
* The directory the preference is currently set to
*/
private var mDirectory : String? = null
constructor(context : Context?, attrs : AttributeSet?, defStyleAttr : Int) : super(context, attrs, defStyleAttr) { init {
summaryProvider = SummaryProvider<FolderPreference> { preference -> summaryProvider = SummaryProvider<FolderPreference> { preference ->
preference.onSetInitialValue(null) Uri.decode(preference.getPersistedString(""))
Uri.decode(preference.mDirectory) ?: ""
} }
} }
constructor(context : Context?, attrs : AttributeSet?) : this(context, attrs, R.attr.preferenceStyle)
constructor(context : Context?) : this(context, null)
/** /**
* This launches [FolderActivity] on click to change the directory * This launches [DocumentActivity] on click to change the directory
*/ */
override fun onClick() { override fun onClick() {
if (context is SettingsActivity) (context as Activity).startActivityForResult(Intent(context, FolderActivity::class.java).apply { putExtra(DocumentActivity.KEY_NAME, key) }, requestCode)
(context as SettingsActivity).refreshKey = key
val intent = Intent(context, FolderActivity::class.java)
(context as Activity).startActivityForResult(intent, 0)
} }
/** override fun onActivityResult(requestCode : Int, resultCode : Int, data : Intent?) {
* This sets the initial value of [mDirectory] if (requestCode == requestCode) notifyChanged()
*/
override fun onSetInitialValue(defaultValue : Any?) {
mDirectory = getPersistedString(defaultValue as String?)
} }
} }

View File

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center_horizontal" android:layout_gravity="center_horizontal"
@ -13,7 +14,8 @@
android:layout_height="150dp" android:layout_height="150dp"
android:contentDescription="@string/icon" android:contentDescription="@string/icon"
android:focusable="false" android:focusable="false"
app:shapeAppearanceOverlay="@style/roundedAppImage" /> app:shapeAppearanceOverlay="@style/roundedAppImage"
tools:src="@drawable/default_icon" />
<LinearLayout <LinearLayout
android:layout_width="wrap_content" android:layout_width="wrap_content"
@ -26,7 +28,8 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceListItem" android:textAppearance="?android:attr/textAppearanceListItem"
android:textSize="18sp" /> android:textSize="18sp"
tools:text="Title" />
<TextView <TextView
android:id="@+id/game_subtitle" android:id="@+id/game_subtitle"
@ -34,7 +37,8 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceListItemSecondary" android:textAppearance="?android:attr/textAppearanceListItemSecondary"
android:textColor="@android:color/tertiary_text_light" android:textColor="@android:color/tertiary_text_light"
android:textSize="14sp" /> android:textSize="14sp"
tools:text="Subtitle" />
<LinearLayout <LinearLayout
android:id="@+id/linearLayout" android:id="@+id/linearLayout"

View File

@ -51,8 +51,9 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:ellipsize="marquee" android:ellipsize="marquee"
android:fadingEdge="horizontal"
android:marqueeRepeatLimit="marquee_forever" android:marqueeRepeatLimit="marquee_forever"
android:paddingStart="15dp"
android:paddingEnd="15dp"
android:paddingBottom="15dp" android:paddingBottom="15dp"
android:singleLine="true" android:singleLine="true"
android:textAlignment="center" android:textAlignment="center"

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/errorRecyclerView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:padding="16dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />

View File

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
app:cardElevation="0dp"
app:cardUseCompatPadding="true"
app:strokeColor="?attr/colorOnSurface"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/errorText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAllCaps="true"
android:textColor="#F0FF0000"
android:textStyle="bold"
tools:text="File" />
<TextView
android:id="@+id/fileText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
tools:text="Error" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
@ -18,6 +17,7 @@
app:layout_behavior="@string/appbar_scrolling_view_behavior" /> app:layout_behavior="@string/appbar_scrolling_view_behavior" />
<emu.skyline.views.CustomLinearLayout <emu.skyline.views.CustomLinearLayout
android:id="@+id/fab_parent"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="bottom|end" android:layout_gravity="bottom|end"
@ -30,7 +30,8 @@
android:focusable="true" android:focusable="true"
android:orientation="vertical" android:orientation="vertical"
android:translationX="72dp" android:translationX="72dp"
android:visibility="gone"> android:visibility="gone"
tools:visibility="visible">
<com.google.android.material.floatingactionbutton.FloatingActionButton <com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/refresh_fab" android:id="@+id/refresh_fab"

View File

@ -14,6 +14,9 @@
<string name="pin">Pin</string> <string name="pin">Pin</string>
<string name="play">Play</string> <string name="play">Play</string>
<string name="searching_roms">Searching for ROMs</string> <string name="searching_roms">Searching for ROMs</string>
<string name="invalid_file">Invalid file</string>
<string name="missing_title_key">Missing title key</string>
<string name="incomplete_prod_keys">Incomplete production keys</string>
<!-- Toolbar Logger --> <!-- Toolbar Logger -->
<string name="clear">Clear</string> <string name="clear">Clear</string>
<string name="share">Share</string> <string name="share">Share</string>
@ -42,6 +45,11 @@
<string name="docked_enabled">The system will emulate being in docked mode</string> <string name="docked_enabled">The system will emulate being in docked mode</string>
<string name="username">Username</string> <string name="username">Username</string>
<string name="username_default">@string/app_name</string> <string name="username_default">@string/app_name</string>
<string name="keys">Keys</string>
<string name="prod_keys">Production Keys</string>
<string name="title_keys">Title Keys</string>
<string name="import_keys_success">Successfully imported keys</string>
<string name="import_keys_failed">Failed to import keys</string>
<!-- Input --> <!-- Input -->
<string name="input">Input</string> <string name="input">Input</string>
<string name="show_osc">Show On-Screen Controls</string> <string name="show_osc">Show On-Screen Controls</string>

View File

@ -51,6 +51,18 @@
app:limit="31" app:limit="31"
app:title="@string/username" /> app:title="@string/username" />
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory
android:key="category_keys"
android:title="@string/keys">
<emu.skyline.preference.FilePreference
app:key="prod_keys"
app:title="@string/prod_keys"
app:useSimpleSummaryProvider="true" />
<emu.skyline.preference.FilePreference
app:key="title_keys"
app:title="@string/title_keys"
app:useSimpleSummaryProvider="true" />
</PreferenceCategory>
<PreferenceCategory <PreferenceCategory
android:key="category_system" android:key="category_system"
android:title="@string/system"> android:title="@string/system">

View File

@ -19,3 +19,7 @@ org.gradle.daemon=true
android.useAndroidX=true android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX # Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true android.enableJetifier=true
# Enables Prefab
android.enablePrefab=true
android.prefabVersion=1.1.0