[#664] Check sapling files size
* [#664] Transfer maximum bytes from remote file * Tests for limiting sapling files max size * Address comments from review * Optimization of sapling files download * Add units to data class Co-authored-by: Carter Jernigan <git@carterjernigan.com>
This commit is contained in:
parent
1fae5beab6
commit
12c23dd054
|
@ -1,85 +1,137 @@
|
||||||
package cash.z.ecc.android.sdk.internal
|
package cash.z.ecc.android.sdk.internal
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import cash.z.ecc.fixture.SaplingParamsFixture
|
||||||
import cash.z.ecc.android.sdk.ext.ZcashSdk
|
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Ignore
|
import org.junit.Ignore
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import kotlin.test.assertContains
|
||||||
import kotlin.test.assertFalse
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
@Ignore(
|
@Ignore(
|
||||||
"These tests need to be refactored to a separate test module. They cause SSLHandshakeException: Chain " +
|
"These tests need to be refactored to a separate test module. They cause SSLHandshakeException: Chain " +
|
||||||
"validation failed on CI"
|
"validation failed on CI"
|
||||||
)
|
)
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
class SaplingParamToolTest {
|
class SaplingParamToolTest {
|
||||||
|
|
||||||
val context: Context = InstrumentationRegistry.getInstrumentation().context
|
private val spendSaplingParams = SaplingParamsFixture.newFile()
|
||||||
|
|
||||||
val cacheDir = "${context.cacheDir.absolutePath}/params"
|
private val outputSaplingParams = SaplingParamsFixture.newFile(
|
||||||
|
SaplingParamsFixture.DESTINATION_DIRECTORY,
|
||||||
|
SaplingParamsFixture.OUTPUT_FILE_NAME,
|
||||||
|
SaplingParamsFixture.OUTPUT_FILE_MAX_SIZE
|
||||||
|
)
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setup() {
|
fun setup() {
|
||||||
// clear the param files
|
// clear the param files
|
||||||
runBlocking { SaplingParamTool.clear(cacheDir) }
|
runBlocking { SaplingParamTool.clear(spendSaplingParams.destinationDirectoryPath) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Ignore("This test is broken")
|
fun test_files_exists() = runBlocking {
|
||||||
fun testFilesExists() = runBlocking {
|
|
||||||
// Given
|
// Given
|
||||||
SaplingParamTool.fetchParams(cacheDir)
|
SaplingParamTool.fetchParams(spendSaplingParams)
|
||||||
|
SaplingParamTool.fetchParams(outputSaplingParams)
|
||||||
|
|
||||||
// When
|
// When
|
||||||
val result = SaplingParamTool.validate(cacheDir)
|
val result = SaplingParamTool.validate(SaplingParamsFixture.DESTINATION_DIRECTORY)
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
assertFalse(result)
|
assertTrue(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun output_file_exists() = runBlocking {
|
fun output_file_exists() = runBlocking {
|
||||||
// Given
|
// Given
|
||||||
SaplingParamTool.fetchParams(cacheDir)
|
SaplingParamTool.fetchParams(spendSaplingParams)
|
||||||
File(cacheDir, ZcashSdk.OUTPUT_PARAM_FILE_NAME).delete()
|
File(spendSaplingParams.destinationDirectoryPath, spendSaplingParams.fileName).delete()
|
||||||
|
|
||||||
// When
|
// When
|
||||||
val result = SaplingParamTool.validate(cacheDir)
|
val result = SaplingParamTool.validate(spendSaplingParams.destinationDirectoryPath)
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
assertFalse(result, "Validation should fail when the spend params are missing")
|
assertFalse(result, "Validation should fail when the spend params are missing")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun param_file_exists() = runBlocking {
|
fun spend_file_exists() = runBlocking {
|
||||||
// Given
|
// Given
|
||||||
SaplingParamTool.fetchParams(cacheDir)
|
SaplingParamTool.fetchParams(outputSaplingParams)
|
||||||
File(cacheDir, ZcashSdk.SPEND_PARAM_FILE_NAME).delete()
|
File(outputSaplingParams.destinationDirectoryPath, outputSaplingParams.fileName).delete()
|
||||||
|
|
||||||
// When
|
// When
|
||||||
val result = SaplingParamTool.validate(cacheDir)
|
val result = SaplingParamTool.validate(outputSaplingParams.destinationDirectoryPath)
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
assertFalse(result, "Validation should fail when the spend params are missing")
|
assertFalse(result, "Validation should fail when the output params are missing")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testInsufficientDeviceStorage() = runBlocking {
|
fun testInsufficientDeviceStorage() = runBlocking {
|
||||||
// Given
|
// Given
|
||||||
SaplingParamTool.fetchParams(cacheDir)
|
SaplingParamTool.fetchParams(spendSaplingParams)
|
||||||
|
|
||||||
assertFalse(false, "insufficient storage")
|
assertFalse(false, "insufficient storage")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testSufficientDeviceStorageForOnlyOneFile() = runBlocking {
|
fun testSufficientDeviceStorageForOnlyOneFile() = runBlocking {
|
||||||
SaplingParamTool.fetchParams(cacheDir)
|
SaplingParamTool.fetchParams(spendSaplingParams)
|
||||||
|
|
||||||
assertFalse(false, "insufficient storage")
|
assertFalse(false, "insufficient storage")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun check_all_files_fetched() = runBlocking {
|
||||||
|
val expectedSpendFile = File(
|
||||||
|
SaplingParamsFixture.DESTINATION_DIRECTORY,
|
||||||
|
SaplingParamsFixture.SPEND_FILE_NAME
|
||||||
|
)
|
||||||
|
val expectedOutputFile = File(
|
||||||
|
SaplingParamsFixture.DESTINATION_DIRECTORY,
|
||||||
|
SaplingParamsFixture.OUTPUT_FILE_NAME
|
||||||
|
)
|
||||||
|
|
||||||
|
SaplingParamTool.ensureParams(SaplingParamsFixture.DESTINATION_DIRECTORY)
|
||||||
|
|
||||||
|
val actualFiles = File(SaplingParamsFixture.DESTINATION_DIRECTORY).listFiles()
|
||||||
|
assertNotNull(actualFiles)
|
||||||
|
|
||||||
|
assertContains(actualFiles, expectedSpendFile)
|
||||||
|
|
||||||
|
assertContains(actualFiles, expectedOutputFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun check_correct_spend_param_file_size() = runBlocking {
|
||||||
|
SaplingParamTool.fetchParams(spendSaplingParams)
|
||||||
|
|
||||||
|
val expectedSpendFile = File(
|
||||||
|
SaplingParamsFixture.DESTINATION_DIRECTORY,
|
||||||
|
SaplingParamsFixture.SPEND_FILE_NAME
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(expectedSpendFile.length() < SaplingParamsFixture.SPEND_FILE_MAX_SIZE)
|
||||||
|
assertFalse(expectedSpendFile.length() < SaplingParamsFixture.OUTPUT_FILE_MAX_SIZE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun check_correct_output_param_file_size() = runBlocking {
|
||||||
|
SaplingParamTool.fetchParams(outputSaplingParams)
|
||||||
|
|
||||||
|
val expectedOutputFile = File(
|
||||||
|
SaplingParamsFixture.DESTINATION_DIRECTORY,
|
||||||
|
SaplingParamsFixture.OUTPUT_FILE_NAME
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(expectedOutputFile.length() < SaplingParamsFixture.OUTPUT_FILE_MAX_SIZE)
|
||||||
|
assertFalse(expectedOutputFile.length() > SaplingParamsFixture.SPEND_FILE_MAX_SIZE)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
package cash.z.ecc.fixture
|
||||||
|
|
||||||
|
import cash.z.ecc.android.sdk.ext.ZcashSdk
|
||||||
|
import cash.z.ecc.android.sdk.internal.SaplingFileParameters
|
||||||
|
import cash.z.ecc.android.sdk.internal.SaplingParamTool
|
||||||
|
import cash.z.ecc.android.sdk.test.getAppContext
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
object SaplingParamsFixture {
|
||||||
|
|
||||||
|
val DESTINATION_DIRECTORY: String = File(getAppContext().cacheDir, "params").absolutePath
|
||||||
|
|
||||||
|
const val SPEND_FILE_NAME = ZcashSdk.SPEND_PARAM_FILE_NAME
|
||||||
|
const val SPEND_FILE_MAX_SIZE = SaplingParamTool.SPEND_PARAM_FILE_MAX_BYTES_SIZE
|
||||||
|
|
||||||
|
const val OUTPUT_FILE_NAME = ZcashSdk.OUTPUT_PARAM_FILE_NAME
|
||||||
|
const val OUTPUT_FILE_MAX_SIZE = SaplingParamTool.OUTPUT_PARAM_FILE_MAX_BYTES_SIZE
|
||||||
|
|
||||||
|
internal fun newFile(
|
||||||
|
destinationDirectoryPath: String = DESTINATION_DIRECTORY,
|
||||||
|
fileName: String = SPEND_FILE_NAME,
|
||||||
|
fileMaxSize: Long = SPEND_FILE_MAX_SIZE
|
||||||
|
) = SaplingFileParameters(
|
||||||
|
destinationDirectoryPath = destinationDirectoryPath,
|
||||||
|
fileName = fileName,
|
||||||
|
fileMaxSizeBytes = fileMaxSize
|
||||||
|
)
|
||||||
|
}
|
|
@ -17,128 +17,146 @@ import java.nio.channels.Channels
|
||||||
// TODO [#665]: https://github.com/zcash/zcash-android-wallet-sdk/issues/665
|
// TODO [#665]: https://github.com/zcash/zcash-android-wallet-sdk/issues/665
|
||||||
// TODO [#665]: Recover from corrupted sapling-spend.params and sapling-output.params
|
// TODO [#665]: Recover from corrupted sapling-spend.params and sapling-output.params
|
||||||
|
|
||||||
// TODO [#664]: https://github.com/zcash/zcash-android-wallet-sdk/issues/664
|
|
||||||
// TODO [#664]: Check size of download for sapling-spend.params and sapling-output.params
|
|
||||||
|
|
||||||
// TODO [#611]: https://github.com/zcash/zcash-android-wallet-sdk/issues/611
|
// TODO [#611]: https://github.com/zcash/zcash-android-wallet-sdk/issues/611
|
||||||
// TODO [#611]: Move Params Directory to No Backup Directory
|
// TODO [#611]: Move Params Directory to No Backup Directory
|
||||||
|
|
||||||
@Suppress("UtilityClassWithPublicConstructor")
|
object SaplingParamTool {
|
||||||
class SaplingParamTool {
|
/**
|
||||||
|
* Maximum file size for the sapling spend params - 50MB
|
||||||
|
*/
|
||||||
|
internal const val SPEND_PARAM_FILE_MAX_BYTES_SIZE = 50L * 1024L * 1024L
|
||||||
|
|
||||||
companion object {
|
/**
|
||||||
/**
|
* Maximum file size for the sapling spend params - 5MB
|
||||||
* Checks the given directory for the output and spending params and calls [fetchParams] if
|
*/
|
||||||
* they're missing.
|
internal const val OUTPUT_PARAM_FILE_MAX_BYTES_SIZE = 5L * 1024L * 1024L
|
||||||
*
|
|
||||||
* @param destinationDir the directory where the params should be stored.
|
/**
|
||||||
*/
|
* Checks the given directory for the output and spending params and calls [fetchParams] for those, which are
|
||||||
suspend fun ensureParams(destinationDir: String) {
|
* missing.
|
||||||
var hadError = false
|
*
|
||||||
arrayOf(
|
* @param destinationDir the directory where the params should be stored.
|
||||||
|
*
|
||||||
|
* @throws TransactionEncoderException.MissingParamsException in case of failure while checking sapling params
|
||||||
|
* files
|
||||||
|
*/
|
||||||
|
@Throws(TransactionEncoderException.MissingParamsException::class)
|
||||||
|
suspend fun ensureParams(destinationDir: String) {
|
||||||
|
arrayOf(
|
||||||
|
SaplingFileParameters(
|
||||||
|
destinationDir,
|
||||||
ZcashSdk.SPEND_PARAM_FILE_NAME,
|
ZcashSdk.SPEND_PARAM_FILE_NAME,
|
||||||
ZcashSdk.OUTPUT_PARAM_FILE_NAME
|
SPEND_PARAM_FILE_MAX_BYTES_SIZE
|
||||||
).forEach { paramFileName ->
|
),
|
||||||
if (!File(destinationDir, paramFileName).existsSuspend()) {
|
SaplingFileParameters(
|
||||||
twig("WARNING: $paramFileName not found at location: $destinationDir")
|
destinationDir,
|
||||||
hadError = true
|
ZcashSdk.OUTPUT_PARAM_FILE_NAME,
|
||||||
}
|
OUTPUT_PARAM_FILE_MAX_BYTES_SIZE
|
||||||
}
|
)
|
||||||
if (hadError) {
|
).filter {
|
||||||
@Suppress("TooGenericExceptionCaught")
|
!File(it.destinationDirectoryPath, it.fileName).existsSuspend()
|
||||||
try {
|
}.forEach {
|
||||||
Bush.trunk.twigTask("attempting to download missing params") {
|
try {
|
||||||
fetchParams(destinationDir)
|
twig("Attempting to download missing params: ${it.fileName}.")
|
||||||
}
|
fetchParams(it)
|
||||||
} catch (e: Throwable) {
|
} catch (e: TransactionEncoderException.FetchParamsException) {
|
||||||
twig("failed to fetch params due to: $e")
|
twig("Failed to fetch params ${it.fileName} due to: $e")
|
||||||
throw TransactionEncoderException.MissingParamsException
|
throw TransactionEncoderException.MissingParamsException
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
if (!validate(destinationDir)) {
|
||||||
* Download and store the params into the given directory.
|
twig("Fetching sapling params files failed.")
|
||||||
*
|
throw TransactionEncoderException.MissingParamsException
|
||||||
* @param destinationDir the directory where the params will be stored. It's assumed that we
|
}
|
||||||
* have write access to this directory. Typically, this should be the app's cache directory
|
}
|
||||||
* because it is not harmful if these files are cleared by the user since they are downloaded
|
|
||||||
* on-demand.
|
|
||||||
*/
|
|
||||||
suspend fun fetchParams(destinationDir: String) {
|
|
||||||
var failureMessage = ""
|
|
||||||
arrayOf(
|
|
||||||
ZcashSdk.SPEND_PARAM_FILE_NAME,
|
|
||||||
ZcashSdk.OUTPUT_PARAM_FILE_NAME
|
|
||||||
).forEach { paramFileName ->
|
|
||||||
val url = URL("${ZcashSdk.CLOUD_PARAM_DIR_URL}/$paramFileName")
|
|
||||||
|
|
||||||
val file = File(destinationDir, paramFileName)
|
/**
|
||||||
if (file.parentFile?.existsSuspend() == true) {
|
* Download and store the params file into the given directory. It also checks the file size to eliminate the
|
||||||
twig("Directory ${file.parentFile?.name} exists!")
|
* risk of downloading potentially large file from a malicious server.
|
||||||
} else {
|
*
|
||||||
twig("Directory did not exist attempting to make it.")
|
* @param paramsToFetch parameters wrapper class, which holds information about it
|
||||||
file.parentFile?.mkdirsSuspend()
|
*
|
||||||
}
|
* @throws TransactionEncoderException.FetchParamsException if any error while downloading the params file occurs
|
||||||
|
*/
|
||||||
|
@Throws(TransactionEncoderException.FetchParamsException::class)
|
||||||
|
internal suspend fun fetchParams(paramsToFetch: SaplingFileParameters) {
|
||||||
|
val url = URL("${ZcashSdk.CLOUD_PARAM_DIR_URL}/${paramsToFetch.fileName}")
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
val file = File(paramsToFetch.destinationDirectoryPath, paramsToFetch.fileName)
|
||||||
runCatching {
|
if (file.parentFile?.existsSuspend() == true) {
|
||||||
Channels.newChannel(url.openStream()).use { readableByteChannel ->
|
twig("Directory ${file.parentFile?.name} exists!")
|
||||||
file.outputStream().use { fileOutputStream ->
|
} else {
|
||||||
fileOutputStream.channel.use { fileChannel ->
|
twig("Directory did not exist attempting to make it.")
|
||||||
// transfers bytes from stream to file (position 0 to the end position)
|
file.parentFile?.mkdirsSuspend()
|
||||||
fileChannel.transferFrom(readableByteChannel, 0, Long.MAX_VALUE)
|
}
|
||||||
}
|
|
||||||
}
|
withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
Channels.newChannel(url.openStream()).use { readableByteChannel ->
|
||||||
|
file.outputStream().use { fileOutputStream ->
|
||||||
|
fileOutputStream.channel.use { fileChannel ->
|
||||||
|
// Transfers bytes from stream to file from position 0 to end position or to max
|
||||||
|
// file size limit. This eliminates the risk of downloading potentially large files
|
||||||
|
// from a malicious server. We need to make a check of the file hash then.
|
||||||
|
fileChannel.transferFrom(readableByteChannel, 0, paramsToFetch.fileMaxSizeBytes)
|
||||||
}
|
}
|
||||||
}.onFailure { exception ->
|
|
||||||
// IllegalArgumentException - If the preconditions on the parameters do not hold
|
|
||||||
// NonReadableChannelException - If the source channel was not opened for reading
|
|
||||||
// NonWritableChannelException - If this channel was not opened for writing
|
|
||||||
// ClosedChannelException - If either this channel or the source channel is closed
|
|
||||||
// AsynchronousCloseException - If another thread closes either channel while the transfer is
|
|
||||||
// in progress
|
|
||||||
// ClosedByInterruptException - If another thread interrupts the current thread while the
|
|
||||||
// transfer is in progress, thereby closing both channels and setting the current thread's
|
|
||||||
// interrupt status
|
|
||||||
// IOException - If some other I/O error occurs
|
|
||||||
failureMessage += "Error while fetching $paramFileName, caused by $exception\n"
|
|
||||||
twig(failureMessage)
|
|
||||||
}.onSuccess {
|
|
||||||
twig("Fetch and write of $paramFileName succeeded.")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}.onFailure { exception ->
|
||||||
if (failureMessage.isNotEmpty()) {
|
// IllegalArgumentException - If the preconditions on the parameters do not hold
|
||||||
throw TransactionEncoderException.FetchParamsException(failureMessage)
|
// NonReadableChannelException - If the source channel was not opened for reading
|
||||||
}
|
// NonWritableChannelException - If this channel was not opened for writing
|
||||||
}
|
// ClosedChannelException - If either this channel or the source channel is closed
|
||||||
|
// AsynchronousCloseException - If another thread closes either channel while the transfer is
|
||||||
suspend fun clear(destinationDir: String) {
|
// in progress
|
||||||
if (validate(destinationDir)) {
|
// ClosedByInterruptException - If another thread interrupts the current thread while the
|
||||||
arrayOf(
|
// transfer is in progress, thereby closing both channels and setting the current thread's
|
||||||
ZcashSdk.SPEND_PARAM_FILE_NAME,
|
// interrupt status
|
||||||
ZcashSdk.OUTPUT_PARAM_FILE_NAME
|
// IOException - If some other I/O error occurs
|
||||||
).forEach { paramFileName ->
|
"Error while fetching ${paramsToFetch.fileName}, caused by $exception".also {
|
||||||
val file = File(destinationDir, paramFileName)
|
twig(it)
|
||||||
if (file.deleteRecursivelySuspend()) {
|
throw TransactionEncoderException.FetchParamsException(it)
|
||||||
twig("Files deleted successfully")
|
|
||||||
} else {
|
|
||||||
twig("Error: Files not able to be deleted!")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}.onSuccess {
|
||||||
}
|
twig("Fetch and write of ${paramsToFetch.fileName} succeeded.")
|
||||||
|
|
||||||
suspend fun validate(destinationDir: String): Boolean {
|
|
||||||
return arrayOf(
|
|
||||||
ZcashSdk.SPEND_PARAM_FILE_NAME,
|
|
||||||
ZcashSdk.OUTPUT_PARAM_FILE_NAME
|
|
||||||
).all { paramFileName ->
|
|
||||||
File(destinationDir, paramFileName).existsSuspend()
|
|
||||||
}.also {
|
|
||||||
println("Param files${if (!it) "did not" else ""} both exist!")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun clear(destinationDir: String) {
|
||||||
|
if (validate(destinationDir)) {
|
||||||
|
arrayOf(
|
||||||
|
ZcashSdk.SPEND_PARAM_FILE_NAME,
|
||||||
|
ZcashSdk.OUTPUT_PARAM_FILE_NAME
|
||||||
|
).forEach { paramFileName ->
|
||||||
|
val file = File(destinationDir, paramFileName)
|
||||||
|
if (file.deleteRecursivelySuspend()) {
|
||||||
|
twig("Files deleted successfully")
|
||||||
|
} else {
|
||||||
|
twig("Error: Files not able to be deleted!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun validate(destinationDir: String): Boolean {
|
||||||
|
return arrayOf(
|
||||||
|
ZcashSdk.SPEND_PARAM_FILE_NAME,
|
||||||
|
ZcashSdk.OUTPUT_PARAM_FILE_NAME
|
||||||
|
).all { paramFileName ->
|
||||||
|
File(destinationDir, paramFileName).existsSuspend()
|
||||||
|
}.also {
|
||||||
|
println("Param files ${if (!it) "did not" else ""} both exist!")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sapling file parameters class to hold each sapling file attributes.
|
||||||
|
*/
|
||||||
|
internal data class SaplingFileParameters(
|
||||||
|
val destinationDirectoryPath: String,
|
||||||
|
val fileName: String,
|
||||||
|
val fileMaxSizeBytes: Long
|
||||||
|
)
|
||||||
|
|
Loading…
Reference in New Issue