use gcm for file encryption over http
This commit is contained in:
parent
b7c64cd19d
commit
58d80f58be
|
@ -1,26 +1,7 @@
|
||||||
package eu.siacs.conversations.entities;
|
package eu.siacs.conversations.entities;
|
||||||
|
|
||||||
import android.util.Log;
|
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.FileInputStream;
|
|
||||||
import java.io.FileNotFoundException;
|
|
||||||
import java.io.FileOutputStream;
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.io.OutputStream;
|
|
||||||
import java.security.InvalidAlgorithmParameterException;
|
|
||||||
import java.security.InvalidKeyException;
|
|
||||||
import java.security.Key;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
|
|
||||||
import javax.crypto.Cipher;
|
|
||||||
import javax.crypto.CipherInputStream;
|
|
||||||
import javax.crypto.CipherOutputStream;
|
|
||||||
import javax.crypto.NoSuchPaddingException;
|
|
||||||
import javax.crypto.spec.IvParameterSpec;
|
|
||||||
import javax.crypto.spec.SecretKeySpec;
|
|
||||||
|
|
||||||
import eu.siacs.conversations.Config;
|
|
||||||
import eu.siacs.conversations.utils.MimeUtils;
|
import eu.siacs.conversations.utils.MimeUtils;
|
||||||
|
|
||||||
public class DownloadableFile extends File {
|
public class DownloadableFile extends File {
|
||||||
|
@ -29,8 +10,7 @@ public class DownloadableFile extends File {
|
||||||
|
|
||||||
private long expectedSize = 0;
|
private long expectedSize = 0;
|
||||||
private String sha1sum;
|
private String sha1sum;
|
||||||
private Key aeskey;
|
private byte[] aeskey;
|
||||||
private String mime;
|
|
||||||
|
|
||||||
private byte[] iv = { 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
|
private byte[] iv = { 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
|
||||||
0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0xf };
|
0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0xf };
|
||||||
|
@ -84,85 +64,24 @@ public class DownloadableFile extends File {
|
||||||
byte[] iv = new byte[16];
|
byte[] iv = new byte[16];
|
||||||
System.arraycopy(key, 0, iv, 0, 16);
|
System.arraycopy(key, 0, iv, 0, 16);
|
||||||
System.arraycopy(key, 16, secretKey, 0, 32);
|
System.arraycopy(key, 16, secretKey, 0, 32);
|
||||||
this.aeskey = new SecretKeySpec(secretKey, "AES");
|
this.aeskey = secretKey;
|
||||||
this.iv = iv;
|
this.iv = iv;
|
||||||
} else if (key.length >= 32) {
|
} else if (key.length >= 32) {
|
||||||
byte[] secretKey = new byte[32];
|
byte[] secretKey = new byte[32];
|
||||||
System.arraycopy(key, 0, secretKey, 0, 32);
|
System.arraycopy(key, 0, secretKey, 0, 32);
|
||||||
this.aeskey = new SecretKeySpec(secretKey, "AES");
|
this.aeskey = secretKey;
|
||||||
} else if (key.length >= 16) {
|
} else if (key.length >= 16) {
|
||||||
byte[] secretKey = new byte[16];
|
byte[] secretKey = new byte[16];
|
||||||
System.arraycopy(key, 0, secretKey, 0, 16);
|
System.arraycopy(key, 0, secretKey, 0, 16);
|
||||||
this.aeskey = new SecretKeySpec(secretKey, "AES");
|
this.aeskey = secretKey;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Key getKey() {
|
public byte[] getKey() {
|
||||||
return this.aeskey;
|
return this.aeskey;
|
||||||
}
|
}
|
||||||
|
|
||||||
public InputStream createInputStream() {
|
public byte[] getIv() {
|
||||||
if (this.getKey() == null) {
|
return this.iv;
|
||||||
try {
|
|
||||||
return new FileInputStream(this);
|
|
||||||
} catch (FileNotFoundException e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
IvParameterSpec ips = new IvParameterSpec(iv);
|
|
||||||
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
|
|
||||||
cipher.init(Cipher.ENCRYPT_MODE, this.getKey(), ips);
|
|
||||||
Log.d(Config.LOGTAG, "opening encrypted input stream");
|
|
||||||
return new CipherInputStream(new FileInputStream(this), cipher);
|
|
||||||
} catch (NoSuchAlgorithmException e) {
|
|
||||||
Log.d(Config.LOGTAG, "no such algo: " + e.getMessage());
|
|
||||||
return null;
|
|
||||||
} catch (NoSuchPaddingException e) {
|
|
||||||
Log.d(Config.LOGTAG, "no such padding: " + e.getMessage());
|
|
||||||
return null;
|
|
||||||
} catch (InvalidKeyException e) {
|
|
||||||
Log.d(Config.LOGTAG, "invalid key: " + e.getMessage());
|
|
||||||
return null;
|
|
||||||
} catch (InvalidAlgorithmParameterException e) {
|
|
||||||
Log.d(Config.LOGTAG, "invavid iv:" + e.getMessage());
|
|
||||||
return null;
|
|
||||||
} catch (FileNotFoundException e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public OutputStream createOutputStream() {
|
|
||||||
if (this.getKey() == null) {
|
|
||||||
try {
|
|
||||||
return new FileOutputStream(this);
|
|
||||||
} catch (FileNotFoundException e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
IvParameterSpec ips = new IvParameterSpec(this.iv);
|
|
||||||
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
|
|
||||||
cipher.init(Cipher.DECRYPT_MODE, this.getKey(), ips);
|
|
||||||
Log.d(Config.LOGTAG, "opening encrypted output stream");
|
|
||||||
return new CipherOutputStream(new FileOutputStream(this),
|
|
||||||
cipher);
|
|
||||||
} catch (NoSuchAlgorithmException e) {
|
|
||||||
Log.d(Config.LOGTAG, "no such algo: " + e.getMessage());
|
|
||||||
return null;
|
|
||||||
} catch (NoSuchPaddingException e) {
|
|
||||||
Log.d(Config.LOGTAG, "no such padding: " + e.getMessage());
|
|
||||||
return null;
|
|
||||||
} catch (InvalidKeyException e) {
|
|
||||||
Log.d(Config.LOGTAG, "invalid key: " + e.getMessage());
|
|
||||||
return null;
|
|
||||||
} catch (InvalidAlgorithmParameterException e) {
|
|
||||||
Log.d(Config.LOGTAG, "invavid iv:" + e.getMessage());
|
|
||||||
return null;
|
|
||||||
} catch (FileNotFoundException e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,10 +2,17 @@ package eu.siacs.conversations.http;
|
||||||
|
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.SystemClock;
|
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
|
import org.bouncycastle.crypto.engines.AESEngine;
|
||||||
|
import org.bouncycastle.crypto.io.CipherOutputStream;
|
||||||
|
import org.bouncycastle.crypto.modes.AEADBlockCipher;
|
||||||
|
import org.bouncycastle.crypto.modes.GCMBlockCipher;
|
||||||
|
import org.bouncycastle.crypto.params.AEADParameters;
|
||||||
|
import org.bouncycastle.crypto.params.KeyParameter;
|
||||||
|
|
||||||
import java.io.BufferedInputStream;
|
import java.io.BufferedInputStream;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
import java.net.HttpURLConnection;
|
import java.net.HttpURLConnection;
|
||||||
|
@ -206,7 +213,7 @@ public class HttpDownloadConnection implements Transferable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void download() throws SSLHandshakeException, IOException {
|
private void download() throws IOException {
|
||||||
HttpURLConnection connection = (HttpURLConnection) mUrl.openConnection();
|
HttpURLConnection connection = (HttpURLConnection) mUrl.openConnection();
|
||||||
if (connection instanceof HttpsURLConnection) {
|
if (connection instanceof HttpsURLConnection) {
|
||||||
mHttpConnectionManager.setupTrustManager((HttpsURLConnection) connection, interactive);
|
mHttpConnectionManager.setupTrustManager((HttpsURLConnection) connection, interactive);
|
||||||
|
@ -215,9 +222,13 @@ public class HttpDownloadConnection implements Transferable {
|
||||||
BufferedInputStream is = new BufferedInputStream(connection.getInputStream());
|
BufferedInputStream is = new BufferedInputStream(connection.getInputStream());
|
||||||
file.getParentFile().mkdirs();
|
file.getParentFile().mkdirs();
|
||||||
file.createNewFile();
|
file.createNewFile();
|
||||||
OutputStream os = file.createOutputStream();
|
OutputStream os;
|
||||||
if (os == null) {
|
if (file.getKey() != null) {
|
||||||
throw new IOException();
|
AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine());
|
||||||
|
cipher.init(false, new AEADParameters(new KeyParameter(file.getKey()), 128, file.getIv()));
|
||||||
|
os = new CipherOutputStream(new FileOutputStream(file), cipher);
|
||||||
|
} else {
|
||||||
|
os = new FileOutputStream(file);
|
||||||
}
|
}
|
||||||
long transmitted = 0;
|
long transmitted = 0;
|
||||||
long expected = file.getExpectedSize();
|
long expected = file.getExpectedSize();
|
||||||
|
|
|
@ -1,8 +1,18 @@
|
||||||
package eu.siacs.conversations.http;
|
package eu.siacs.conversations.http;
|
||||||
|
|
||||||
import android.app.PendingIntent;
|
import android.app.PendingIntent;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.net.Uri;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
|
import org.bouncycastle.crypto.engines.AESEngine;
|
||||||
|
import org.bouncycastle.crypto.io.CipherInputStream;
|
||||||
|
import org.bouncycastle.crypto.modes.AEADBlockCipher;
|
||||||
|
import org.bouncycastle.crypto.modes.GCMBlockCipher;
|
||||||
|
import org.bouncycastle.crypto.params.AEADParameters;
|
||||||
|
import org.bouncycastle.crypto.params.KeyParameter;
|
||||||
|
|
||||||
|
import java.io.FileInputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
|
@ -43,7 +53,7 @@ public class HttpUploadConnection implements Transferable {
|
||||||
private byte[] key = null;
|
private byte[] key = null;
|
||||||
|
|
||||||
private long transmitted = 0;
|
private long transmitted = 0;
|
||||||
private long expected = 1;
|
private int expected = 1;
|
||||||
|
|
||||||
public HttpUploadConnection(HttpConnectionManager httpConnectionManager) {
|
public HttpUploadConnection(HttpConnectionManager httpConnectionManager) {
|
||||||
this.mHttpConnectionManager = httpConnectionManager;
|
this.mHttpConnectionManager = httpConnectionManager;
|
||||||
|
@ -142,14 +152,21 @@ public class HttpUploadConnection implements Transferable {
|
||||||
if (connection instanceof HttpsURLConnection) {
|
if (connection instanceof HttpsURLConnection) {
|
||||||
mHttpConnectionManager.setupTrustManager((HttpsURLConnection) connection, true);
|
mHttpConnectionManager.setupTrustManager((HttpsURLConnection) connection, true);
|
||||||
}
|
}
|
||||||
|
if (file.getKey() != null) {
|
||||||
|
AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine());
|
||||||
|
cipher.init(true, new AEADParameters(new KeyParameter(file.getKey()), 128, file.getIv()));
|
||||||
|
expected = cipher.getOutputSize((int) file.getSize());
|
||||||
|
is = new CipherInputStream(new FileInputStream(file), cipher);
|
||||||
|
} else {
|
||||||
|
expected = (int) file.getSize();
|
||||||
|
is = new FileInputStream(file);
|
||||||
|
}
|
||||||
connection.setRequestMethod("PUT");
|
connection.setRequestMethod("PUT");
|
||||||
connection.setFixedLengthStreamingMode((int) file.getExpectedSize());
|
connection.setFixedLengthStreamingMode(expected);
|
||||||
connection.setDoOutput(true);
|
connection.setDoOutput(true);
|
||||||
connection.connect();
|
connection.connect();
|
||||||
os = connection.getOutputStream();
|
os = connection.getOutputStream();
|
||||||
is = file.createInputStream();
|
|
||||||
transmitted = 0;
|
transmitted = 0;
|
||||||
expected = file.getExpectedSize();
|
|
||||||
int count = -1;
|
int count = -1;
|
||||||
byte[] buffer = new byte[4096];
|
byte[] buffer = new byte[4096];
|
||||||
while (((count = is.read(buffer)) != -1) && !canceled) {
|
while (((count = is.read(buffer)) != -1) && !canceled) {
|
||||||
|
@ -163,11 +180,13 @@ public class HttpUploadConnection implements Transferable {
|
||||||
int code = connection.getResponseCode();
|
int code = connection.getResponseCode();
|
||||||
if (code == 200 || code == 201) {
|
if (code == 200 || code == 201) {
|
||||||
Log.d(Config.LOGTAG, "finished uploading file");
|
Log.d(Config.LOGTAG, "finished uploading file");
|
||||||
Message.FileParams params = message.getFileParams();
|
|
||||||
if (key != null) {
|
if (key != null) {
|
||||||
mGetUrl = new URL(mGetUrl.toString() + "#" + CryptoHelper.bytesToHex(key));
|
mGetUrl = new URL(mGetUrl.toString() + "#" + CryptoHelper.bytesToHex(key));
|
||||||
}
|
}
|
||||||
mXmppConnectionService.getFileBackend().updateFileParams(message, mGetUrl);
|
mXmppConnectionService.getFileBackend().updateFileParams(message, mGetUrl);
|
||||||
|
Intent intent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
|
||||||
|
intent.setData(Uri.fromFile(file));
|
||||||
|
mXmppConnectionService.sendBroadcast(intent);
|
||||||
message.setTransferable(null);
|
message.setTransferable(null);
|
||||||
message.setCounterpart(message.getConversation().getJid().toBareJid());
|
message.setCounterpart(message.getConversation().getJid().toBareJid());
|
||||||
if (message.getEncryption() == Message.ENCRYPTION_DECRYPTED) {
|
if (message.getEncryption() == Message.ENCRYPTION_DECRYPTED) {
|
||||||
|
@ -194,6 +213,7 @@ public class HttpUploadConnection implements Transferable {
|
||||||
fail();
|
fail();
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
|
e.printStackTrace();
|
||||||
Log.d(Config.LOGTAG,"http upload failed "+e.getMessage());
|
Log.d(Config.LOGTAG,"http upload failed "+e.getMessage());
|
||||||
fail();
|
fail();
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
@ -93,7 +93,7 @@ public class JingleInbandTransport extends JingleTransport {
|
||||||
digest.reset();
|
digest.reset();
|
||||||
file.getParentFile().mkdirs();
|
file.getParentFile().mkdirs();
|
||||||
file.createNewFile();
|
file.createNewFile();
|
||||||
this.fileOutputStream = file.createOutputStream();
|
this.fileOutputStream = createOutputStream(file);
|
||||||
if (this.fileOutputStream == null) {
|
if (this.fileOutputStream == null) {
|
||||||
Log.d(Config.LOGTAG,account.getJid().toBareJid()+": could not create output stream");
|
Log.d(Config.LOGTAG,account.getJid().toBareJid()+": could not create output stream");
|
||||||
callback.onFileTransferAborted();
|
callback.onFileTransferAborted();
|
||||||
|
@ -120,7 +120,7 @@ public class JingleInbandTransport extends JingleTransport {
|
||||||
this.fileSize = this.remainingSize;
|
this.fileSize = this.remainingSize;
|
||||||
this.digest = MessageDigest.getInstance("SHA-1");
|
this.digest = MessageDigest.getInstance("SHA-1");
|
||||||
this.digest.reset();
|
this.digest.reset();
|
||||||
fileInputStream = this.file.createInputStream();
|
fileInputStream = createInputStream(this.file);
|
||||||
if (fileInputStream == null) {
|
if (fileInputStream == null) {
|
||||||
Log.d(Config.LOGTAG,account.getJid().toBareJid()+": could no create input stream");
|
Log.d(Config.LOGTAG,account.getJid().toBareJid()+": could no create input stream");
|
||||||
callback.onFileTransferAborted();
|
callback.onFileTransferAborted();
|
||||||
|
|
|
@ -106,7 +106,7 @@ public class JingleSocks5Transport extends JingleTransport {
|
||||||
try {
|
try {
|
||||||
MessageDigest digest = MessageDigest.getInstance("SHA-1");
|
MessageDigest digest = MessageDigest.getInstance("SHA-1");
|
||||||
digest.reset();
|
digest.reset();
|
||||||
fileInputStream = file.createInputStream();
|
fileInputStream = createInputStream(file); //file.createInputStream();
|
||||||
if (fileInputStream == null) {
|
if (fileInputStream == null) {
|
||||||
Log.d(Config.LOGTAG, connection.getAccount().getJid().toBareJid() + ": could not create input stream");
|
Log.d(Config.LOGTAG, connection.getAccount().getJid().toBareJid() + ": could not create input stream");
|
||||||
callback.onFileTransferAborted();
|
callback.onFileTransferAborted();
|
||||||
|
@ -157,7 +157,7 @@ public class JingleSocks5Transport extends JingleTransport {
|
||||||
socket.setSoTimeout(30000);
|
socket.setSoTimeout(30000);
|
||||||
file.getParentFile().mkdirs();
|
file.getParentFile().mkdirs();
|
||||||
file.createNewFile();
|
file.createNewFile();
|
||||||
fileOutputStream = file.createOutputStream();
|
fileOutputStream = createOutputStream(file);
|
||||||
if (fileOutputStream == null) {
|
if (fileOutputStream == null) {
|
||||||
callback.onFileTransferAborted();
|
callback.onFileTransferAborted();
|
||||||
Log.d(Config.LOGTAG, connection.getAccount().getJid().toBareJid() + ": could not create output stream");
|
Log.d(Config.LOGTAG, connection.getAccount().getJid().toBareJid() + ": could not create output stream");
|
||||||
|
|
|
@ -1,5 +1,24 @@
|
||||||
package eu.siacs.conversations.xmpp.jingle;
|
package eu.siacs.conversations.xmpp.jingle;
|
||||||
|
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.FileNotFoundException;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.security.InvalidAlgorithmParameterException;
|
||||||
|
import java.security.InvalidKeyException;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
|
||||||
|
import javax.crypto.Cipher;
|
||||||
|
import javax.crypto.CipherInputStream;
|
||||||
|
import javax.crypto.CipherOutputStream;
|
||||||
|
import javax.crypto.NoSuchPaddingException;
|
||||||
|
import javax.crypto.spec.IvParameterSpec;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
|
||||||
|
import eu.siacs.conversations.Config;
|
||||||
import eu.siacs.conversations.entities.DownloadableFile;
|
import eu.siacs.conversations.entities.DownloadableFile;
|
||||||
|
|
||||||
public abstract class JingleTransport {
|
public abstract class JingleTransport {
|
||||||
|
@ -12,4 +31,58 @@ public abstract class JingleTransport {
|
||||||
final OnFileTransmissionStatusChanged callback);
|
final OnFileTransmissionStatusChanged callback);
|
||||||
|
|
||||||
public abstract void disconnect();
|
public abstract void disconnect();
|
||||||
|
|
||||||
|
protected InputStream createInputStream(DownloadableFile file) {
|
||||||
|
FileInputStream is;
|
||||||
|
try {
|
||||||
|
is = new FileInputStream(file);
|
||||||
|
if (file.getKey() == null) {
|
||||||
|
return is;
|
||||||
|
}
|
||||||
|
} catch (FileNotFoundException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
IvParameterSpec ips = new IvParameterSpec(file.getIv());
|
||||||
|
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(file.getKey(), "AES"), ips);
|
||||||
|
Log.d(Config.LOGTAG, "opening encrypted input stream");
|
||||||
|
return new CipherInputStream(is, cipher);
|
||||||
|
} catch (InvalidKeyException e) {
|
||||||
|
return null;
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
return null;
|
||||||
|
} catch (NoSuchPaddingException e) {
|
||||||
|
return null;
|
||||||
|
} catch (InvalidAlgorithmParameterException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected OutputStream createOutputStream(DownloadableFile file) {
|
||||||
|
FileOutputStream os;
|
||||||
|
try {
|
||||||
|
os = new FileOutputStream(file);
|
||||||
|
if (file.getKey() == null) {
|
||||||
|
return os;
|
||||||
|
}
|
||||||
|
} catch (FileNotFoundException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
IvParameterSpec ips = new IvParameterSpec(file.getIv());
|
||||||
|
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(file.getKey(), "AES"), ips);
|
||||||
|
Log.d(Config.LOGTAG, "opening encrypted output stream");
|
||||||
|
return new CipherOutputStream(os, cipher);
|
||||||
|
} catch (InvalidKeyException e) {
|
||||||
|
return null;
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
return null;
|
||||||
|
} catch (NoSuchPaddingException e) {
|
||||||
|
return null;
|
||||||
|
} catch (InvalidAlgorithmParameterException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue