Updated on 2026-08-14
This commit is contained in:
parent
0193926e46
commit
9f9c5db55e
14 changed files with 581 additions and 476 deletions
|
|
@ -3,6 +3,7 @@ package com.tangem.data.network;
|
|||
import com.tangem.data.network.model.BlockcypherBody;
|
||||
import com.tangem.data.network.model.BlockcypherResponse;
|
||||
import com.tangem.data.network.model.BlockcypherFee;
|
||||
import com.tangem.data.network.model.BlockcypherTx;
|
||||
|
||||
import retrofit2.Call;
|
||||
import retrofit2.http.Body;
|
||||
|
|
@ -19,6 +20,9 @@ public interface BlockcypherApi {
|
|||
@GET(Server.ApiBlockcypher.Method.ADDRESS)
|
||||
Call<BlockcypherResponse> blockcypherAddress(@Path("blockchain") String blockchain, @Path("network") String network, @Path("address") String address);
|
||||
|
||||
@GET(Server.ApiBlockcypher.Method.TXS)
|
||||
Call<BlockcypherTx> blockcypherTxs(@Path("blockchain") String blockchain, @Path("network") String network, @Path("txHash") String txHash);
|
||||
|
||||
@Headers("Content-Type: application/json")
|
||||
@POST(Server.ApiBlockcypher.Method.PUSH)
|
||||
Call<BlockcypherResponse> blockcypherPush(@Path("blockchain") String blockchain, @Path("network") String network, @Body BlockcypherBody blockcypherBody, @Query("token") String token);
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ public class Server {
|
|||
public static class Method {
|
||||
static final String MAIN = URL_BLOCKCYPHER + V1_MAIN;
|
||||
static final String ADDRESS = MAIN + "/addrs/{address}?unspentOnly=true&includeScript=true";
|
||||
static final String TXS = MAIN + "/txs/{txHash}?includeHex=true";
|
||||
static final String PUSH = MAIN + "/txs/push";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import com.tangem.data.Blockchain;
|
|||
import com.tangem.data.network.model.BlockcypherBody;
|
||||
import com.tangem.data.network.model.BlockcypherFee;
|
||||
import com.tangem.data.network.model.BlockcypherResponse;
|
||||
import com.tangem.data.network.model.BlockcypherTx;
|
||||
|
||||
import java.util.Random;
|
||||
|
||||
|
|
@ -20,6 +21,7 @@ public class ServerApiBlockcypher {
|
|||
|
||||
public static final String BLOCKCYPHER_ADDRESS = "blockcypher_address";
|
||||
public static final String BLOCKCYPHER_FEE = "blockcypher_fee";
|
||||
public static final String BLOCKCYPHER_TXS = "blockcypher_txs";
|
||||
public static final String BLOCKCYPHER_SEND = "blockcypher_send";
|
||||
|
||||
private int requestsCount = 0;
|
||||
|
|
@ -31,6 +33,8 @@ public class ServerApiBlockcypher {
|
|||
|
||||
private ResponseListener responseListener;
|
||||
|
||||
private TxResponseListener txResponseListener;
|
||||
|
||||
public interface ResponseListener {
|
||||
void onSuccess(String method, BlockcypherResponse blockcypherResponse);
|
||||
|
||||
|
|
@ -39,10 +43,20 @@ public class ServerApiBlockcypher {
|
|||
void onFail(String method, String message);
|
||||
}
|
||||
|
||||
public interface TxResponseListener {
|
||||
void onSuccess(BlockcypherTx blockcypherTx);
|
||||
|
||||
void onFail(String message);
|
||||
}
|
||||
|
||||
public void setResponseListener(ResponseListener listener) {
|
||||
responseListener = listener;
|
||||
}
|
||||
|
||||
public void setTxResponseListener(TxResponseListener txListener) {
|
||||
txResponseListener = txListener;
|
||||
}
|
||||
|
||||
public void requestData(String blockchainID, String method, String wallet, String tx) {
|
||||
requestsCount++;
|
||||
String blockchain = blockchainID.toLowerCase();
|
||||
|
|
@ -60,8 +74,8 @@ public class ServerApiBlockcypher {
|
|||
addressCall.enqueue(new Callback<BlockcypherResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<BlockcypherResponse> call, @NonNull Response<BlockcypherResponse> response) {
|
||||
requestsCount--;
|
||||
if (response.code() == 200) {
|
||||
requestsCount--;
|
||||
responseListener.onSuccess(method, response.body());
|
||||
Log.i(TAG, "requestData " + method + " onResponse " + response.code());
|
||||
} else {
|
||||
|
|
@ -72,6 +86,7 @@ public class ServerApiBlockcypher {
|
|||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<BlockcypherResponse> call, @NonNull Throwable t) {
|
||||
requestsCount--;
|
||||
responseListener.onFail(method, String.valueOf(t.getMessage()));
|
||||
Log.e(TAG, "requestData " + method + " onFailure " + t.getMessage());
|
||||
}
|
||||
|
|
@ -83,8 +98,8 @@ public class ServerApiBlockcypher {
|
|||
feeCall.enqueue(new Callback<BlockcypherFee>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<BlockcypherFee> call, @NonNull Response<BlockcypherFee> response) {
|
||||
requestsCount--;
|
||||
if (response.code() == 200) {
|
||||
requestsCount--;
|
||||
responseListener.onSuccess(method, response.body());
|
||||
Log.i(TAG, "requestData " + method + " onResponse " + response.code());
|
||||
} else {
|
||||
|
|
@ -95,12 +110,37 @@ public class ServerApiBlockcypher {
|
|||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<BlockcypherFee> call, @NonNull Throwable t) {
|
||||
requestsCount--;
|
||||
responseListener.onFail(method, String.valueOf(t.getMessage()));
|
||||
Log.e(TAG, "requestData " + method + " onFailure " + t.getMessage());
|
||||
}
|
||||
});
|
||||
break;
|
||||
|
||||
case BLOCKCYPHER_TXS:
|
||||
Call<BlockcypherTx> txsCall = blockcypherApi.blockcypherTxs(blockchain, network, tx);
|
||||
txsCall.enqueue(new Callback<BlockcypherTx>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<BlockcypherTx> call,@NonNull Response<BlockcypherTx> response) {
|
||||
requestsCount--;
|
||||
if (response.code() == 200) {
|
||||
txResponseListener.onSuccess(response.body());
|
||||
Log.i(TAG, "requestData " + method + " onResponse " + response.code());
|
||||
} else {
|
||||
txResponseListener.onFail(String.valueOf(response.code()));
|
||||
Log.e(TAG, "requestData " + method + " onResponse " + response.code());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<BlockcypherTx> call, @NonNull Throwable t) {
|
||||
requestsCount--;
|
||||
txResponseListener.onFail(String.valueOf(t.getMessage()));
|
||||
Log.e(TAG, "requestData " + method + " onFailure " + t.getMessage());
|
||||
}
|
||||
});
|
||||
break;
|
||||
|
||||
case BLOCKCYPHER_SEND:
|
||||
BlockcypherToken blockcypherToken = BlockcypherToken.values()[new Random().nextInt(BlockcypherToken.values().length)];
|
||||
|
||||
|
|
@ -108,8 +148,8 @@ public class ServerApiBlockcypher {
|
|||
sendCall.enqueue(new Callback<BlockcypherResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<BlockcypherResponse> call, @NonNull Response<BlockcypherResponse> response) {
|
||||
requestsCount--;
|
||||
if (response.code() == 201) {
|
||||
requestsCount--;
|
||||
responseListener.onSuccess(method, response.body());
|
||||
Log.i(TAG, "requestData " + method + " onResponse " + response.code());
|
||||
} else {
|
||||
|
|
@ -120,6 +160,7 @@ public class ServerApiBlockcypher {
|
|||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<BlockcypherResponse> call, @NonNull Throwable t) {
|
||||
requestsCount--;
|
||||
responseListener.onFail(method, String.valueOf(t.getMessage()));
|
||||
Log.e(TAG, "requestData " + method + " onFailure " + t.getMessage());
|
||||
}
|
||||
|
|
@ -127,6 +168,7 @@ public class ServerApiBlockcypher {
|
|||
break;
|
||||
|
||||
default:
|
||||
requestsCount--;
|
||||
responseListener.onFail(method, "undeclared method");
|
||||
Log.e(TAG, "requestData " + method + " onFailure - undeclared method");
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -13,8 +13,6 @@ import retrofit2.Response;
|
|||
|
||||
public class ServerApiSoChain {
|
||||
|
||||
public static String NETWORK_BTC = "BTC";
|
||||
|
||||
private static String TAG = ServerApiSoChain.class.getSimpleName();
|
||||
|
||||
private int requestsCount = 0;
|
||||
|
|
@ -86,9 +84,9 @@ public class ServerApiSoChain {
|
|||
call.enqueue(new Callback<SoChain.Response.AddressBalance>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<SoChain.Response.AddressBalance> call, @NonNull Response<SoChain.Response.AddressBalance> response) {
|
||||
requestsCount--;
|
||||
Log.i(TAG, "requestAddressBalance onResponse " + response.code());
|
||||
if (response.code() == 200) {
|
||||
requestsCount--;
|
||||
addressInfoListener.onSuccess(response.body());
|
||||
} else {
|
||||
addressInfoListener.onFail(String.valueOf(response.code()));
|
||||
|
|
@ -97,6 +95,7 @@ public class ServerApiSoChain {
|
|||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<SoChain.Response.AddressBalance> call, @NonNull Throwable t) {
|
||||
requestsCount--;
|
||||
Log.e(TAG, "requestAddressBalance onFailure " + t.getMessage());
|
||||
addressInfoListener.onFail(String.valueOf(t.getMessage()));
|
||||
}
|
||||
|
|
@ -111,9 +110,9 @@ public class ServerApiSoChain {
|
|||
call.enqueue(new Callback<SoChain.Response.TxUnspent>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<SoChain.Response.TxUnspent> call, @NonNull Response<SoChain.Response.TxUnspent> response) {
|
||||
requestsCount--;
|
||||
Log.i(TAG, "requestAddressBalance onResponse " + response.code());
|
||||
if (response.code() == 200) {
|
||||
requestsCount--;
|
||||
addressInfoListener.onSuccess(response.body());
|
||||
} else {
|
||||
addressInfoListener.onFail(String.valueOf(response.code()));
|
||||
|
|
@ -122,6 +121,7 @@ public class ServerApiSoChain {
|
|||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<SoChain.Response.TxUnspent> call, @NonNull Throwable t) {
|
||||
requestsCount--;
|
||||
Log.e(TAG, "requestAddressBalance onFailure " + t.getMessage());
|
||||
addressInfoListener.onFail(String.valueOf(t.getMessage()));
|
||||
}
|
||||
|
|
@ -138,9 +138,9 @@ public class ServerApiSoChain {
|
|||
call.enqueue(new Callback<SoChain.Response.SendTx>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<SoChain.Response.SendTx> call, @NonNull Response<SoChain.Response.SendTx> response) {
|
||||
requestsCount--;
|
||||
Log.i(TAG, "requestAddressBalance onResponse " + response.code());
|
||||
if (response.code() == 200) {
|
||||
requestsCount--;
|
||||
sendTxListener.onSuccess(response.body());
|
||||
} else {
|
||||
sendTxListener.onFail(String.valueOf(response.code()));
|
||||
|
|
@ -149,6 +149,7 @@ public class ServerApiSoChain {
|
|||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<SoChain.Response.SendTx> call, @NonNull Throwable t) {
|
||||
requestsCount--;
|
||||
Log.e(TAG, "requestAddressBalance onFailure " + t.getMessage());
|
||||
sendTxListener.onFail(String.valueOf(t.getMessage()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,11 @@ data class BlockcypherTxref(
|
|||
var script: String? = null
|
||||
)
|
||||
|
||||
data class BlockcypherTx(
|
||||
@SerializedName("hex")
|
||||
var hex: String? = null
|
||||
)
|
||||
|
||||
data class BlockcypherFee(
|
||||
@SerializedName("low_fee_per_kb")
|
||||
var low_fee_per_kb: Long? = null,
|
||||
|
|
|
|||
|
|
@ -267,8 +267,8 @@ public final class BCHUtils {
|
|||
ArrayList<UnspentOutputInfo> unspentOutputs = new ArrayList<>();
|
||||
|
||||
for (BtcData.UnspentTransaction current : rawTxList) {
|
||||
byte[] rawTxByte = BCHUtils.fromHex(current.Raw);
|
||||
if (rawTxByte == null || current.Raw.isEmpty()) {
|
||||
byte[] rawTxByte = BCHUtils.fromHex(current.script);
|
||||
if (rawTxByte == null || current.script.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -190,8 +190,8 @@ public final class BTCUtils {
|
|||
ArrayList<UnspentOutputInfo> unspentOutputs = new ArrayList<>();
|
||||
|
||||
for (BtcData.UnspentTransaction current : rawTxList) {
|
||||
byte[] rawTxByte = BTCUtils.fromHex(current.Raw);
|
||||
if (rawTxByte == null || current.Raw.isEmpty()) {
|
||||
byte[] rawTxByte = BTCUtils.fromHex(current.script);
|
||||
if (rawTxByte == null || current.script.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -591,8 +591,8 @@ public class BtcCashEngine extends CoinEngine {
|
|||
JSONObject jsUnspent = jsUnspentArray.getJSONObject(i);
|
||||
BtcData.UnspentTransaction trUnspent = new BtcData.UnspentTransaction();
|
||||
trUnspent.txID = jsUnspent.getString("tx_hash");
|
||||
trUnspent.Amount = jsUnspent.getLong("value");
|
||||
trUnspent.Height = jsUnspent.getInt("height");
|
||||
trUnspent.amount = jsUnspent.getLong("value");
|
||||
trUnspent.outputN = jsUnspent.getInt("height");
|
||||
coinData.getUnspentTransactions().add(trUnspent);
|
||||
}
|
||||
} catch (JSONException e) {
|
||||
|
|
@ -623,7 +623,7 @@ public class BtcCashEngine extends CoinEngine {
|
|||
String raw = electrumRequest.getResultString();
|
||||
for (BtcData.UnspentTransaction tx : coinData.getUnspentTransactions()) {
|
||||
if (tx.txID.equals(txHash))
|
||||
tx.Raw = raw;
|
||||
tx.script = raw;
|
||||
}
|
||||
} catch (JSONException e) {
|
||||
e.printStackTrace();
|
||||
|
|
|
|||
|
|
@ -17,14 +17,16 @@ public class BtcData extends CoinData {
|
|||
|
||||
private Long balanceConfirmed, balanceUnconfirmed;
|
||||
|
||||
private boolean useBlockcypher = false;
|
||||
|
||||
public String getUnspentInputsDescription() {
|
||||
try {
|
||||
int gatheredUnspents = 0;
|
||||
if( unspentTransactions==null ) return "";
|
||||
for (int i = 0; i < unspentTransactions.size(); i++) {
|
||||
if (unspentTransactions.get(i).Raw != null && unspentTransactions.get(i).Raw.length() > 1) gatheredUnspents++;
|
||||
if (unspentTransactions.get(i).script != null && unspentTransactions.get(i).script.length() > 1) gatheredUnspents++;
|
||||
}
|
||||
return String.valueOf(unspentTransactions.size()) + " unspents (" + String.valueOf(gatheredUnspents) + " received)";
|
||||
return unspentTransactions.size() + " unspents (" + gatheredUnspents + " received)";
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
|
|
@ -35,24 +37,24 @@ public class BtcData extends CoinData {
|
|||
|
||||
public static class UnspentTransaction {
|
||||
public String txID;
|
||||
public Long Amount;
|
||||
public Integer Height;
|
||||
public String Raw = "";
|
||||
public Long amount;
|
||||
public Integer outputN;
|
||||
public String script = "";
|
||||
|
||||
public Bundle getAsBundle() {
|
||||
Bundle B = new Bundle();
|
||||
B.putString("txID", txID);
|
||||
B.putLong("Amount", Amount);
|
||||
B.putInt("Height", Height);
|
||||
B.putString("Raw", Raw);
|
||||
B.putLong("Amount", amount);
|
||||
B.putInt("OutputN", outputN);
|
||||
B.putString("Script", script);
|
||||
return B;
|
||||
}
|
||||
|
||||
public void loadFromBundle(Bundle B) {
|
||||
txID = B.getString("txID");
|
||||
Amount = B.getLong("Amount");
|
||||
Height = B.getInt("Height");
|
||||
Raw = B.getString("Raw");
|
||||
amount = B.getLong("Amount");
|
||||
outputN = B.getInt("OutputN");
|
||||
script = B.getString("Script");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -83,6 +85,7 @@ public class BtcData extends CoinData {
|
|||
i++;
|
||||
}
|
||||
}
|
||||
if (B.containsKey("UseBlockcypher")) useBlockcypher = B.getBoolean("UseBlockcypher");
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -98,6 +101,7 @@ public class BtcData extends CoinData {
|
|||
}
|
||||
if (balanceConfirmed != null) B.putLong("BalanceConfirmed", balanceConfirmed);
|
||||
if (balanceUnconfirmed != null) B.putLong("BalanceUnconfirmed", balanceUnconfirmed);
|
||||
if (useBlockcypher) B.putBoolean("UseBlockcypher", true);
|
||||
} catch (Exception e) {
|
||||
Log.e("Can't save to bundle ", e.getMessage());
|
||||
}
|
||||
|
|
@ -131,4 +135,11 @@ public class BtcData extends CoinData {
|
|||
return balanceConfirmed != null || balanceUnconfirmed != null;
|
||||
}
|
||||
|
||||
public boolean isUseBlockcypher() {
|
||||
return useBlockcypher;
|
||||
}
|
||||
|
||||
public void setUseBlockcypher(boolean useBlockcypher) {
|
||||
this.useBlockcypher = useBlockcypher;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -22,7 +22,7 @@ public class CardanoData extends CoinData {
|
|||
// int gatheredUnspents = 0;
|
||||
if( unspentOutputs ==null ) return "";
|
||||
// for (int i = 0; i < unspentOutputs.size(); i++) {
|
||||
// if (unspentOutputs.get(i).Raw != null && unspentOutputs.get(i).Raw.length() > 1) gatheredUnspents++;
|
||||
// if (unspentOutputs.get(i).script != null && unspentOutputs.get(i).script.length() > 1) gatheredUnspents++;
|
||||
// }
|
||||
return String.valueOf(unspentOutputs.size()) + " unspents";// (" + String.valueOf(gatheredUnspents) + " received)";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -525,7 +525,7 @@ public class DucatusEngine extends BtcEngine {
|
|||
String txHash = new String(BTCUtils.reverse(CryptoUtil.doubleSha256(BTCUtils.fromHex(raw)))); //TODO: check
|
||||
for (BtcData.UnspentTransaction tx : coinData.getUnspentTransactions()) {
|
||||
if (tx.txID.equals(txHash))
|
||||
tx.Raw = raw;
|
||||
tx.script = raw;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
|
|
@ -548,8 +548,8 @@ public class DucatusEngine extends BtcEngine {
|
|||
for (InsightResponse utxo : utxoList) {
|
||||
BtcData.UnspentTransaction trUnspent = new BtcData.UnspentTransaction();
|
||||
trUnspent.txID = utxo.getTxid();
|
||||
trUnspent.Amount = utxo.getSatoshis();
|
||||
trUnspent.Height = utxo.getHeight();
|
||||
trUnspent.amount = utxo.getSatoshis();
|
||||
trUnspent.outputN = utxo.getHeight();
|
||||
coinData.getUnspentTransactions().add(trUnspent);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -336,8 +336,8 @@ public class EosEngine extends CoinEngine {
|
|||
// return address;
|
||||
|
||||
// byte[] csum = Ripemd160.from(pkCompressed).bytes();
|
||||
// csum = Raw.copy(csum, 0, 4);
|
||||
// byte[] addy = Raw.concat(pkCompressed, csum);
|
||||
// csum = script.copy(csum, 0, 4);
|
||||
// byte[] addy = script.concat(pkCompressed, csum);
|
||||
// StringBuffer bf = new StringBuffer("EOS");
|
||||
// bf.append(Base58.encode(addy));
|
||||
// return bf.toString() + " " + address;
|
||||
|
|
|
|||
|
|
@ -2,15 +2,11 @@ package com.tangem.wallet.ltc;
|
|||
|
||||
import android.net.Uri;
|
||||
import android.text.InputFilter;
|
||||
import android.util.Log;
|
||||
|
||||
import com.tangem.data.network.ElectrumRequest;
|
||||
import com.tangem.data.network.ServerApiElectrum;
|
||||
import com.tangem.wallet.BTCUtils;
|
||||
import com.tangem.wallet.BalanceValidator;
|
||||
import com.tangem.wallet.Base58;
|
||||
import com.tangem.wallet.CoinData;
|
||||
import com.tangem.wallet.CoinEngine;
|
||||
import com.tangem.wallet.TangemContext;
|
||||
import com.tangem.wallet.Transaction;
|
||||
import com.tangem.wallet.UnspentOutputInfo;
|
||||
|
|
@ -25,8 +21,6 @@ import com.tangem.util.DecimalDigitsInputFilter;
|
|||
import com.tangem.util.DerEncodingUtil;
|
||||
import com.tangem.wallet.R;
|
||||
|
||||
import org.json.JSONException;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
|
|
@ -36,7 +30,6 @@ import java.security.NoSuchAlgorithmException;
|
|||
import java.security.NoSuchProviderException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
public class LtcEngine extends BtcEngine {
|
||||
private static final String TAG = LtcEngine.class.getSimpleName();
|
||||
|
|
@ -402,7 +395,7 @@ public class LtcEngine extends BtcEngine {
|
|||
byte[] pbKey = ctx.getCard().getWalletPublicKey();
|
||||
|
||||
for (BtcData.UnspentTransaction utxo : coinData.getUnspentTransactions()) {
|
||||
unspentOutputs.add(new UnspentOutputInfo(BTCUtils.fromHex(utxo.txID), new Transaction.Script(BTCUtils.fromHex(utxo.Raw)), utxo.Amount, utxo.Height, -1, utxo.txID, null));
|
||||
unspentOutputs.add(new UnspentOutputInfo(BTCUtils.fromHex(utxo.txID), new Transaction.Script(BTCUtils.fromHex(utxo.script)), utxo.amount, utxo.outputN, -1, utxo.txID, null));
|
||||
}
|
||||
|
||||
long fullAmount = 0;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue