Updated on 2026-08-14
This commit is contained in:
parent
b55b705ee1
commit
bfbf4fff95
7 changed files with 195 additions and 127 deletions
|
|
@ -1,52 +0,0 @@
|
|||
{
|
||||
"issuerData": null,
|
||||
"issuerName": "TANGEM SDK",
|
||||
"series": "DB",
|
||||
"numberFormat": "",
|
||||
"PIN": "000000",
|
||||
"PIN2": "000",
|
||||
"PIN3": "",
|
||||
"hexCrExKey": "",
|
||||
"CVC": "000",
|
||||
"curveID": "ed25519",
|
||||
"ndef": "com.tangem.wallet",
|
||||
"startNumber": 300000000,
|
||||
"count": 20,
|
||||
"SigningMethod": 0,
|
||||
"MaxSignatures": 1000000,
|
||||
"pauseBeforePIN2": 15000,
|
||||
"smartSecurityDelay": true,
|
||||
"releaseVersion": false,
|
||||
"isReusable": true,
|
||||
"allowSwapPIN": false,
|
||||
"allowSwapPIN2": true,
|
||||
"useActivation": false,
|
||||
"useCVC": false,
|
||||
"useNDEF": true,
|
||||
"useDynamicNDEF": true,
|
||||
"useOneCommandAtTime": false,
|
||||
"useBlock": false,
|
||||
"allowSelectBlockchain": false,
|
||||
"forbidPurgeWallet": false,
|
||||
"protocolAllowUnencrypted": true,
|
||||
"protocolAllowStaticEncryption": true,
|
||||
"protectIssuerDataAgainstReplay": true,
|
||||
"forbidDefaultPIN": false,
|
||||
"disablePrecomputedNDEF": false,
|
||||
"skipSecurityDelayIfValidatedByIssuer": false,
|
||||
"skipCheckPIN2andCVCIfValidatedByIssuer": false,
|
||||
"skipSecurityDelayIfValidatedByLinkedTerminal": true,
|
||||
"restrictOverwriteIssuerDataEx": false,
|
||||
"requireTerminalTxSignature": false,
|
||||
"requireTerminalCertSignature": false,
|
||||
"checkPIN3onCard": false,
|
||||
"cardData": {
|
||||
"date": "2020-02-17",
|
||||
"batch": "FF87",
|
||||
"blockchain": "XLM",
|
||||
"product_note": true,
|
||||
"product_tag": false,
|
||||
"product_id_card": false
|
||||
},
|
||||
"createWallet": true
|
||||
}
|
||||
|
|
@ -10,25 +10,25 @@ import com.tangem.tasks.TaskEvent
|
|||
*/
|
||||
class PersonalizeAction : BaseCardAction() {
|
||||
override fun executeMainAction(attrs: AttrForAction, callback: ActionCallback) {
|
||||
var cardId: String? = null
|
||||
ScanAction().executeMainAction(attrs) { response, b ->
|
||||
when (response) {
|
||||
is TaskEvent.Event -> {
|
||||
val taskEvent = response as TaskEvent.Event
|
||||
val cardId = handleDataEvent(taskEvent.data)
|
||||
cardId = handleDataEvent(taskEvent.data)
|
||||
if (cardId == null) {
|
||||
handleResponse(response, null, attrs, callback)
|
||||
return@executeMainAction
|
||||
}
|
||||
}
|
||||
is TaskEvent.Completion -> {
|
||||
val cardConfig = attrs.payload.remove(cardConfig) as? CardConfig
|
||||
?: throw IllegalArgumentException("CardConfig must be in the payloads of the ParamsManager")
|
||||
|
||||
attrs.cardManager.personalize(cardConfig, cardId) {
|
||||
attrs.cardManager.personalize(cardConfig, cardId!!) {
|
||||
handleResponse(it, null, attrs, callback)
|
||||
}
|
||||
}
|
||||
is TaskEvent.Completion -> {
|
||||
handleResponse(response, null, attrs, callback)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -244,46 +244,47 @@ class PersonalizeConfigConverter {
|
|||
}?.let { ndefs.add(it) }
|
||||
|
||||
return CardConfig(
|
||||
cardData = cardData,
|
||||
curveID = EllipticCurve.byName(applyConfig.curveID) ?: EllipticCurve.Secp256k1,
|
||||
signingMethod = signingMethod,
|
||||
createWallet = applyConfig.createWallet,
|
||||
maxSignatures = applyConfig.MaxSignatures.toInt(),
|
||||
isReusable = applyConfig.isReusable,
|
||||
protocolAllowUnencrypted = applyConfig.protocolAllowUnencrypted,
|
||||
protocolAllowStaticEncryption = applyConfig.protocolAllowStaticEncryption,
|
||||
useActivation = applyConfig.useActivation,
|
||||
|
||||
useOneCommandAtTime = applyConfig.oneApdu,
|
||||
useCvc = applyConfig.useCVC,
|
||||
useBlock = applyConfig.useBlock,
|
||||
allowSwapPin = applyConfig.allowSwapPIN,
|
||||
allowSwapPin2 = applyConfig.allowSwapPIN2,
|
||||
useNdef = applyConfig.useNDEF,
|
||||
useDynamicNdef = applyConfig.useDynamicNDEF,
|
||||
protectIssuerDataAgainstReplay = applyConfig.protectIssuerDataAgainstReplay,
|
||||
forbidDefaultPin = applyConfig.forbidDefaultPIN,
|
||||
smartSecurityDelay = applyConfig.smartSecurityDelay,
|
||||
pauseBeforePin2 = applyConfig.pauseBeforePIN2.toInt(),
|
||||
allowSelectBlockchain = applyConfig.allowSelectBlockchain,
|
||||
forbidPurgeWallet = applyConfig.forbidPurgeWallet,
|
||||
disablePrecomputedNdef = applyConfig.disablePrecomputedNDEF,
|
||||
skipSecurityDelayIfValidatedByIssuer = applyConfig.skipSecurityDelayIfValidatedByIssuer,
|
||||
skipCheckPIN2andCVCIfValidatedByIssuer = applyConfig.skipCheckPIN2andCVCIfValidatedByIssuer,
|
||||
|
||||
skipSecurityDelayIfValidatedByLinkedTerminal = applyConfig.skipSecurityDelayIfValidatedByLinkedTerminal,
|
||||
restrictOverwriteIssuerDataEx = applyConfig.restrictOverwriteIssuerDataEx,
|
||||
|
||||
requireTerminalTxSignature = applyConfig.requireTerminalTxSignature,
|
||||
requireTerminalCertSignature = applyConfig.requireTerminalCertSignature,
|
||||
checkPin3onCard = applyConfig.checkPIN3onCard,
|
||||
|
||||
cvc = applyConfig.CVC,
|
||||
pin = applyConfig.PIN,
|
||||
pin2 = applyConfig.PIN2,
|
||||
pin3 = applyConfig.PIN3,
|
||||
hexCrExKey = applyConfig.hexCrExKey,
|
||||
ndefRecords = ndefs
|
||||
"Tangem",
|
||||
"MyHomeLts",
|
||||
"HS",
|
||||
0,
|
||||
1000,
|
||||
applyConfig.PIN,
|
||||
applyConfig.PIN2,
|
||||
applyConfig.PIN3,
|
||||
applyConfig.hexCrExKey,
|
||||
applyConfig.CVC,
|
||||
applyConfig.pauseBeforePIN2.toInt(),
|
||||
applyConfig.smartSecurityDelay,
|
||||
EllipticCurve.byName(applyConfig.curveID) ?: EllipticCurve.Secp256k1,
|
||||
signingMethod,
|
||||
applyConfig.MaxSignatures.toInt(),
|
||||
applyConfig.isReusable,
|
||||
applyConfig.allowSwapPIN,
|
||||
applyConfig.allowSwapPIN2,
|
||||
applyConfig.useActivation,
|
||||
applyConfig.useCVC,
|
||||
applyConfig.useNDEF,
|
||||
applyConfig.useDynamicNDEF,
|
||||
applyConfig.oneApdu,
|
||||
applyConfig.useBlock,
|
||||
applyConfig.allowSelectBlockchain,
|
||||
applyConfig.forbidPurgeWallet,
|
||||
applyConfig.protocolAllowUnencrypted,
|
||||
applyConfig.protocolAllowStaticEncryption,
|
||||
applyConfig.protectIssuerDataAgainstReplay,
|
||||
applyConfig.forbidDefaultPIN,
|
||||
applyConfig.disablePrecomputedNDEF,
|
||||
applyConfig.skipSecurityDelayIfValidatedByIssuer,
|
||||
applyConfig.skipCheckPIN2andCVCIfValidatedByIssuer,
|
||||
applyConfig.skipSecurityDelayIfValidatedByLinkedTerminal,
|
||||
applyConfig.restrictOverwriteIssuerDataEx,
|
||||
applyConfig.requireTerminalTxSignature,
|
||||
applyConfig.requireTerminalCertSignature,
|
||||
applyConfig.checkPIN3onCard,
|
||||
applyConfig.createWallet,
|
||||
cardData,
|
||||
ndefs
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
package com.tangem.tangemtest.ucase.variants.personalize.dto
|
||||
|
||||
import com.tangem.Config
|
||||
import com.tangem.commands.personalization.entities.Issuer
|
||||
import com.tangem.common.KeyPair
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
|
||||
interface CardManagerConfig {
|
||||
companion object {
|
||||
fun default(): Config {
|
||||
val issuer = issuer()
|
||||
return Config(
|
||||
true,
|
||||
issuer = issuer(),
|
||||
issuerPublicKey = null,
|
||||
acquirerKeyPair = acquirer(),
|
||||
manufacturerKeyPair = manufacturer()
|
||||
)
|
||||
}
|
||||
|
||||
private fun issuer(): Issuer {
|
||||
return Issuer(
|
||||
name = "TANGEM SDK",
|
||||
id = "TANGEM SDK" + "\u0000",
|
||||
dataKeyPair = KeyPair(
|
||||
"".toByteArray(),
|
||||
"11121314151617184771ED81F2BACF57479E4735EB1405083927372D40DA9E92".toByteArray()
|
||||
),
|
||||
transactionKeyPair = KeyPair(
|
||||
"".toByteArray(),
|
||||
"11121314151617184771ED81F2BACF57479E4735EB1405081918171615141312".toByteArray()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun acquirer(): KeyPair {
|
||||
return KeyPair(
|
||||
"".toByteArray(),
|
||||
"21222324252627284771ED81F2BACF57479E4735EB1405083927372D40DA9E92".toByteArray()
|
||||
)
|
||||
}
|
||||
|
||||
private fun manufacturer(): KeyPair {
|
||||
val manufacturer = Manufacturer(Manufacturer.Mode.Developer)
|
||||
return KeyPair(
|
||||
manufacturer.publicKey,
|
||||
manufacturer.privateKey
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package com.tangem.tangemtest.ucase.variants.personalize.dto
|
||||
|
||||
class Manufacturer(private val mode: Mode) {
|
||||
|
||||
private val devPrivateKeyArray = byteArrayOf(
|
||||
0x1B.toByte(), 0x48.toByte(), 0xCF.toByte(), 0xD2.toByte(), 0x4B.toByte(), 0xBB.toByte(), 0x5B.toByte(), 0x39.toByte(),
|
||||
0x47.toByte(), 0x71.toByte(), 0xED.toByte(), 0x81.toByte(), 0xF2.toByte(), 0xBA.toByte(), 0xCF.toByte(), 0x57.toByte(),
|
||||
0x47.toByte(), 0x9E.toByte(), 0x47.toByte(), 0x35.toByte(), 0xEB.toByte(), 0x14.toByte(), 0x05.toByte(), 0x08.toByte(),
|
||||
0x39.toByte(), 0x27.toByte(), 0x37.toByte(), 0x2D.toByte(), 0x40.toByte(), 0xDA.toByte(), 0x9E.toByte(), 0x92.toByte()
|
||||
)
|
||||
private val devPublicKeyArray = byteArrayOf(0x04,
|
||||
0xBA.toByte(), 0xB8.toByte(), 0x6D.toByte(), 0x56.toByte(), 0x29.toByte(), 0x8C.toByte(), 0x99.toByte(), 0x6F.toByte(),
|
||||
0x56.toByte(), 0x4A.toByte(), 0x84.toByte(), 0xFC.toByte(), 0x88.toByte(), 0xE2.toByte(), 0x8A.toByte(), 0xED.toByte(),
|
||||
0x38.toByte(), 0x18.toByte(), 0x4B.toByte(), 0x12.toByte(), 0xF0.toByte(), 0x7E.toByte(), 0x51.toByte(), 0x91.toByte(),
|
||||
0x13.toByte(), 0xBE.toByte(), 0xF4.toByte(), 0x8C.toByte(), 0x76.toByte(), 0xF3.toByte(), 0xDF.toByte(), 0x3A.toByte(),
|
||||
0xDC.toByte(), 0x30.toByte(), 0x35.toByte(), 0x99.toByte(), 0xB0.toByte(), 0x8A.toByte(), 0xC0.toByte(), 0x5B.toByte(),
|
||||
0x55.toByte(), 0xEC.toByte(), 0x3D.toByte(), 0xF9.toByte(), 0x8D.toByte(), 0x93.toByte(), 0x38.toByte(), 0x57.toByte(),
|
||||
0x3A.toByte(), 0x62.toByte(), 0x42.toByte(), 0xF7.toByte(), 0x6F.toByte(), 0x5D.toByte(), 0x28.toByte(), 0xF4.toByte(),
|
||||
0xF0.toByte(), 0xF3.toByte(), 0x64.toByte(), 0xE8.toByte(), 0x7E.toByte(), 0x8F.toByte(), 0xCA.toByte(), 0x2F.toByte()
|
||||
)
|
||||
private val releasePublicKeyArray = byteArrayOf(0x04.toByte(),
|
||||
0x2E.toByte(), 0xDE.toByte(), 0x11.toByte(), 0x9B.toByte(), 0xF3.toByte(), 0x37.toByte(), 0xB2.toByte(), 0x64.toByte(),
|
||||
0xFD.toByte(), 0xA1.toByte(), 0x32.toByte(), 0xCF.toByte(), 0xC7.toByte(), 0xC1.toByte(), 0x77.toByte(), 0x82.toByte(),
|
||||
0x4D.toByte(), 0x36.toByte(), 0x17.toByte(), 0xDA.toByte(), 0xC8.toByte(), 0x0F.toByte(), 0x25.toByte(), 0xDB.toByte(),
|
||||
0xB2.toByte(), 0xA4.toByte(), 0xA8.toByte(), 0xA1.toByte(), 0x18.toByte(), 0x3C.toByte(), 0x03.toByte(), 0xB9.toByte(),
|
||||
0x15.toByte(), 0x23.toByte(), 0x05.toByte(), 0xF8.toByte(), 0xF1.toByte(), 0xDB.toByte(), 0x97.toByte(), 0x00.toByte(),
|
||||
0x45.toByte(), 0x18.toByte(), 0x48.toByte(), 0x0D.toByte(), 0x50.toByte(), 0x91.toByte(), 0xAD.toByte(), 0xC1.toByte(),
|
||||
0xCA.toByte(), 0xB9.toByte(), 0xEA.toByte(), 0xCC.toByte(), 0xC1.toByte(), 0x8E.toByte(), 0x1B.toByte(), 0x9E.toByte(),
|
||||
0x9C.toByte(), 0x3B.toByte(), 0xEF.toByte(), 0xB2.toByte(), 0x93.toByte(), 0xDD.toByte(), 0x37.toByte(), 0xB2.toByte()
|
||||
)
|
||||
|
||||
val publicKey: ByteArray
|
||||
get() = if (mode == Mode.Developer) {
|
||||
devPublicKeyArray
|
||||
} else {
|
||||
releasePublicKeyArray
|
||||
}
|
||||
|
||||
@get:Throws(Exception::class)
|
||||
val privateKey: ByteArray
|
||||
get() = if (mode == Mode.Developer) {
|
||||
devPrivateKeyArray
|
||||
} else {
|
||||
throw Exception("Real manufacturer key is very secret!")
|
||||
}
|
||||
|
||||
val name: String
|
||||
get() = if (mode == Mode.Release) "SMART CASH" else "SMART CASH SDK"
|
||||
|
||||
enum class Mode {
|
||||
Developer, Release
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
package com.tangem.tangemtest.ucase.variants.personalize.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
|
|
@ -13,7 +12,10 @@ import androidx.lifecycle.Observer
|
|||
import com.google.android.material.snackbar.BaseTransientBottomBar
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.tangem.CardManager
|
||||
import com.tangem.tangem_sdk_new.extensions.init
|
||||
import com.tangem.tangem_sdk_new.DefaultCardManagerDelegate
|
||||
import com.tangem.tangem_sdk_new.NfcLifecycleObserver
|
||||
import com.tangem.tangem_sdk_new.TerminalKeysStorage
|
||||
import com.tangem.tangem_sdk_new.nfc.NfcManager
|
||||
import com.tangem.tangemtest.R
|
||||
import com.tangem.tangemtest._arch.structure.abstraction.Block
|
||||
import com.tangem.tangemtest._main.MainViewModel
|
||||
|
|
@ -23,12 +25,11 @@ import com.tangem.tangemtest.ucase.domain.paramsManager.ParamsManagerFactory
|
|||
import com.tangem.tangemtest.ucase.resources.ActionType
|
||||
import com.tangem.tangemtest.ucase.ui.ActionViewModelFactory
|
||||
import com.tangem.tangemtest.ucase.ui.ParamsViewModel
|
||||
import com.tangem.tangemtest.ucase.variants.personalize.dto.CardManagerConfig
|
||||
import com.tangem.tangemtest.ucase.variants.personalize.dto.PersonalizeConfig
|
||||
import com.tangem.tangemtest.ucase.variants.personalize.ui.widgets.WidgetBuilder
|
||||
import kotlinx.android.synthetic.main.fg_personalize.*
|
||||
import ru.dev.gbixahue.eu4d.lib.android.global.log.Log
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.nio.charset.Charset
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
|
|
@ -36,7 +37,7 @@ import java.nio.charset.Charset
|
|||
class PersonalizeFragment : Fragment() {
|
||||
|
||||
private val mainActivityVM: MainViewModel by activityViewModels()
|
||||
private val personalizeVM: PersonalizeViewModel by viewModels { PersonalizeViewModelFactory(getPersonalizeJson()) }
|
||||
private val personalizeVM: PersonalizeViewModel by viewModels { PersonalizeViewModelFactory(PersonalizeConfig()) }
|
||||
|
||||
private val paramsManager: ParamsManager by lazy { ParamsManagerFactory.createFactory().get(ActionType.Personalize)!! }
|
||||
private val paramsVM: ParamsViewModel by viewModels() { ActionViewModelFactory(paramsManager) }
|
||||
|
|
@ -55,6 +56,8 @@ class PersonalizeFragment : Fragment() {
|
|||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val cardManager = initCardManager() ?: return
|
||||
|
||||
val blockList = mutableListOf<Block>()
|
||||
personalizeVM.ldBlockList.observe(viewLifecycleOwner, Observer { list ->
|
||||
blockList.clear()
|
||||
|
|
@ -65,7 +68,7 @@ class PersonalizeFragment : Fragment() {
|
|||
personalizeVM.toggleDescriptionVisibility(it)
|
||||
})
|
||||
|
||||
paramsVM.setCardManager(CardManager.init(requireActivity()))
|
||||
paramsVM.setCardManager(cardManager)
|
||||
paramsVM.ldResponse.observe(viewLifecycleOwner, Observer {
|
||||
Log.d(this, "action response: ${if (it.length > 50) it.substring(0..50) else it}")
|
||||
showSnackbarMessage(it)
|
||||
|
|
@ -78,27 +81,37 @@ class PersonalizeFragment : Fragment() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun initCardManager(): CardManager? {
|
||||
val activity = activity ?: return null
|
||||
val nfcManager = NfcManager().apply {
|
||||
this.setCurrentActivity(activity)
|
||||
activity.lifecycle.addObserver(NfcLifecycleObserver(this))
|
||||
}
|
||||
val cardManagerDelegate = DefaultCardManagerDelegate(nfcManager.reader).apply {
|
||||
this.activity = activity
|
||||
}
|
||||
return CardManager(nfcManager.reader, cardManagerDelegate, CardManagerConfig.default()).apply {
|
||||
this.setTerminalKeysService(TerminalKeysStorage(activity.application))
|
||||
}
|
||||
}
|
||||
|
||||
protected fun showSnackbarMessage(message: String) {
|
||||
Snackbar.make(mainView, message, BaseTransientBottomBar.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun getPersonalizeJson(): String {
|
||||
return getJsonFromAssets(requireContext(), "personalize_default.json") ?: "[]"
|
||||
}
|
||||
|
||||
private fun getJsonFromAssets(context: Context, fileName: String): String? {
|
||||
return try {
|
||||
val stream: InputStream = context.assets.open(fileName)
|
||||
val size: Int = stream.available()
|
||||
val buffer = ByteArray(size)
|
||||
stream.read(buffer)
|
||||
stream.close()
|
||||
String(buffer, Charset.forName("UTF-8"))
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
// private fun getJsonFromAssets(context: Context, fileName: String): String? {
|
||||
// return try {
|
||||
// val stream: InputStream = context.assets.open(fileName)
|
||||
// val size: Int = stream.available()
|
||||
// val buffer = ByteArray(size)
|
||||
// stream.read(buffer)
|
||||
// stream.close()
|
||||
// String(buffer, Charset.forName("UTF-8"))
|
||||
// } catch (e: IOException) {
|
||||
// e.printStackTrace()
|
||||
// null
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -14,18 +14,17 @@ import com.tangem.tangemtest.ucase.variants.personalize.dto.PersonalizeConfig
|
|||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
class PersonalizeViewModelFactory(private val jsonPersonalizeString: String) : ViewModelProvider.NewInstanceFactory() {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T = PersonalizeViewModel(jsonPersonalizeString) as T
|
||||
class PersonalizeViewModelFactory(private val config: PersonalizeConfig) : ViewModelProvider.NewInstanceFactory() {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T = PersonalizeViewModel(config) as T
|
||||
}
|
||||
|
||||
class PersonalizeViewModel(private val jsonPersonalizeString: String) : ViewModel() {
|
||||
class PersonalizeViewModel(private val config: PersonalizeConfig) : ViewModel() {
|
||||
|
||||
val ldBlockList: MutableLiveData<List<Block>> by lazy { MutableLiveData(initBlockList()) }
|
||||
|
||||
private fun initBlockList(): List<Block> = parseJsonToBlockList(jsonPersonalizeString)
|
||||
private fun initBlockList(): List<Block> = createBlocksFromConfig(config)
|
||||
|
||||
fun parseJsonToBlockList(jsonString: String): List<Block> {
|
||||
val config = Gson().fromJson(jsonString, PersonalizeConfig::class.java)
|
||||
fun createBlocksFromConfig(config: PersonalizeConfig): List<Block> {
|
||||
return PersonalizeConfigConverter().toBlock(config)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue