feat(crypto): Implement cross-platform cryptographic functions and integrate with native interop

This commit is contained in:
tapframe 2026-04-02 12:52:57 +05:30
parent d7efa1ff17
commit fa7c4b3881
9 changed files with 553 additions and 106 deletions

View file

@ -92,10 +92,21 @@ kotlin {
}
}
listOf(
val iosTargets = listOf(
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
)
iosTargets.forEach { iosTarget ->
iosTarget.compilations.getByName("main") {
cinterops {
create("commoncrypto") {
defFile(project.file("src/nativeInterop/cinterop/commoncrypto.def"))
compilerOpts("-I${project.projectDir}/src/nativeInterop/cinterop")
}
}
}
iosTarget.binaries.framework {
baseName = "ComposeApp"
isStatic = true

View file

@ -2,21 +2,19 @@ package com.nuvio.app.features.addons
import android.content.Context
import android.content.SharedPreferences
import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.accept
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.request
import io.ktor.client.request.setBody
import io.ktor.client.request.url
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.isSuccess
import com.nuvio.app.core.network.IPv4FirstDns
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.ResponseBody
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.net.Proxy
import java.io.ByteArrayOutputStream
import java.io.InputStream
import kotlin.text.Charsets
import java.util.concurrent.TimeUnit
actual object AddonStorage {
private const val preferencesName = "nuvio_addons"
@ -45,95 +43,156 @@ actual object AddonStorage {
}
}
private val addonHttpClient = HttpClient(Android) {
install(HttpTimeout) {
requestTimeoutMillis = 10_000
connectTimeoutMillis = 10_000
socketTimeoutMillis = 10_000
private val addonHttpClient = OkHttpClient.Builder()
.dns(IPv4FirstDns())
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.followRedirects(true)
.followSslRedirects(true)
.proxy(Proxy.NO_PROXY)
.build()
private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
private const val maxResponseBodyBytes = 256 * 1024
private const val truncationSuffix = "\n...[truncated]"
private fun requestAllowsBody(method: String): Boolean =
when (method.uppercase()) {
"POST", "PUT", "PATCH", "DELETE" -> true
else -> false
}
private fun Map<String, String>.withoutAcceptEncoding(): Map<String, String> =
entries
.filterNot { (key, _) -> key.equals("Accept-Encoding", ignoreCase = true) }
.associate { (key, value) -> key to value }
private fun Map<String, String>.getHeaderIgnoreCase(name: String): String? =
entries.firstOrNull { (key, _) -> key.equals(name, ignoreCase = true) }?.value
private data class LimitedReadResult(
val bytes: ByteArray,
val truncated: Boolean,
)
private fun readAtMostBytes(stream: InputStream, maxBytes: Int): LimitedReadResult {
val out = ByteArrayOutputStream(minOf(maxBytes, 16 * 1024))
val buffer = ByteArray(8 * 1024)
var remaining = maxBytes
var truncated = false
while (remaining > 0) {
val read = stream.read(buffer, 0, minOf(buffer.size, remaining))
if (read <= 0) break
out.write(buffer, 0, read)
remaining -= read
}
if (remaining == 0) {
truncated = stream.read() != -1
}
return LimitedReadResult(out.toByteArray(), truncated)
}
private fun readResponseBodyLimited(body: ResponseBody?): String {
if (body == null) return ""
val charset = body.contentType()?.charset(Charsets.UTF_8) ?: Charsets.UTF_8
val readResult = body.byteStream().use { stream ->
readAtMostBytes(stream, maxResponseBodyBytes)
}
val decoded = try {
String(readResult.bytes, charset)
} catch (_: Exception) {
String(readResult.bytes, Charsets.UTF_8)
}
return if (readResult.truncated) {
decoded + truncationSuffix
} else {
decoded
}
}
private suspend fun executeTextRequest(
method: String,
url: String,
headers: Map<String, String> = emptyMap(),
body: String = "",
): String = withContext(Dispatchers.IO) {
val normalizedMethod = method.uppercase()
val sanitizedHeaders = headers.withoutAcceptEncoding()
val builder = Request.Builder().url(url)
sanitizedHeaders.forEach { (key, value) ->
builder.header(key, value)
}
val request = if (requestAllowsBody(normalizedMethod)) {
val contentType = sanitizedHeaders.getHeaderIgnoreCase("Content-Type")
?: if (normalizedMethod == "POST") "application/x-www-form-urlencoded" else "application/json"
// Preserve exact media type and avoid implicit charset rewriting used in signed APIs like MovieBox.
val requestBody = body.toByteArray(Charsets.UTF_8).toRequestBody(contentType.toMediaType())
builder.method(normalizedMethod, requestBody)
} else {
builder.method(normalizedMethod, null)
}.build()
addonHttpClient.newCall(request).execute().use { response ->
val payload = readResponseBodyLimited(response.body)
if (!response.isSuccessful) {
error("Request failed with HTTP ${response.code}")
}
if (payload.isBlank()) {
throw IllegalStateException("Empty response body")
}
payload
}
expectSuccess = false
}
actual suspend fun httpGetText(url: String): String =
addonHttpClient
.get(url) {
accept(ContentType.Application.Json)
}
.let { response ->
val payload = response.bodyAsText()
if (!response.status.isSuccess()) {
error("Request failed with HTTP ${response.status.value}")
}
if (payload.isBlank()) {
throw IllegalStateException("Empty response body")
}
payload
}
executeTextRequest(
method = "GET",
url = url,
headers = mapOf("Accept" to "application/json"),
)
actual suspend fun httpPostJson(url: String, body: String): String =
addonHttpClient
.post(url) {
accept(ContentType.Application.Json)
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody(body)
}
.let { response ->
val payload = response.bodyAsText()
if (!response.status.isSuccess()) {
error("Request failed with HTTP ${response.status.value}")
}
if (payload.isBlank()) {
throw IllegalStateException("Empty response body")
}
payload
}
executeTextRequest(
method = "POST",
url = url,
headers = mapOf(
"Accept" to "application/json",
"Content-Type" to "application/json",
),
body = body,
)
actual suspend fun httpGetTextWithHeaders(
url: String,
headers: Map<String, String>,
): String =
addonHttpClient
.get(url) {
accept(ContentType.Application.Json)
headers.forEach { (key, value) ->
header(key, value)
}
}
.let { response ->
val payload = response.bodyAsText()
if (!response.status.isSuccess()) {
error("Request failed with HTTP ${response.status.value}")
}
if (payload.isBlank()) {
throw IllegalStateException("Empty response body")
}
payload
}
executeTextRequest(
method = "GET",
url = url,
headers = mapOf("Accept" to "application/json") + headers,
)
actual suspend fun httpPostJsonWithHeaders(
url: String,
body: String,
headers: Map<String, String>,
): String =
addonHttpClient
.post(url) {
accept(ContentType.Application.Json)
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
headers.forEach { (key, value) ->
header(key, value)
}
setBody(body)
}
.let { response ->
val payload = response.bodyAsText()
if (!response.status.isSuccess()) {
error("Request failed with HTTP ${response.status.value}")
}
if (payload.isBlank()) {
throw IllegalStateException("Empty response body")
}
payload
}
executeTextRequest(
method = "POST",
url = url,
headers = mapOf(
"Accept" to "application/json",
"Content-Type" to "application/json",
) + headers,
body = body,
)
actual suspend fun httpRequestRaw(
method: String,
@ -141,25 +200,34 @@ actual suspend fun httpRequestRaw(
headers: Map<String, String>,
body: String,
): RawHttpResponse =
addonHttpClient
.request {
url(url)
this.method = HttpMethod.parse(method.uppercase())
headers.forEach { (key, value) ->
header(key, value)
}
if (this.method == HttpMethod.Post || this.method == HttpMethod.Put || this.method == HttpMethod.Patch) {
setBody(body)
}
withContext(Dispatchers.IO) {
val normalizedMethod = method.uppercase()
val sanitizedHeaders = headers.withoutAcceptEncoding()
val builder = Request.Builder().url(url)
sanitizedHeaders.forEach { (key, value) ->
builder.header(key, value)
}
.let { response ->
val request = if (requestAllowsBody(normalizedMethod)) {
val contentType = sanitizedHeaders.getHeaderIgnoreCase("Content-Type")
?: if (normalizedMethod == "POST") "application/x-www-form-urlencoded" else "application/json"
val requestBody = body.toByteArray(Charsets.UTF_8).toRequestBody(contentType.toMediaType())
builder.method(normalizedMethod, requestBody)
} else {
builder.method(normalizedMethod, null)
}.build()
addonHttpClient.newCall(request).execute().use { response ->
RawHttpResponse(
status = response.status.value,
statusText = response.status.description,
url = response.call.request.url.toString(),
body = response.bodyAsText(),
headers = response.headers.entries().associate { (name, values) ->
name.lowercase() to values.joinToString(",")
status = response.code,
statusText = response.message,
url = response.request.url.toString(),
body = readResponseBodyLimited(response.body),
headers = response.headers.toMultimap().mapValues { (_, values) ->
values.joinToString(",")
}.mapKeys { (name, _) ->
name.lowercase()
},
)
}
}

View file

@ -0,0 +1,29 @@
package com.nuvio.app.features.plugins
import java.security.MessageDigest
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
internal actual fun pluginDigestHex(algorithm: String, data: String): String {
val normalized = algorithm.uppercase()
val digest = MessageDigest.getInstance(normalized).digest(data.encodeToByteArray())
return digest.joinToString(separator = "") { byte ->
byte.toUByte().toString(16).padStart(2, '0')
}
}
internal actual fun pluginHmacHex(algorithm: String, key: String, data: String): String {
val normalized = when (algorithm.uppercase()) {
"SHA1" -> "HmacSHA1"
"SHA256" -> "HmacSHA256"
"SHA512" -> "HmacSHA512"
"MD5" -> "HmacMD5"
else -> error("Unsupported HMAC algorithm: $algorithm")
}
val mac = Mac.getInstance(normalized)
mac.init(SecretKeySpec(key.encodeToByteArray(), normalized))
val digest = mac.doFinal(data.encodeToByteArray())
return digest.joinToString(separator = "") { byte ->
byte.toUByte().toString(16).padStart(2, '0')
}
}

View file

@ -0,0 +1,39 @@
package com.nuvio.app.features.plugins
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
internal expect fun pluginDigestHex(algorithm: String, data: String): String
internal expect fun pluginHmacHex(algorithm: String, key: String, data: String): String
@OptIn(ExperimentalEncodingApi::class)
internal fun pluginBase64Encode(data: String): String =
Base64.encode(data.encodeToByteArray())
@OptIn(ExperimentalEncodingApi::class)
internal fun pluginBase64Decode(data: String): String {
val normalized = data.trim().replace("\n", "").replace("\r", "").replace(" ", "")
val decoded = Base64.decode(normalized)
return decoded.decodeToString()
}
internal fun pluginUtf8ToHex(value: String): String =
value.encodeToByteArray().joinToString(separator = "") { byte ->
byte.toUByte().toString(16).padStart(2, '0')
}
internal fun pluginHexToUtf8(hex: String): String {
val normalized = hex.trim().lowercase()
.replace(" ", "")
.removePrefix("0x")
if (normalized.isEmpty()) return ""
val evenHex = if (normalized.length % 2 == 0) normalized else "0$normalized"
val out = ByteArray(evenHex.length / 2)
for (index in out.indices) {
val part = evenHex.substring(index * 2, index * 2 + 2)
out[index] = part.toInt(16).toByte()
}
return out.decodeToString()
}

View file

@ -120,6 +120,51 @@ internal object PluginRuntime {
}
}
function("__crypto_digest_hex") { args ->
val algorithm = args.getOrNull(0)?.toString() ?: "SHA256"
val data = args.getOrNull(1)?.toString() ?: ""
runCatching {
pluginDigestHex(algorithm, data)
}.getOrDefault("")
}
function("__crypto_hmac_hex") { args ->
val algorithm = args.getOrNull(0)?.toString() ?: "SHA256"
val key = args.getOrNull(1)?.toString() ?: ""
val data = args.getOrNull(2)?.toString() ?: ""
runCatching {
pluginHmacHex(algorithm, key, data)
}.getOrDefault("")
}
function("__crypto_base64_encode") { args ->
val data = args.getOrNull(0)?.toString() ?: ""
runCatching {
pluginBase64Encode(data)
}.getOrDefault("")
}
function("__crypto_base64_decode") { args ->
val data = args.getOrNull(0)?.toString() ?: ""
runCatching {
pluginBase64Decode(data)
}.getOrDefault("")
}
function("__crypto_utf8_to_hex") { args ->
val data = args.getOrNull(0)?.toString() ?: ""
runCatching {
pluginUtf8ToHex(data)
}.getOrDefault("")
}
function("__crypto_hex_to_utf8") { args ->
val data = args.getOrNull(0)?.toString() ?: ""
runCatching {
pluginHexToUtf8(data)
}.getOrDefault("")
}
function("__parse_url") { args ->
parseUrl(args.getOrNull(0)?.toString() ?: "")
}
@ -607,6 +652,149 @@ internal object PluginRuntime {
this._params = sorted;
};
function __hexToWords(hex) {
var words = [];
for (var i = 0; i < hex.length; i += 8) {
var chunk = hex.substring(i, i + 8);
while (chunk.length < 8) chunk += '0';
words.push(parseInt(chunk, 16) | 0);
}
return words;
}
function __wordsToHex(words, sigBytes) {
var hex = '';
for (var i = 0; i < sigBytes; i++) {
var word = words[i >>> 2] || 0;
var byte = (word >>> (24 - (i % 4) * 8)) & 0xff;
var part = byte.toString(16);
if (part.length < 2) part = '0' + part;
hex += part;
}
return hex;
}
function __wordArrayToHex(value) {
if (!value) return '';
if (typeof value.__hex === 'string') return value.__hex.toLowerCase();
if (Array.isArray(value.words) && typeof value.sigBytes === 'number') {
return __wordsToHex(value.words, value.sigBytes);
}
return __crypto_utf8_to_hex(String(value));
}
function __buildWordArray(hex, utf8Override) {
var normalizedHex = (hex || '').toLowerCase();
if (normalizedHex.length % 2 !== 0) normalizedHex = '0' + normalizedHex;
var wordArray = {
__hex: normalizedHex,
__utf8: utf8Override !== undefined ? utf8Override : __crypto_hex_to_utf8(normalizedHex),
sigBytes: normalizedHex.length / 2,
words: __hexToWords(normalizedHex),
toString: function(encoder) {
if (!encoder || encoder === CryptoJS.enc.Hex) return this.__hex;
if (encoder === CryptoJS.enc.Utf8) return this.__utf8;
if (encoder === CryptoJS.enc.Base64) return __crypto_base64_encode(this.__utf8);
return this.__hex;
},
clamp: function() {
return this;
},
concat: function(other) {
var otherHex = __wordArrayToHex(other);
this.__hex += otherHex;
this.__utf8 = __crypto_hex_to_utf8(this.__hex);
this.sigBytes = this.__hex.length / 2;
this.words = __hexToWords(this.__hex);
return this;
}
};
return wordArray;
}
function __wordArrayFromHex(hex) {
return __buildWordArray(hex, undefined);
}
function __wordArrayFromUtf8(text) {
var utf8 = text == null ? '' : String(text);
return __buildWordArray(__crypto_utf8_to_hex(utf8), utf8);
}
function __wordArrayFromBase64(base64) {
return __wordArrayFromUtf8(__crypto_base64_decode(base64 || ''));
}
function __normalizeWordArrayInput(value) {
if (value && typeof value === 'object' && typeof value.__utf8 === 'string') {
return value.__utf8;
}
if (value && typeof value === 'object' && typeof value.__hex === 'string') {
return __crypto_hex_to_utf8(value.__hex);
}
if (value && typeof value === 'object' && Array.isArray(value.words) && typeof value.sigBytes === 'number') {
return __crypto_hex_to_utf8(__wordsToHex(value.words, value.sigBytes));
}
if (value == null) return '';
return String(value);
}
function __cryptoHashWordArray(algorithm, message) {
var utf8 = __normalizeWordArrayInput(message);
var hex = __crypto_digest_hex(algorithm, utf8);
return __wordArrayFromHex(hex);
}
function __cryptoHmacWordArray(algorithm, message, key) {
var utf8Message = __normalizeWordArrayInput(message);
var utf8Key = __normalizeWordArrayInput(key);
var hex = __crypto_hmac_hex(algorithm, utf8Key, utf8Message);
return __wordArrayFromHex(hex);
}
var CryptoJS = {
enc: {
Hex: {
stringify: function(wordArray) {
return __wordArrayToHex(wordArray);
},
parse: function(hexStr) {
return __wordArrayFromHex(hexStr || '');
}
},
Utf8: {
stringify: function(wordArray) {
if (wordArray && typeof wordArray.__utf8 === 'string') return wordArray.__utf8;
if (wordArray && typeof wordArray.__hex === 'string') return __crypto_hex_to_utf8(wordArray.__hex);
return __normalizeWordArrayInput(wordArray);
},
parse: function(text) {
return __wordArrayFromUtf8(text);
}
},
Base64: {
stringify: function(wordArray) {
if (wordArray && typeof wordArray.__utf8 === 'string') {
return __crypto_base64_encode(wordArray.__utf8);
}
return __crypto_base64_encode(__normalizeWordArrayInput(wordArray));
},
parse: function(base64) {
return __wordArrayFromBase64(base64);
}
}
},
MD5: function(message) { return __cryptoHashWordArray('MD5', message); },
SHA1: function(message) { return __cryptoHashWordArray('SHA1', message); },
SHA256: function(message) { return __cryptoHashWordArray('SHA256', message); },
SHA512: function(message) { return __cryptoHashWordArray('SHA512', message); },
HmacMD5: function(message, key) { return __cryptoHmacWordArray('MD5', message, key); },
HmacSHA1: function(message, key) { return __cryptoHmacWordArray('SHA1', message, key); },
HmacSHA256: function(message, key) { return __cryptoHmacWordArray('SHA256', message, key); },
HmacSHA512: function(message, key) { return __cryptoHmacWordArray('SHA512', message, key); }
};
globalThis.CryptoJS = CryptoJS;
var cheerio = {
load: function(html) {
var docId = __cheerio_load(html);
@ -743,6 +931,9 @@ internal object PluginRuntime {
if (moduleName === 'cheerio' || moduleName === 'cheerio-without-node-native' || moduleName === 'react-native-cheerio') {
return cheerio;
}
if (moduleName === 'crypto-js') {
return CryptoJS;
}
throw new Error("Module '" + moduleName + "' is not available");
};

View file

@ -0,0 +1,72 @@
package com.nuvio.app.features.plugins
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.refTo
import com.nuvio.app.features.plugins.cryptointerop.CC_MD5
import com.nuvio.app.features.plugins.cryptointerop.CC_MD5_DIGEST_LENGTH
import com.nuvio.app.features.plugins.cryptointerop.CC_SHA1
import com.nuvio.app.features.plugins.cryptointerop.CC_SHA1_DIGEST_LENGTH
import com.nuvio.app.features.plugins.cryptointerop.CC_SHA256
import com.nuvio.app.features.plugins.cryptointerop.CC_SHA256_DIGEST_LENGTH
import com.nuvio.app.features.plugins.cryptointerop.CC_SHA512
import com.nuvio.app.features.plugins.cryptointerop.CC_SHA512_DIGEST_LENGTH
import com.nuvio.app.features.plugins.cryptointerop.CCHmac
import com.nuvio.app.features.plugins.cryptointerop.kCCHmacAlgMD5
import com.nuvio.app.features.plugins.cryptointerop.kCCHmacAlgSHA1
import com.nuvio.app.features.plugins.cryptointerop.kCCHmacAlgSHA256
import com.nuvio.app.features.plugins.cryptointerop.kCCHmacAlgSHA512
private fun UByteArray.toHex(): String = joinToString(separator = "") { byte ->
byte.toString(16).padStart(2, '0')
}
@OptIn(ExperimentalForeignApi::class)
internal actual fun pluginDigestHex(algorithm: String, data: String): String {
val normalized = algorithm.uppercase()
val input = data.encodeToByteArray()
val output = UByteArray(
when (normalized) {
"MD5" -> CC_MD5_DIGEST_LENGTH.toInt()
"SHA1" -> CC_SHA1_DIGEST_LENGTH.toInt()
"SHA256" -> CC_SHA256_DIGEST_LENGTH.toInt()
"SHA512" -> CC_SHA512_DIGEST_LENGTH.toInt()
else -> error("Unsupported digest algorithm: $algorithm")
},
)
when (normalized) {
"MD5" -> CC_MD5(input.refTo(0), input.size.toUInt(), output.refTo(0))
"SHA1" -> CC_SHA1(input.refTo(0), input.size.toUInt(), output.refTo(0))
"SHA256" -> CC_SHA256(input.refTo(0), input.size.toUInt(), output.refTo(0))
"SHA512" -> CC_SHA512(input.refTo(0), input.size.toUInt(), output.refTo(0))
}
return output.toHex()
}
@OptIn(ExperimentalForeignApi::class)
internal actual fun pluginHmacHex(algorithm: String, key: String, data: String): String {
val normalized = algorithm.uppercase()
val keyBytes = key.encodeToByteArray()
val input = data.encodeToByteArray()
val (alg, outputSize) = when (normalized) {
"MD5" -> kCCHmacAlgMD5 to CC_MD5_DIGEST_LENGTH.toInt()
"SHA1" -> kCCHmacAlgSHA1 to CC_SHA1_DIGEST_LENGTH.toInt()
"SHA256" -> kCCHmacAlgSHA256 to CC_SHA256_DIGEST_LENGTH.toInt()
"SHA512" -> kCCHmacAlgSHA512 to CC_SHA512_DIGEST_LENGTH.toInt()
else -> error("Unsupported HMAC algorithm: $algorithm")
}
val output = UByteArray(outputSize)
CCHmac(
alg,
keyBytes.refTo(0),
keyBytes.size.toULong(),
input.refTo(0),
input.size.toULong(),
output.refTo(0),
)
return output.toHex()
}

View file

@ -0,0 +1,3 @@
language = C
headers = commoncrypto_shim.h
package = com.nuvio.app.features.plugins.cryptointerop

View file

@ -0,0 +1,33 @@
#include <stddef.h>
#include <stdint.h>
typedef uint32_t CC_LONG;
enum {
CC_MD5_DIGEST_LENGTH = 16,
CC_SHA1_DIGEST_LENGTH = 20,
CC_SHA256_DIGEST_LENGTH = 32,
CC_SHA512_DIGEST_LENGTH = 64,
};
typedef enum {
kCCHmacAlgSHA1 = 0,
kCCHmacAlgMD5 = 1,
kCCHmacAlgSHA256 = 2,
kCCHmacAlgSHA384 = 3,
kCCHmacAlgSHA512 = 4,
} CCHmacAlgorithm;
unsigned char *CC_MD5(const void *data, CC_LONG len, unsigned char *md);
unsigned char *CC_SHA1(const void *data, CC_LONG len, unsigned char *md);
unsigned char *CC_SHA256(const void *data, CC_LONG len, unsigned char *md);
unsigned char *CC_SHA512(const void *data, CC_LONG len, unsigned char *md);
void CCHmac(
CCHmacAlgorithm algorithm,
const void *key,
size_t keyLength,
const void *data,
size_t dataLength,
void *macOut
);

View file

@ -1,6 +1,7 @@
#Kotlin
kotlin.code.style=official
kotlin.daemon.jvmargs=-Xmx3072M
kotlin.mpp.enableCInteropCommonization=true
#Gradle
org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8