mirror of
https://github.com/skyline-emu/skyline.git
synced 2024-11-26 21:34:16 +01:00
Introduce new loader JNI for parsing application data and port Kotlin
code to use it This will help ease the process of implementing new formats in the future and remove duplicated code.
This commit is contained in:
parent
dca06f2b49
commit
1bb979a7e1
@ -25,7 +25,8 @@ set(CMAKE_POLICY_DEFAULT_CMP0048 NEW)
|
|||||||
include_directories(${source_DIR}/skyline)
|
include_directories(${source_DIR}/skyline)
|
||||||
|
|
||||||
add_library(skyline SHARED
|
add_library(skyline SHARED
|
||||||
${source_DIR}/main.cpp
|
${source_DIR}/emu_jni.cpp
|
||||||
|
${source_DIR}/loader_jni.cpp
|
||||||
${source_DIR}/skyline/common.cpp
|
${source_DIR}/skyline/common.cpp
|
||||||
${source_DIR}/skyline/nce/guest.S
|
${source_DIR}/skyline/nce/guest.S
|
||||||
${source_DIR}/skyline/nce/guest.cpp
|
${source_DIR}/skyline/nce/guest.cpp
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#include <csignal>
|
#include <csignal>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
|
#include "skyline/loader/loader.h"
|
||||||
#include "skyline/common.h"
|
#include "skyline/common.h"
|
||||||
#include "skyline/os.h"
|
#include "skyline/os.h"
|
||||||
#include "skyline/jvm.h"
|
#include "skyline/jvm.h"
|
||||||
@ -50,7 +51,7 @@ extern "C" JNIEXPORT void Java_emu_skyline_EmulationActivity_executeApplication(
|
|||||||
auto romUri = env->GetStringUTFChars(romUriJstring, nullptr);
|
auto romUri = env->GetStringUTFChars(romUriJstring, nullptr);
|
||||||
logger->Info("Launching ROM {}", romUri);
|
logger->Info("Launching ROM {}", romUri);
|
||||||
env->ReleaseStringUTFChars(romUriJstring, romUri);
|
env->ReleaseStringUTFChars(romUriJstring, romUri);
|
||||||
os.Execute(romFd, static_cast<skyline::TitleFormat>(romType));
|
os.Execute(romFd, static_cast<skyline::loader::RomFormat>(romType));
|
||||||
} catch (std::exception &e) {
|
} catch (std::exception &e) {
|
||||||
logger->Error(e.what());
|
logger->Error(e.what());
|
||||||
} catch (...) {
|
} catch (...) {
|
52
app/src/main/cpp/loader_jni.cpp
Normal file
52
app/src/main/cpp/loader_jni.cpp
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
// Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/)
|
||||||
|
|
||||||
|
#include "skyline/vfs/os_backing.h"
|
||||||
|
#include "skyline/loader/nro.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);
|
||||||
|
|
||||||
|
try {
|
||||||
|
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));
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
} catch (const std::exception &e) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
@ -28,9 +28,8 @@ import emu.skyline.adapter.AppAdapter
|
|||||||
import emu.skyline.adapter.AppItem
|
import emu.skyline.adapter.AppItem
|
||||||
import emu.skyline.adapter.GridLayoutSpan
|
import emu.skyline.adapter.GridLayoutSpan
|
||||||
import emu.skyline.adapter.LayoutType
|
import emu.skyline.adapter.LayoutType
|
||||||
import emu.skyline.loader.BaseLoader
|
import emu.skyline.loader.RomFile
|
||||||
import emu.skyline.loader.NroLoader
|
import emu.skyline.loader.RomFormat
|
||||||
import emu.skyline.utility.RandomAccessDocument
|
|
||||||
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
|
||||||
@ -52,22 +51,24 @@ class MainActivity : AppCompatActivity(), View.OnClickListener, View.OnLongClick
|
|||||||
/**
|
/**
|
||||||
* 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 [loader] to load metadata
|
||||||
*/
|
*/
|
||||||
private fun addEntries(extension : String, loader : BaseLoader, 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
|
||||||
|
|
||||||
directory.listFiles().forEach { file ->
|
directory.listFiles().forEach { file ->
|
||||||
if (file.isDirectory) {
|
if (file.isDirectory) {
|
||||||
foundCurrent = addEntries(extension, loader, 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 document = RandomAccessDocument(this, file)
|
val romFd = contentResolver.openFileDescriptor(file.uri, "r")!!
|
||||||
|
val romFile = RomFile(this, romFormat, romFd)
|
||||||
|
|
||||||
if (loader.verifyFile(document)) {
|
if (romFile.valid()) {
|
||||||
val entry = loader.getAppEntry(document, file.uri)
|
romFile.use {
|
||||||
|
val entry = romFile.getAppEntry(file.uri)
|
||||||
|
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
if (!foundCurrent) {
|
if (!foundCurrent) {
|
||||||
adapter.addHeader(loader.format.name)
|
adapter.addHeader(romFormat.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
adapter.addItem(AppItem(entry))
|
adapter.addItem(AppItem(entry))
|
||||||
@ -75,8 +76,9 @@ class MainActivity : AppCompatActivity(), View.OnClickListener, View.OnLongClick
|
|||||||
|
|
||||||
foundCurrent = true
|
foundCurrent = true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.close()
|
romFd.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -109,7 +111,7 @@ class MainActivity : AppCompatActivity(), View.OnClickListener, View.OnLongClick
|
|||||||
try {
|
try {
|
||||||
runOnUiThread { adapter.clear() }
|
runOnUiThread { adapter.clear() }
|
||||||
|
|
||||||
val foundNros = addEntries("nro", NroLoader(this), DocumentFile.fromTreeUri(this, Uri.parse(sharedPreferences.getString("search_location", "")))!!)
|
val foundNros = addEntries("nro", RomFormat.NRO, DocumentFile.fromTreeUri(this, Uri.parse(sharedPreferences.getString("search_location", "")))!!)
|
||||||
|
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
if (!foundNros)
|
if (!foundNros)
|
||||||
|
@ -1,75 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-License-Identifier: MPL-2.0
|
|
||||||
* Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/)
|
|
||||||
*/
|
|
||||||
|
|
||||||
package emu.skyline.loader
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.net.Uri
|
|
||||||
import emu.skyline.utility.RandomAccessDocument
|
|
||||||
import java.io.IOException
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This loader is used to load in NRO (Nintendo Relocatable Object) files (https://switchbrew.org/wiki/NRO)
|
|
||||||
*/
|
|
||||||
internal class NroLoader(context : Context) : BaseLoader(context, RomFormat.NRO) {
|
|
||||||
/**
|
|
||||||
* This is used to get the [AppEntry] for the specified NRO
|
|
||||||
*/
|
|
||||||
override fun getAppEntry(file : RandomAccessDocument, uri : Uri) : AppEntry {
|
|
||||||
return try {
|
|
||||||
file.seek(0x18) // Skip to NroHeader.size
|
|
||||||
|
|
||||||
val asetOffset = Integer.reverseBytes(file.readInt())
|
|
||||||
file.seek(asetOffset.toLong()) // Skip to the offset specified by NroHeader.size
|
|
||||||
|
|
||||||
val buffer = ByteArray(4)
|
|
||||||
file.read(buffer)
|
|
||||||
if (String(buffer) != "ASET") throw IOException()
|
|
||||||
file.skipBytes(0x4)
|
|
||||||
|
|
||||||
val iconOffset = java.lang.Long.reverseBytes(file.readLong())
|
|
||||||
val iconSize = Integer.reverseBytes(file.readInt())
|
|
||||||
if (iconOffset == 0L || iconSize == 0) throw IOException()
|
|
||||||
file.seek(asetOffset + iconOffset)
|
|
||||||
|
|
||||||
val iconData = ByteArray(iconSize)
|
|
||||||
file.read(iconData)
|
|
||||||
val icon = BitmapFactory.decodeByteArray(iconData, 0, iconSize)
|
|
||||||
file.seek(asetOffset + 0x18.toLong())
|
|
||||||
|
|
||||||
val nacpOffset = java.lang.Long.reverseBytes(file.readLong())
|
|
||||||
val nacpSize = java.lang.Long.reverseBytes(file.readLong())
|
|
||||||
if (nacpOffset == 0L || nacpSize == 0L) throw IOException()
|
|
||||||
file.seek(asetOffset + nacpOffset)
|
|
||||||
|
|
||||||
val name = ByteArray(0x200)
|
|
||||||
file.read(name)
|
|
||||||
|
|
||||||
val author = ByteArray(0x100)
|
|
||||||
file.read(author)
|
|
||||||
|
|
||||||
AppEntry(String(name).substringBefore((0.toChar())), String(author).substringBefore((0.toChar())), format, uri, icon)
|
|
||||||
} catch (e : IOException) {
|
|
||||||
AppEntry(context, format, uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This verifies if [file] is a valid NRO file
|
|
||||||
*/
|
|
||||||
override fun verifyFile(file : RandomAccessDocument) : Boolean {
|
|
||||||
try {
|
|
||||||
file.seek(0x10) // Skip to NroHeader.magic
|
|
||||||
|
|
||||||
val buffer = ByteArray(4)
|
|
||||||
file.read(buffer)
|
|
||||||
if (String(buffer) != "NRO0") return false
|
|
||||||
} catch (e : IOException) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
@ -10,8 +10,9 @@ 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.ParcelFileDescriptor
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
import emu.skyline.utility.RandomAccessDocument
|
import android.view.Surface
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.ObjectInputStream
|
import java.io.ObjectInputStream
|
||||||
import java.io.ObjectOutputStream
|
import java.io.ObjectOutputStream
|
||||||
@ -21,10 +22,10 @@ import java.util.*
|
|||||||
/**
|
/**
|
||||||
* An enumeration of all supported ROM formats
|
* An enumeration of all supported ROM formats
|
||||||
*/
|
*/
|
||||||
enum class RomFormat {
|
enum class RomFormat(val format: Int){
|
||||||
NRO,
|
NRO(0),
|
||||||
XCI,
|
XCI(1),
|
||||||
NSP,
|
NSP(2),
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -69,7 +70,7 @@ class AppEntry : Serializable {
|
|||||||
*/
|
*/
|
||||||
var uri : Uri
|
var uri : Uri
|
||||||
|
|
||||||
constructor(name : String, author : String, format : RomFormat, uri : Uri, icon : Bitmap) {
|
constructor(name : String, author : String, format : RomFormat, uri : Uri, icon : Bitmap?) {
|
||||||
this.name = name
|
this.name = name
|
||||||
this.author = author
|
this.author = author
|
||||||
this.icon = icon
|
this.icon = icon
|
||||||
@ -123,16 +124,81 @@ class AppEntry : Serializable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This class is used as the base class for all loaders
|
* This class is used as interface between libskyline and Kotlin for loaders
|
||||||
*/
|
*/
|
||||||
internal abstract class BaseLoader(val context : Context, val format : RomFormat) {
|
internal class RomFile(val context : Context, val format : RomFormat, val file : ParcelFileDescriptor) : AutoCloseable {
|
||||||
/**
|
/**
|
||||||
* This is used to get the [AppEntry] for the specified [file] at the supplied [uri]
|
* This is a pointer to the corresponding C++ Loader class
|
||||||
*/
|
*/
|
||||||
abstract fun getAppEntry(file : RandomAccessDocument, uri : Uri) : AppEntry
|
var instance : Long
|
||||||
|
|
||||||
|
init {
|
||||||
|
System.loadLibrary("skyline")
|
||||||
|
|
||||||
|
instance = initialize(format.ordinal, file.fd)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This returns if the supplied [file] is a valid ROM or not
|
* This allocates and initializes a new loader object
|
||||||
|
* @param format The format of the ROM
|
||||||
|
* @param romFd A file descriptor of the ROM
|
||||||
|
* @return A pointer to the newly allocated object, or 0 if the ROM is invalid
|
||||||
*/
|
*/
|
||||||
abstract fun verifyFile(file : RandomAccessDocument) : Boolean
|
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 NRO
|
||||||
|
*/
|
||||||
|
fun getAppEntry(uri : Uri) : AppEntry {
|
||||||
|
return if (hasAssets(instance)) {
|
||||||
|
val rawIcon = getIcon(instance)
|
||||||
|
val icon = if (rawIcon.size != 0) 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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,94 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-License-Identifier: MPL-2.0
|
|
||||||
* Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/)
|
|
||||||
*/
|
|
||||||
|
|
||||||
package emu.skyline.utility
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.ParcelFileDescriptor
|
|
||||||
import androidx.documentfile.provider.DocumentFile
|
|
||||||
import java.nio.ByteBuffer
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This is made as a parallel to [java.io.RandomAccessFile] for [DocumentFile]s
|
|
||||||
*
|
|
||||||
* @param parcelFileDescriptor The file descriptor for the [DocumentFile]
|
|
||||||
*/
|
|
||||||
class RandomAccessDocument(private var parcelFileDescriptor : ParcelFileDescriptor) {
|
|
||||||
/**
|
|
||||||
* The actual file descriptor for the [DocumentFile] as an [FileDescriptor] object
|
|
||||||
*/
|
|
||||||
private val fileDescriptor = parcelFileDescriptor.fileDescriptor
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The current position of where the file is being read
|
|
||||||
*/
|
|
||||||
private var position : Long = 0
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The constructor sets [parcelFileDescriptor] by opening a read-only FD to [file]
|
|
||||||
*/
|
|
||||||
constructor(context : Context, file : DocumentFile) : this(context.contentResolver.openFileDescriptor(file.uri, "r")!!)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This reads in as many as possible bytes into [array] (Generally [array].size)
|
|
||||||
*
|
|
||||||
* @return The amount of bytes read from the file
|
|
||||||
*/
|
|
||||||
fun read(array : ByteArray) : Int {
|
|
||||||
val bytesRead = android.system.Os.pread(fileDescriptor, array, 0, array.size, position)
|
|
||||||
position += bytesRead
|
|
||||||
return bytesRead
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This reads in as many as possible bytes into [buffer] (Generally [buffer].array().size)
|
|
||||||
*
|
|
||||||
* @return The amount of bytes read from the file
|
|
||||||
*/
|
|
||||||
fun read(buffer : ByteBuffer) : Int {
|
|
||||||
val bytesRead = android.system.Os.pread(fileDescriptor, buffer.array(), 0, buffer.array().size, position)
|
|
||||||
position += bytesRead
|
|
||||||
return bytesRead
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This returns a single [Long] from the file at the current [position]
|
|
||||||
*/
|
|
||||||
fun readLong() : Long {
|
|
||||||
val buffer : ByteBuffer = ByteBuffer.allocate(Long.SIZE_BYTES)
|
|
||||||
read(buffer)
|
|
||||||
return buffer.long
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This returns a single [Int] from the file at the current [position]
|
|
||||||
*/
|
|
||||||
fun readInt() : Int {
|
|
||||||
val buffer : ByteBuffer = ByteBuffer.allocate(Int.SIZE_BYTES)
|
|
||||||
read(buffer)
|
|
||||||
return buffer.int
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This sets [RandomAccessDocument.position] to the supplied [position]
|
|
||||||
*/
|
|
||||||
fun seek(position : Long) {
|
|
||||||
this.position = position
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This increments [position] by [amount]
|
|
||||||
*/
|
|
||||||
fun skipBytes(amount : Long) {
|
|
||||||
this.position += amount
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This closes [parcelFileDescriptor] so this class doesn't leak file descriptors
|
|
||||||
*/
|
|
||||||
fun close() {
|
|
||||||
parcelFileDescriptor.close()
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in New Issue
Block a user