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

@ -12,7 +12,7 @@ set(source_DIR ${CMAKE_SOURCE_DIR}/src/main/cpp)
set(CMAKE_CXX_FLAGS_RELEASE "-Ofast -flto=full -Wno-unused-command-line-argument")
if (uppercase_CMAKE_BUILD_TYPE STREQUAL "RELEASE")
add_compile_definitions(NDEBUG)
endif()
endif ()
set(CMAKE_POLICY_DEFAULT_CMP0048 OLD)
add_subdirectory("libraries/tinyxml2")
@ -24,6 +24,8 @@ include_directories("libraries/oboe/include")
include_directories("libraries/vkhpp/include")
set(CMAKE_POLICY_DEFAULT_CMP0048 NEW)
find_package(mbedtls REQUIRED CONFIG)
include_directories(${source_DIR}/skyline)
add_library(skyline SHARED
@ -38,6 +40,8 @@ add_library(skyline SHARED
${source_DIR}/skyline/audio/track.cpp
${source_DIR}/skyline/audio/resampler.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/macro_interpreter.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/vfs/os_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/os_backing.cpp
${source_DIR}/skyline/vfs/nacp.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")
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
}
}
androidExtensions {
experimental = true
}
externalNativeBuild {
cmake {
version "3.10.2+"
@ -63,7 +66,11 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.documentfile:documentfile:1.0.1'
implementation 'info.debatty:java-string-similarity:1.2.1'
implementation(name: 'mbedtls', ext: 'aar')
}
repositories {
mavenCentral()
flatDir {
dirs 'libraries'
}
}

BIN
app/libraries/mbedtls.aar Normal file

Binary file not shown.

View File

@ -1,9 +1,3 @@
# Skyline Proguard Rules
# For more details, see
# 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"
package="emu.skyline">
<uses-permission android:name="android.permission.READ_INTERNAL_STORAGE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-feature
@ -47,6 +46,11 @@
android:name="android.support.PARENT_ACTIVITY"
android:value="emu.skyline.SettingsActivity" />
</activity>
<activity android:name="emu.skyline.preference.FileActivity">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value="emu.skyline.SettingsActivity" />
</activity>
<activity
android:name="emu.skyline.input.ControllerActivity"
android:exported="true">

View File

@ -1,6 +1,8 @@
// SPDX-License-Identifier: MPL-2.0
// 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/loader/nro.h"
#include "skyline/loader/nso.h"
@ -8,54 +10,53 @@
#include "skyline/loader/nsp.h"
#include "skyline/jvm.h"
extern "C" JNIEXPORT jlong JNICALL Java_emu_skyline_loader_RomFile_initialize(JNIEnv *env, jobject thiz, jint jformat, jint fd) {
skyline::loader::RomFormat format = static_cast<skyline::loader::RomFormat>(jformat);
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)};
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 {
auto backing = std::make_shared<skyline::vfs::OsBacking>(fd);
auto backing{std::make_shared<skyline::vfs::OsBacking>(fd)};
switch (format) {
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:
return reinterpret_cast<jlong>(new skyline::loader::NsoLoader(backing));
loader = std::make_unique<skyline::loader::NsoLoader>(backing);
break;
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:
return reinterpret_cast<jlong>(new skyline::loader::NspLoader(backing));
loader = std::make_unique<skyline::loader::NspLoader>(backing, keyStore);
break;
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) {
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) {
return reinterpret_cast<skyline::loader::Loader *>(instance)->nacp != nullptr;
}
extern "C" JNIEXPORT jbyteArray JNICALL Java_emu_skyline_loader_RomFile_getIcon(JNIEnv *env, jobject thiz, jlong instance) {
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);
jclass clazz{env->GetObjectClass(thiz)};
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);
}
return static_cast<jint>(skyline::loader::LoaderResult::Success);
}

View File

@ -62,6 +62,19 @@ namespace skyline {
constexpr u16 DockedResolutionH = 1080; //!< The height component of the docked resolution
// Time
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 {
@ -159,6 +172,28 @@ namespace skyline {
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);
};
/**
* @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 JvmManager;
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
};
/**
* @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
*/

View File

@ -8,7 +8,7 @@
#include "nca.h"
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)
throw exception("Only NCAs with an ExeFS can be loaded directly");
}

View File

@ -5,6 +5,7 @@
#include <common.h>
#include <vfs/nca.h>
#include <crypto/key_store.h>
#include "loader.h"
namespace skyline::loader {
@ -16,7 +17,7 @@ namespace skyline::loader {
vfs::NCA nca; //!< The backing NCA of the loader
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

View File

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

View File

@ -7,6 +7,7 @@
#include <vfs/nca.h>
#include <vfs/rom_filesystem.h>
#include <vfs/partition_filesystem.h>
#include <crypto/key_store.h>
#include "loader.h"
namespace skyline::loader {
@ -21,7 +22,7 @@ namespace skyline::loader {
std::optional<vfs::NCA> controlNca; //!< The main control NCA within the NSP
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();

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) {}
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) {
state.loader = std::make_shared<loader::NroLoader>(romFile);
} else if (romType == loader::RomFormat::NSO) {
state.loader = std::make_shared<loader::NsoLoader>(romFile);
} 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) {
state.loader = std::make_shared<loader::NspLoader>(romFile);
state.loader = std::make_shared<loader::NspLoader>(romFile, keyStore);
} else {
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) {
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;
if (mprotect(reinterpret_cast<void *>(stack->guest.address), PAGE_SIZE, PROT_NONE))
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;
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)
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
// 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 "partition_filesystem.h"
#include "nca.h"
@ -8,17 +11,31 @@
#include "directory.h"
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);
if (header.magic != util::MakeMagic<u32>("NCA3"))
throw exception("Attempting to load an encrypted or invalid NCA");
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"))
throw loader_exception(LoaderResult::ParsingError);
encrypted = true;
}
contentType = header.contentType;
rightsIdEmpty = header.rightsId == crypto::KeyStore::Key128{};
for (size_t i = 0; i < header.sectionHeaders.size(); i++) {
auto &sectionHeader = header.sectionHeaders.at(i);
auto &sectionEntry = header.fsEntries.at(i);
for (size_t i{}; i < header.sectionHeaders.size(); i++) {
auto &sectionHeader{header.sectionHeaders.at(i)};
auto &sectionEntry{header.fsEntries.at(i)};
if (sectionHeader.fsType == NcaSectionFsType::PFS0 && sectionHeader.hashType == NcaSectionHashType::HierarchicalSha256)
ReadPfs0(sectionHeader, sectionEntry);
@ -27,11 +44,11 @@ namespace skyline::vfs {
}
}
void NCA::ReadPfs0(const NcaSectionHeader &header, const NcaFsEntry &entry) {
size_t offset = static_cast<size_t>(entry.startOffset) * constant::MediaUnitSize + header.sha256HashInfo.pfs0Offset;
size_t size = constant::MediaUnitSize * static_cast<size_t>(entry.endOffset - entry.startOffset);
void NCA::ReadPfs0(const NcaSectionHeader &sectionHeader, const NcaFsEntry &entry) {
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)};
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) {
// 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) {
size_t offset = static_cast<size_t>(entry.startOffset) * constant::MediaUnitSize + header.integrityHashInfo.levels.back().offset;
size_t size = header.integrityHashInfo.levels.back().size;
void NCA::ReadRomFs(const NcaSectionHeader &sectionHeader, const NcaFsEntry &entry) {
size_t offset{static_cast<size_t>(entry.startOffset) * constant::MediaUnitSize + sectionHeader.integrityHashInfo.levels.back().offset};
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
#include <array>
#include <crypto/key_store.h>
#include <crypto/aes_cipher.h>
#include "filesystem.h"
namespace skyline {
@ -195,10 +197,21 @@ namespace skyline {
static_assert(sizeof(NcaHeader) == 0xC00);
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:
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
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"
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 (!CreateDirectory(basePath, true))
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);
fileDataOffset = stringTableOffset + header.stringTableSize;
std::vector<char> stringTable(header.stringTableSize);
std::vector<char> stringTable(header.stringTableSize + 1);
backing->Read(stringTable.data(), stringTableOffset, header.stringTableSize);
stringTable[header.stringTableSize] = 0;
for (u32 i = 0; i < header.numFiles; i++) {
PartitionFileEntry entry{};
backing->Read(&entry, sizeof(FsHeader) + i * entrySize);
for (u32 entryOffset{sizeof(FsHeader)}; entryOffset < header.numFiles * entrySize; entryOffset += entrySize) {
PartitionFileEntry entry;
backing->Read(&entry, entryOffset);
std::string name(&stringTable[entry.stringTableOffset]);
fileMap.emplace(name, std::move(entry));

View File

@ -12,7 +12,7 @@ namespace skyline::vfs {
class RegionBacking : public Backing {
private:
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:
/**
@ -20,7 +20,7 @@ namespace skyline::vfs {
* @param offset The offset of the region start within 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) {
if (!mode.read)
@ -28,7 +28,7 @@ namespace skyline::vfs {
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.BottomSheetDialogFragment
import emu.skyline.data.AppItem
import emu.skyline.loader.LoaderResult
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
*
* @param item This is used to hold the [AppItem] between instances
*/
class AppDialog(val item : AppItem) : BottomSheetDialogFragment() {
class AppDialog : BottomSheetDialogFragment() {
companion object {
/**
* @param item This is used to hold the [AppItem] between instances
*/
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
@ -36,6 +50,12 @@ class AppDialog(val item : AppItem) : BottomSheetDialogFragment() {
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
*/
@ -64,13 +84,11 @@ class AppDialog(val item : AppItem) : BottomSheetDialogFragment() {
game_icon.setImageBitmap(item.icon ?: missingIcon)
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 {
val intent = Intent(activity, EmulationActivity::class.java)
intent.data = item.uri
startActivity(intent)
startActivity(Intent(activity, EmulationActivity::class.java).apply { data = item.uri })
}
val shortcutManager = requireActivity().getSystemService(ShortcutManager::class.java)

View File

@ -21,6 +21,10 @@ import java.io.File
import kotlin.math.abs
class EmulationActivity : AppCompatActivity(), SurfaceHolder.Callback, View.OnTouchListener {
companion object {
private val Tag = EmulationActivity::class.java.name
}
init {
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
*/
override fun surfaceCreated(holder : SurfaceHolder) {
Log.d("surfaceCreated", "Holder: $holder")
Log.d(Tag, "surfaceCreated Holder: $holder")
surface = holder.surface
setSurface(surface)
surfaceReady.open()
@ -297,14 +301,14 @@ class EmulationActivity : AppCompatActivity(), SurfaceHolder.Callback, View.OnTo
* This is purely used for debugging surface changes
*/
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
*/
override fun surfaceDestroyed(holder : SurfaceHolder) {
Log.d("surfaceDestroyed", "Holder: $holder")
Log.d(Tag, "surfaceDestroyed Holder: $holder")
surfaceReady.close()
surface = null
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.LayoutType
import emu.skyline.data.AppItem
import emu.skyline.loader.LoaderResult
import emu.skyline.loader.RomFile
import emu.skyline.loader.RomFormat
import kotlinx.android.synthetic.main.main_activity.*
import kotlinx.android.synthetic.main.titlebar.*
import java.io.File
import java.io.IOException
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.concurrent.thread
import kotlin.math.ceil
class MainActivity : AppCompatActivity(), View.OnClickListener {
class MainActivity : AppCompatActivity() {
/**
* This is used to get/set shared preferences
*/
@ -50,8 +52,10 @@ class MainActivity : AppCompatActivity(), View.OnClickListener {
*/
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 {
var foundCurrent = found
@ -61,25 +65,16 @@ class MainActivity : AppCompatActivity(), View.OnClickListener {
foundCurrent = addEntries(extension, romFormat, file, foundCurrent)
} else {
if (extension.equals(file.name?.substringAfterLast("."), ignoreCase = true)) {
val romFd = contentResolver.openFileDescriptor(file.uri, "r")!!
val romFile = RomFile(this, romFormat, romFd)
RomFile(this, romFormat, file.uri).let { romFile ->
val finalFoundCurrent = foundCurrent
runOnUiThread {
if (!finalFoundCurrent) adapter.addHeader(romFormat.name)
if (romFile.valid()) {
romFile.use {
val entry = romFile.getAppEntry(file.uri)
val finalFoundCurrent = foundCurrent
runOnUiThread {
if (!finalFoundCurrent) adapter.addHeader(romFormat.name)
adapter.addItem(AppItem(entry))
}
foundCurrent = true
adapter.addItem(AppItem(romFile.appEntry))
}
}
romFd.close()
foundCurrent = true
}
}
}
}
@ -102,6 +97,7 @@ class MainActivity : AppCompatActivity(), View.OnClickListener {
}
}
if (reloading.getAndSet(true)) return
thread(start = true) {
val snackbar = Snackbar.make(coordinatorLayout, getString(R.string.searching_roms), Snackbar.LENGTH_INDEFINITE)
runOnUiThread {
@ -148,6 +144,8 @@ class MainActivity : AppCompatActivity(), View.OnClickListener {
snackbar.dismiss()
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
reloading.set(false)
}
}
@ -171,10 +169,18 @@ class MainActivity : AppCompatActivity(), View.OnClickListener {
else -> AppCompatDelegate.MODE_NIGHT_UNSPECIFIED
})
refresh_fab.setOnClickListener(this)
settings_fab.setOnClickListener(this)
open_fab.setOnClickListener(this)
log_fab.setOnClickListener(this)
refresh_fab.setOnClickListener { refreshAdapter(false) }
settings_fab.setOnClickListener { startActivityForResult(Intent(this, SettingsActivity::class.java), 3) }
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()
@ -217,7 +223,7 @@ class MainActivity : AppCompatActivity(), View.OnClickListener {
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.layoutManager = when (layoutType) {
@ -263,37 +269,15 @@ class MainActivity : AppCompatActivity(), View.OnClickListener {
return super.onCreateOptionsMenu(menu)
}
/**
* This handles on-click interaction with [R.id.refresh_fab], [R.id.settings_fab], [R.id.log_fab], [R.id.open_fab]
*/
override fun onClick(view : View) {
when (view.id) {
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 fun selectStartGame(appItem : AppItem) {
if (sharedPreferences.getBoolean("select_action", false))
AppDialog.newInstance(appItem).show(supportFragmentManager, "game")
else if (appItem.loaderResult == LoaderResult.Success)
startActivity(Intent(this, EmulationActivity::class.java).apply { data = appItem.uri })
}
private val selectStartGame : (appItem : AppItem) -> Unit = {
if (sharedPreferences.getBoolean("select_action", false)) {
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")
private fun selectShowGameDialog(appItem : AppItem) {
AppDialog.newInstance(appItem).show(supportFragmentManager, "game")
}
/**
@ -363,8 +347,8 @@ class MainActivity : AppCompatActivity(), View.OnClickListener {
val gridCardMagin = resources.getDimensionPixelSize(R.dimen.app_card_margin_half)
when (layoutType) {
LayoutType.List -> app_list.setPadding(0, 0, 0, 0)
LayoutType.Grid, LayoutType.GridCompact -> app_list.setPadding(gridCardMagin, 0, gridCardMagin, 0)
LayoutType.List -> app_list.post { app_list.setPadding(0, 0, 0, fab_parent.height) }
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.view.KeyEvent
import androidx.appcompat.app.AppCompatActivity
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceGroup
import emu.skyline.input.InputManager
import emu.skyline.preference.ActivityResultDelegate
import emu.skyline.preference.ControllerPreference
import emu.skyline.preference.DocumentActivity
import kotlinx.android.synthetic.main.settings_activity.*
import kotlinx.android.synthetic.main.titlebar.*
class SettingsActivity : AppCompatActivity() {
@ -26,11 +29,6 @@ class SettingsActivity : AppCompatActivity() {
*/
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
*/
@ -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?) {
super.onActivityResult(requestCode, resultCode, data)
refreshKey?.let {
inputManager.syncObjects()
preferenceFragment.refreshPreference(refreshKey!!)
preferenceFragment.delegateActivityResult(requestCode, resultCode, data)
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() {
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) {
val preference = preferenceManager.findPreference<Preference>(key)!!
preference.isSelectable = !preference.isSelectable
preference.isSelectable = !preference.isSelectable
fun delegateActivityResult(requestCode : Int, resultCode : Int, data : Intent?) {
preferenceScreen.delegateActivityResult(requestCode, resultCode, data)
}
/**
@ -82,6 +77,25 @@ class SettingsActivity : AppCompatActivity() {
*/
override fun onCreatePreferences(savedInstanceState : Bundle?, rootKey : String?) {
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
*/
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 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]
@ -105,7 +104,7 @@ internal class AppAdapter(val layoutType : LayoutType, private val gridSpan : In
if (item is AppItem && holder is ItemViewHolder) {
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)

View File

@ -5,9 +5,12 @@
package emu.skyline.data
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import emu.skyline.R
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
@ -43,6 +46,20 @@ class AppItem(val meta : AppEntry) : BaseItem() {
private val type : String
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
*/

View File

@ -61,23 +61,19 @@ class InputManager constructor(val context : Context) {
* This function syncs the class with data from [file]
*/
fun syncObjects() {
val fileInput = FileInputStream(file)
val objectInput = ObjectInputStream(fileInput)
ObjectInputStream(FileInputStream(file)).use {
@Suppress("UNCHECKED_CAST")
controllers = it.readObject() as HashMap<Int, Controller>
@Suppress("UNCHECKED_CAST")
controllers = objectInput.readObject() as HashMap<Int, Controller>
@Suppress("UNCHECKED_CAST")
eventMap = objectInput.readObject() as HashMap<HostEvent?, GuestEvent?>
@Suppress("UNCHECKED_CAST")
eventMap = it.readObject() as HashMap<HostEvent?, GuestEvent?>
}
}
/**
* This function syncs [file] with data from the class and eliminates unused value from the map
*/
fun syncFile() {
val fileOutput = FileOutputStream(file)
val objectOutput = ObjectOutputStream(fileOutput)
for (controller in controllers.values) {
for (button in ButtonId.values()) {
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)
objectOutput.writeObject(eventMap)
ObjectOutputStream(FileOutputStream(file)).use {
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.BitmapFactory
import android.net.Uri
import android.os.Build
import android.os.ParcelFileDescriptor
import android.provider.OpenableColumns
import java.io.IOException
import java.io.ObjectInputStream
import java.io.ObjectOutputStream
import java.io.Serializable
import java.util.*
@ -46,166 +41,81 @@ fun getRomFormat(uri : Uri, contentResolver : ContentResolver) : RomFormat {
return RomFormat.valueOf(uriStr.substring(uriStr.lastIndexOf(".") + 1).toUpperCase(Locale.ROOT))
}
/**
* An enumeration of all possible results when populating [RomFile]
*/
enum class LoaderResult(val value : Int) {
Success(0),
ParsingError(1),
MissingHeaderKey(2),
MissingTitleKey(3),
MissingTitleKek(4),
MissingKeyArea(5);
companion object {
fun get(value : Int) = values().first { value == it.value }
}
}
/**
* This class is used to hold an application's metadata in a serializable way
*/
class AppEntry : Serializable {
/**
* The name of the application
*/
var name : String
/**
* The author of the application, if it can be extracted from the metadata
*/
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 ->
val nameIndex : Int = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
cursor.moveToFirst()
cursor.getString(nameIndex)
}!!.dropLast(format.name.length + 1)
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)
}
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)
cursor.moveToFirst()
cursor.getString(nameIndex)
}!!.dropLast(format.name.length + 1), null, null, format, uri, loaderResult)
}
/**
* 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 {
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 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
*/
private external fun initialize(format : Int, romFd : Int) : Long
/**
* @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
}
}
private external fun populate(format : Int, romFd : Int, appFilesPath : String) : Int
}

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.SettingsActivity
import emu.skyline.input.ControllerActivity
import emu.skyline.input.InputManager
/**
* 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
*/
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) {
val attr = attrs.getAttributeName(i)
@ -42,23 +47,23 @@ class ControllerPreference : Preference {
title = "${context?.getString(R.string.config_controller)} #${index + 1}"
if (context is SettingsActivity)
summaryProvider = SummaryProvider<ControllerPreference> { _ -> context.inputManager.controllers[index]?.type?.stringRes?.let { context.getString(it) } }
if (context is SettingsActivity) {
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
*/
override fun onClick() {
if (context is SettingsActivity)
(context as SettingsActivity).refreshKey = key
(context as Activity).startActivityForResult(Intent(context, ControllerActivity::class.java).apply { putExtra("index", index) }, requestCode)
}
val intent = Intent(context, ControllerActivity::class.java)
intent.putExtra("index", index)
(context as Activity).startActivityForResult(intent, 0)
override fun onActivityResult(requestCode : Int, resultCode : Int, data : Intent?) {
if (this.requestCode == requestCode) {
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

@ -1,49 +1,15 @@
/*
* 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 select a new search location and set preferences to reflect that
*/
class FolderActivity : AppCompatActivity() {
/**
* 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()
}
}
/*
* 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 a folder
*/
class FolderActivity : DocumentActivity() {
override val actionIntent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
}

View File

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

View File

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

View File

@ -51,8 +51,9 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="marquee"
android:fadingEdge="horizontal"
android:marqueeRepeatLimit="marquee_forever"
android:paddingStart="15dp"
android:paddingEnd="15dp"
android:paddingBottom="15dp"
android:singleLine="true"
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"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout 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"
@ -18,6 +17,7 @@
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
<emu.skyline.views.CustomLinearLayout
android:id="@+id/fab_parent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
@ -30,7 +30,8 @@
android:focusable="true"
android:orientation="vertical"
android:translationX="72dp"
android:visibility="gone">
android:visibility="gone"
tools:visibility="visible">
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/refresh_fab"

View File

@ -14,6 +14,9 @@
<string name="pin">Pin</string>
<string name="play">Play</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 -->
<string name="clear">Clear</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="username">Username</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 -->
<string name="input">Input</string>
<string name="show_osc">Show On-Screen Controls</string>

View File

@ -51,6 +51,18 @@
app:limit="31"
app:title="@string/username" />
</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
android:key="category_system"
android:title="@string/system">

View File

@ -18,4 +18,8 @@ org.gradle.daemon=true
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true
android.enableJetifier=true
# Enables Prefab
android.enablePrefab=true
android.prefabVersion=1.1.0