Updated on 2026-08-14
This commit is contained in:
commit
e8116d8ea0
13 changed files with 151 additions and 48 deletions
|
|
@ -4,20 +4,20 @@ import androidx.compose.animation.AnimatedVisibility
|
|||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.TextUnitType
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.tap.features.tokens.impl.presentation.states.NetworkItemState
|
||||
import kotlinx.collections.immutable.ImmutableCollection
|
||||
|
|
@ -41,10 +41,7 @@ internal fun BriefNetworksList(
|
|||
exit = fadeOut(),
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4)) {
|
||||
val iterator = networks.iterator()
|
||||
var index = 0
|
||||
while (iterator.hasNext()) {
|
||||
val network = iterator.next()
|
||||
for ((index, network) in networks.withIndex()) {
|
||||
if (index < MAX_VISIBLE_BRIEF_ICONS) {
|
||||
key(network.name + network.protocolName) {
|
||||
BriefNetworkItem(model = network)
|
||||
|
|
@ -59,7 +56,6 @@ internal fun BriefNetworksList(
|
|||
break
|
||||
}
|
||||
}
|
||||
index++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -109,8 +105,16 @@ internal fun BriefNetworkItem(model: NetworkItemState, modifier: Modifier = Modi
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
@Composable
|
||||
internal fun HasMoreItem(moreCount: Int) {
|
||||
val count = if (moreCount > 99) 99 else moreCount
|
||||
val themeTextStyle = TangemTheme.typography.overline.copy(
|
||||
letterSpacing = TextUnit(value = 0f, type = TextUnitType.Sp),
|
||||
)
|
||||
var textStyle by remember(themeTextStyle) { mutableStateOf(themeTextStyle) }
|
||||
var readyToDraw by remember(themeTextStyle) { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(size = TangemTheme.dimens.size20)
|
||||
|
|
@ -118,10 +122,20 @@ internal fun HasMoreItem(moreCount: Int) {
|
|||
.background(TangemTheme.colors.control.unchecked),
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
text = "+$moreCount",
|
||||
style = TangemTheme.typography.overline,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
modifier = Modifier
|
||||
.padding(TangemTheme.dimens.spacing4)
|
||||
.align(Alignment.Center)
|
||||
.drawWithContent { if (readyToDraw) drawContent() },
|
||||
text = "+$count",
|
||||
style = textStyle,
|
||||
overflow = TextOverflow.Clip,
|
||||
onTextLayout = { textLayoutResult ->
|
||||
if (textLayoutResult.didOverflowHeight) {
|
||||
textStyle = textStyle.copy(fontSize = textStyle.fontSize * 0.9)
|
||||
} else {
|
||||
readyToDraw = true
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -427,7 +427,7 @@
|
|||
<string name="send_additional_field_already_included">Уже содержится в введенном адресе</string>
|
||||
<string name="send_alert_fee_coverage_subract_text">Вычесть</string>
|
||||
<string name="send_alert_fee_coverage_title">Недостаточно средств для покрытия комиссии сети. Вычесть недостающую сумму для покрытия комиссии из отправляемой суммы?</string>
|
||||
<string name="send_alert_fee_too_high_text">Сумма комиссии в %@ раз превышает рекомендованную. Убедитесь, что указанная комиссия верна.</string>
|
||||
<string name="send_alert_fee_too_high_text">Сумма комиссии в %s раз превышает рекомендованную. Убедитесь, что указанная комиссия верна.</string>
|
||||
<string name="send_alert_fee_too_low_text">Вы указали комиссию ниже рекомендуемой, это может привести к задержке исполнения вашей транзакции. Продолжить?</string>
|
||||
<string name="send_alert_transaction_failed_text">Причина: %1$s\nКод: %2$s</string>
|
||||
<string name="send_alert_transaction_failed_title">Транзакция не выполнена</string>
|
||||
|
|
@ -537,9 +537,9 @@
|
|||
<string name="toast_balances_hidden">Балансы скрыты</string>
|
||||
<string name="toast_balances_shown">Балансы показаны</string>
|
||||
<string name="toast_undo">Отменить</string>
|
||||
<string name="token_button_unavailability_generic_description">Выбранная операция в данный момент недоступна. Попробуйте позже.</string>
|
||||
<string name="token_button_unavailability_reason_buy_unavailable">В данный момент покупка монеты %s недоступна. Но мы работаем над её добавлением.</string>
|
||||
<string name="token_button_unavailability_reason_empty_balance">У вас нет средств для отправки. Пополните счет, чтобы иметь возможность отправить с него средства.</string>
|
||||
<string name="token_button_unavailability_reason_no_quotes">Выбранная операция в данный момент недоступна. Попробуйте позже.</string>
|
||||
<string name="token_button_unavailability_reason_not_exchangeable">Обмен %s не доступен. Но мы работаем над его добавлением.</string>
|
||||
<string name="token_button_unavailability_reason_sell_unavailable">В данный момент продажа монеты %s недоступна. Но мы работаем над её добавлением.</string>
|
||||
<string name="token_details_generate_xpub">Сгенерировать XPUB</string>
|
||||
|
|
@ -548,6 +548,7 @@
|
|||
<string name="token_details_hide_alert_title">Скрыть %s</string>
|
||||
<string name="token_details_hide_token">Скрыть токен</string>
|
||||
<string name="token_details_token_type_subtitle">%1$s токен в сети %%image%% %2$s</string>
|
||||
<string name="token_details_token_type_subtitle_no_standard">Токен в сети %%image%% %1$s</string>
|
||||
<string name="token_details_unable_hide_alert_message">Токен %1$s является основной валютой в сети %2$s и не может быть скрыт до тех пор, пока у вас в списке есть другие токены этой сети.</string>
|
||||
<string name="token_details_unable_hide_alert_title">Невозможно скрыть %s</string>
|
||||
<string name="token_swap_changelly_promotion_message">Обменивайте этот токен на другие с %1$s комиссии за обслуживание с %2$s по %3$s февраля.</string>
|
||||
|
|
|
|||
|
|
@ -534,9 +534,9 @@
|
|||
<string name="toast_balances_hidden">Balances hidden</string>
|
||||
<string name="toast_balances_shown">Balances shown</string>
|
||||
<string name="toast_undo">Undo</string>
|
||||
<string name="token_button_unavailability_generic_description">This operation is currently unavailable. Please try again later.</string>
|
||||
<string name="token_button_unavailability_reason_buy_unavailable">The purchase of the %s is currently unavailable. But we are working on adding it.</string>
|
||||
<string name="token_button_unavailability_reason_empty_balance">You do not have funds to send. Top up your account to be able to send funds from it.</string>
|
||||
<string name="token_button_unavailability_reason_no_quotes">This operation is currently unavailable. Please try again later.</string>
|
||||
<string name="token_button_unavailability_reason_not_exchangeable">%s swap is not available. But we are working on adding it.</string>
|
||||
<string name="token_button_unavailability_reason_sell_unavailable">Sell of the %s coin is currently unavailable. But we are working on adding it.</string>
|
||||
<string name="token_details_generate_xpub">Generate XPUB</string>
|
||||
|
|
@ -545,6 +545,7 @@
|
|||
<string name="token_details_hide_alert_title">Hide %s</string>
|
||||
<string name="token_details_hide_token">Hide token</string>
|
||||
<string name="token_details_token_type_subtitle">%1$s token in %%image%% %2$s network</string>
|
||||
<string name="token_details_token_type_subtitle_no_standard">Token in %%image%% %1$s network</string>
|
||||
<string name="token_details_unable_hide_alert_message">The %1$s token is the main currency on the %2$s network and cannot be hidden as long as you have other tokens on this network in the list.</string>
|
||||
<string name="token_details_unable_hide_alert_title">Unable to hide %s</string>
|
||||
<string name="token_swap_changelly_promotion_message">Exchange this token for another at %1$s service fees from February %2$s-%3$s.</string>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ import java.util.Locale
|
|||
|
||||
private const val TEXT_CHUNK_THOUSAND = 3
|
||||
private const val POINT_SEPARATOR = '.'
|
||||
private const val COMMA_SEPARATOR = ','
|
||||
private const val SCIENTIFIC_NOTATION = 'e'
|
||||
const val DECIMAL_SEPARATOR_LIMIT = 1
|
||||
|
||||
@Composable
|
||||
fun rememberDecimalFormat(): DecimalFormat {
|
||||
|
|
@ -152,6 +155,47 @@ fun BigDecimal.parseBigDecimal(decimals: Int, roundingMode: RoundingMode = Round
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Universal amount string parser to [BigDecimal]
|
||||
* Able to parse values with only ONE separator, assuming separator is COMMA.
|
||||
* Otherwise returns null.
|
||||
*/
|
||||
fun String.parseBigDecimalOrNull() = runCatching {
|
||||
// Filtering value containing more than one either grouping or decimal separator.
|
||||
// We assume there will be only decimal separator, otherwise parsing will fail.
|
||||
|
||||
// Step 1. Exclude formatted (100,000.0) except scientific notation (100.000e10)
|
||||
val excludeFormatted = this.count {
|
||||
!it.isDigit() && !it.equals(SCIENTIFIC_NOTATION, ignoreCase = true)
|
||||
} > DECIMAL_SEPARATOR_LIMIT
|
||||
|
||||
// Step 2. Exclude wrong scientific notation (100e100e100)
|
||||
val excludeWrongScientific = this.count {
|
||||
it.equals(SCIENTIFIC_NOTATION, ignoreCase = true)
|
||||
} > DECIMAL_SEPARATOR_LIMIT
|
||||
if (excludeFormatted || excludeWrongScientific) return null
|
||||
|
||||
// An attempt to parse value with POINT decimal separator
|
||||
val parsed = this.toBigDecimalOrNull()
|
||||
|
||||
if (parsed == null) {
|
||||
// If parsing with POINT separator fails trying to parse with COMMA separator
|
||||
val decimalFormatSymbol = DecimalFormatSymbols().apply {
|
||||
decimalSeparator = COMMA_SEPARATOR
|
||||
}
|
||||
val decimalFormat = DecimalFormat().apply {
|
||||
decimalFormatSymbols = decimalFormatSymbol
|
||||
isParseBigDecimal = true
|
||||
}
|
||||
|
||||
// Return either number or null if fails
|
||||
decimalFormat.parse(this) as? BigDecimal
|
||||
} else {
|
||||
// If parsing with POINT separator succeeds return number
|
||||
parsed
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
private fun Int.getWithIntegerDecimals(before: String, separator: Char, after: String): String = if (this == 0) {
|
||||
before
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.data.qrscanning.repository
|
||||
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.core.ui.utils.parseBigDecimalOrNull
|
||||
import com.tangem.domain.qrscanning.models.QrResult
|
||||
import com.tangem.domain.qrscanning.models.SourceType
|
||||
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
|
||||
|
|
@ -44,7 +45,7 @@ internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository {
|
|||
when (it.key) {
|
||||
Parameter.Amount -> {
|
||||
// According to BIP-0021, the value is specified in decimals. No conversion needed
|
||||
result.amount = it.value.toBigDecimalOrNull()
|
||||
result.amount = it.value.parseBigDecimalOrNull()
|
||||
}
|
||||
Parameter.Message,
|
||||
Parameter.Memo,
|
||||
|
|
@ -52,16 +53,17 @@ internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository {
|
|||
result.memo = URLDecoder.decode(it.value, "UTF-8")
|
||||
}
|
||||
Parameter.Address -> {
|
||||
// If 'address' parameter is exists, then currency must be TOKEN.
|
||||
val tokenCurrency = cryptoCurrency as? CryptoCurrency.Token ?: return QrResult()
|
||||
|
||||
// Overrides destination address for token transfers (ERC-681)
|
||||
if (cryptoCurrency is CryptoCurrency.Token) {
|
||||
// `address` parameter is used only if the contract address, encoded in the QR,
|
||||
// matches the contract address of the token.
|
||||
// Otherwise, the scanned string is likely malformed, and we stop the entire parsing routine
|
||||
if (cryptoCurrency.contractAddress.equals(address, ignoreCase = true)) {
|
||||
result.address = it.value
|
||||
} else {
|
||||
return QrResult()
|
||||
}
|
||||
// `address` parameter is used only if the contract address, encoded in the QR,
|
||||
// matches the contract address of the token.
|
||||
// Otherwise, the scanned string is likely malformed, and we stop the entire parsing routin
|
||||
if (tokenCurrency.contractAddress.equals(address, ignoreCase = true)) {
|
||||
result.address = it.value
|
||||
} else {
|
||||
return QrResult()
|
||||
}
|
||||
}
|
||||
Parameter.Value,
|
||||
|
|
@ -69,7 +71,7 @@ internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository {
|
|||
-> {
|
||||
// Extra convert parses scientific notation to decimal
|
||||
// This is necessary to be able comparing BigDecimal values
|
||||
result.amount = it.value.toBigDecimalOrNull()
|
||||
result.amount = it.value.parseBigDecimalOrNull()
|
||||
?.toPlainString()?.toBigDecimalOrNull()
|
||||
?.divide(BigDecimal.TEN.pow(cryptoCurrency.decimals))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -152,11 +152,6 @@ internal class DefaultQrScanningEventsRepositoryTest {
|
|||
QrResult(address = address2),
|
||||
cryptoCurrency,
|
||||
)
|
||||
positiveCase(
|
||||
"$garbage$schema2:$address2$function?$addressParam=$addressParamValue",
|
||||
QrResult(address = address2),
|
||||
cryptoCurrency,
|
||||
)
|
||||
positiveCase(
|
||||
"$garbage$schema2:$address2?$someParam=$someParamValue&$valueParam=$someAmountParamValue",
|
||||
QrResult(address = address2),
|
||||
|
|
@ -177,6 +172,11 @@ internal class DefaultQrScanningEventsRepositoryTest {
|
|||
QrResult(address = address2, amount = BigDecimal("0.000000023")),
|
||||
cryptoCurrency,
|
||||
)
|
||||
negativeCase(
|
||||
"$garbage$schema2:$address2$function?$addressParam=$addressParamValue",
|
||||
QrResult(address = address2),
|
||||
cryptoCurrency,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -232,11 +232,21 @@ internal class DefaultQrScanningEventsRepositoryTest {
|
|||
QrResult(address = address2, amount = BigDecimal("2300")),
|
||||
tokenCryptoCurrency,
|
||||
)
|
||||
positiveCase(
|
||||
"$address4?$addressParam=$addressParamValue",
|
||||
QrResult(address = addressParamValue),
|
||||
tokenCryptoCurrency,
|
||||
)
|
||||
negativeCase(
|
||||
"$address2?$someParam=$someParamValue&$amountParam=$amountParamValue",
|
||||
QrResult(address = address2, amount = BigDecimal("123.123"), memo = memoParamValueUtf8),
|
||||
tokenCryptoCurrency,
|
||||
)
|
||||
negativeCase(
|
||||
"$address4?$addressParam=$addressParamValue",
|
||||
QrResult(address = addressParamValue),
|
||||
cryptoCurrency,
|
||||
)
|
||||
}
|
||||
|
||||
private fun positiveCase(input: String, expected: QrResult, cryptoCurrency: CryptoCurrency) {
|
||||
|
|
|
|||
|
|
@ -92,9 +92,9 @@ internal class SendFragment : ComposeFragment() {
|
|||
delay(QR_SCAN_DELAY)
|
||||
|
||||
// Delayed launch is needed in order for the UI to be drawn and to process the sent events.
|
||||
// If do not use the delay, then etAmount error field is not displayed when
|
||||
// If do not use the delay, then error field is not displayed when
|
||||
// inserting an incorrect amount by shareUri
|
||||
viewModel.onRecipientAddressScanned(it)
|
||||
viewModel.onQrCodeScanned(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -569,12 +569,14 @@ internal class SendViewModel @Inject constructor(
|
|||
// endregion
|
||||
|
||||
// region recipient state clicks
|
||||
fun onRecipientAddressScanned(address: String) {
|
||||
fun onQrCodeScanned(address: String) {
|
||||
viewModelScope.launch(dispatchers.main) {
|
||||
parseQrCodeUseCase(address, cryptoCurrency).fold(
|
||||
ifRight = { parsedCode ->
|
||||
onRecipientAddressValueChange(parsedCode.address, EnterAddressSource.QRCode)
|
||||
parsedCode.amount?.let { onAmountValueChange(it.toPlainString()) }
|
||||
parsedCode.amount?.let {
|
||||
onAmountValueChange(it.parseBigDecimal(decimals = cryptoCurrency.decimals))
|
||||
}
|
||||
parsedCode.memo?.let { onRecipientMemoValueChange(it) }
|
||||
},
|
||||
ifLeft = {
|
||||
|
|
|
|||
|
|
@ -57,6 +57,21 @@ internal object TokenDetailsPreviewData {
|
|||
),
|
||||
)
|
||||
|
||||
val tokenInfoBlockStateWithLongNameNoStandard = TokenInfoBlockState(
|
||||
name = "Tether (USDT) with long name test",
|
||||
iconState = TokenInfoBlockState.IconState.TokenIcon(
|
||||
url = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/stellar.png",
|
||||
fallbackTint = Color.Cyan,
|
||||
fallbackBackground = Color.Blue,
|
||||
isGrayscale = false,
|
||||
),
|
||||
currency = TokenInfoBlockState.Currency.Token(
|
||||
standardName = null,
|
||||
networkIcon = R.drawable.img_shibarium_22,
|
||||
networkName = "Shibarium",
|
||||
),
|
||||
)
|
||||
|
||||
val tokenInfoBlockState = TokenInfoBlockState(
|
||||
name = "Tether USDT",
|
||||
iconState = TokenInfoBlockState.IconState.CustomTokenIcon(
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ internal data class TokenInfoBlockState(
|
|||
* @param networkIcon - token's network icon.
|
||||
*/
|
||||
data class Token(
|
||||
val standardName: String,
|
||||
val standardName: String?,
|
||||
val networkName: String,
|
||||
@DrawableRes val networkIcon: Int,
|
||||
) : Currency()
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import com.tangem.core.ui.extensions.networkIconResId
|
|||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||
import com.tangem.domain.tokens.model.Network
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.*
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsActionButton
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsPullToRefreshConfig
|
||||
|
|
@ -40,7 +41,7 @@ internal class TokenDetailsSkeletonStateConverter(
|
|||
currency = when (value) {
|
||||
is CryptoCurrency.Coin -> TokenInfoBlockState.Currency.Native
|
||||
is CryptoCurrency.Token -> TokenInfoBlockState.Currency.Token(
|
||||
standardName = value.network.standardType.name,
|
||||
standardName = value.network.standardType.getSpecifiedNameOrNull(),
|
||||
networkName = value.network.name,
|
||||
networkIcon = value.networkIconResId,
|
||||
)
|
||||
|
|
@ -65,6 +66,9 @@ internal class TokenDetailsSkeletonStateConverter(
|
|||
)
|
||||
}
|
||||
|
||||
private fun Network.StandardType.getSpecifiedNameOrNull(): String? =
|
||||
name.takeIf { this !is Network.StandardType.Unspecified }
|
||||
|
||||
private fun createMenu(cryptoCurrency: CryptoCurrency): TokenDetailsAppBarMenuConfig = TokenDetailsAppBarMenuConfig(
|
||||
items = buildList {
|
||||
if (featureToggles.isGenerateXPubEnabled() && isBitcoin(cryptoCurrency.network.id.value)) {
|
||||
|
|
|
|||
|
|
@ -105,13 +105,22 @@ private const val SEPARATOR = " %image% "
|
|||
|
||||
@Composable
|
||||
private fun extractNetwork(tokenCurrency: TokenInfoBlockState.Currency.Token): ExtractedTokenNetworkText {
|
||||
val splitString = stringResource(
|
||||
id = R.string.token_details_token_type_subtitle,
|
||||
formatArgs = arrayOf(
|
||||
tokenCurrency.standardName,
|
||||
tokenCurrency.networkName,
|
||||
),
|
||||
).split(SEPARATOR)
|
||||
val splitString = if (tokenCurrency.standardName != null) {
|
||||
stringResource(
|
||||
id = R.string.token_details_token_type_subtitle,
|
||||
formatArgs = arrayOf(
|
||||
tokenCurrency.standardName,
|
||||
tokenCurrency.networkName,
|
||||
),
|
||||
).split(SEPARATOR)
|
||||
} else {
|
||||
stringResource(
|
||||
id = R.string.token_details_token_type_subtitle_no_standard,
|
||||
formatArgs = arrayOf(
|
||||
tokenCurrency.networkName,
|
||||
),
|
||||
).split(SEPARATOR)
|
||||
}
|
||||
|
||||
return remember(splitString) {
|
||||
ExtractedTokenNetworkText(
|
||||
|
|
@ -153,5 +162,6 @@ private class TokenInfoStateProvider : CollectionPreviewParameterProvider<TokenI
|
|||
TokenDetailsPreviewData.tokenInfoBlockState,
|
||||
TokenDetailsPreviewData.tokenInfoBlockStateWithLongName,
|
||||
TokenDetailsPreviewData.tokenInfoBlockStateWithLongNameInMainCurrency,
|
||||
TokenDetailsPreviewData.tokenInfoBlockStateWithLongNameNoStandard,
|
||||
),
|
||||
)
|
||||
|
|
@ -1 +1 @@
|
|||
Subproject commit 0e4934995b8e7c69862ef80362e86bd166bd1288
|
||||
Subproject commit d8e6da9ab3a0442657822352cb6274237d88e77d
|
||||
Loading…
Add table
Add a link
Reference in a new issue