mirror of
https://github.com/moparisthebest/Conversations
synced 2024-12-11 01:52:17 -05:00
Merge branch 'compression' of https://github.com/rtreffer/Conversations into rtreffer-compression
This commit is contained in:
commit
bd9dba1a69
@ -42,16 +42,6 @@
|
|||||||
android:hint="Password"
|
android:hint="Password"
|
||||||
android:fontFamily="sans-serif" />
|
android:fontFamily="sans-serif" />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<CheckBox
|
|
||||||
android:id="@+id/account_usetls"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="Use Transport Layer Security (TLS)"
|
|
||||||
android:checked="true"/>
|
|
||||||
|
|
||||||
|
|
||||||
<CheckBox
|
<CheckBox
|
||||||
android:id="@+id/edit_account_register_new"
|
android:id="@+id/edit_account_register_new"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
|
@ -30,6 +30,7 @@ public class Account extends AbstractEntity{
|
|||||||
public static final int OPTION_USETLS = 0;
|
public static final int OPTION_USETLS = 0;
|
||||||
public static final int OPTION_DISABLED = 1;
|
public static final int OPTION_DISABLED = 1;
|
||||||
public static final int OPTION_REGISTER = 2;
|
public static final int OPTION_REGISTER = 2;
|
||||||
|
public static final int OPTION_USECOMPRESSION = 3;
|
||||||
|
|
||||||
public static final int STATUS_CONNECTING = 0;
|
public static final int STATUS_CONNECTING = 0;
|
||||||
public static final int STATUS_DISABLED = -2;
|
public static final int STATUS_DISABLED = -2;
|
||||||
|
@ -23,7 +23,7 @@ public class DatabaseBackend extends SQLiteOpenHelper {
|
|||||||
private static DatabaseBackend instance = null;
|
private static DatabaseBackend instance = null;
|
||||||
|
|
||||||
private static final String DATABASE_NAME = "history";
|
private static final String DATABASE_NAME = "history";
|
||||||
private static final int DATABASE_VERSION = 1;
|
private static final int DATABASE_VERSION = 2;
|
||||||
|
|
||||||
public DatabaseBackend(Context context) {
|
public DatabaseBackend(Context context) {
|
||||||
super(context, DATABASE_NAME, null, DATABASE_VERSION);
|
super(context, DATABASE_NAME, null, DATABASE_VERSION);
|
||||||
@ -66,9 +66,12 @@ public class DatabaseBackend extends SQLiteOpenHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onUpgrade(SQLiteDatabase db, int arg1, int arg2) {
|
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
|
||||||
// TODO Auto-generated method stub
|
if (oldVersion < 2 && newVersion >= 2) {
|
||||||
|
// enable compression by default.
|
||||||
|
db.execSQL("update " + Account.TABLENAME
|
||||||
|
+ " set " + Account.OPTIONS + " = " + Account.OPTIONS + " | 8");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static synchronized DatabaseBackend getInstance(Context context) {
|
public static synchronized DatabaseBackend getInstance(Context context) {
|
||||||
|
@ -43,7 +43,6 @@ public class EditAccount extends DialogFragment {
|
|||||||
final EditText jidText = (EditText) view.findViewById(R.id.account_jid);
|
final EditText jidText = (EditText) view.findViewById(R.id.account_jid);
|
||||||
final TextView confirmPwDesc = (TextView) view
|
final TextView confirmPwDesc = (TextView) view
|
||||||
.findViewById(R.id.account_confirm_password_desc);
|
.findViewById(R.id.account_confirm_password_desc);
|
||||||
CheckBox useTLS = (CheckBox) view.findViewById(R.id.account_usetls);
|
|
||||||
|
|
||||||
final EditText password = (EditText) view
|
final EditText password = (EditText) view
|
||||||
.findViewById(R.id.account_password);
|
.findViewById(R.id.account_password);
|
||||||
@ -57,11 +56,6 @@ public class EditAccount extends DialogFragment {
|
|||||||
if (account != null) {
|
if (account != null) {
|
||||||
jidText.setText(account.getJid());
|
jidText.setText(account.getJid());
|
||||||
password.setText(account.getPassword());
|
password.setText(account.getPassword());
|
||||||
if (account.isOptionSet(Account.OPTION_USETLS)) {
|
|
||||||
useTLS.setChecked(true);
|
|
||||||
} else {
|
|
||||||
useTLS.setChecked(false);
|
|
||||||
}
|
|
||||||
Log.d("xmppService","mein debugger. account != null");
|
Log.d("xmppService","mein debugger. account != null");
|
||||||
if (account.isOptionSet(Account.OPTION_REGISTER)) {
|
if (account.isOptionSet(Account.OPTION_REGISTER)) {
|
||||||
registerAccount.setChecked(true);
|
registerAccount.setChecked(true);
|
||||||
@ -121,7 +115,6 @@ public class EditAccount extends DialogFragment {
|
|||||||
EditText passwordEdit = (EditText) d
|
EditText passwordEdit = (EditText) d
|
||||||
.findViewById(R.id.account_password);
|
.findViewById(R.id.account_password);
|
||||||
String password = passwordEdit.getText().toString();
|
String password = passwordEdit.getText().toString();
|
||||||
CheckBox useTLS = (CheckBox) d.findViewById(R.id.account_usetls);
|
|
||||||
CheckBox register = (CheckBox) d.findViewById(R.id.edit_account_register_new);
|
CheckBox register = (CheckBox) d.findViewById(R.id.edit_account_register_new);
|
||||||
String username;
|
String username;
|
||||||
String server;
|
String server;
|
||||||
@ -139,8 +132,9 @@ public class EditAccount extends DialogFragment {
|
|||||||
account.setServer(server);
|
account.setServer(server);
|
||||||
} else {
|
} else {
|
||||||
account = new Account(username, server, password);
|
account = new Account(username, server, password);
|
||||||
|
account.setOption(Account.OPTION_USETLS, true);
|
||||||
|
account.setOption(Account.OPTION_USECOMPRESSION, true);
|
||||||
}
|
}
|
||||||
account.setOption(Account.OPTION_USETLS, useTLS.isChecked());
|
|
||||||
account.setOption(Account.OPTION_REGISTER, register.isChecked());
|
account.setOption(Account.OPTION_REGISTER, register.isChecked());
|
||||||
if (listener != null) {
|
if (listener != null) {
|
||||||
listener.onAccountEdited(account);
|
listener.onAccountEdited(account);
|
||||||
|
@ -66,7 +66,7 @@ public class UIHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static Bitmap getUnknownContactPicture(String name, int size) {
|
private static Bitmap getUnknownContactPicture(String name, int size) {
|
||||||
String firstLetter = name.substring(0, 1).toUpperCase(Locale.US);
|
String firstLetter = (name.length() > 0) ? name.substring(0, 1).toUpperCase(Locale.US) : " ";
|
||||||
|
|
||||||
int holoColors[] = { 0xFF1da9da, 0xFFb368d9, 0xFF83b600, 0xFFffa713,
|
int holoColors[] = { 0xFF1da9da, 0xFFb368d9, 0xFF83b600, 0xFFffa713,
|
||||||
0xFFe92727 };
|
0xFFe92727 };
|
||||||
|
52
src/eu/siacs/conversations/utils/zlib/ZLibInputStream.java
Normal file
52
src/eu/siacs/conversations/utils/zlib/ZLibInputStream.java
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
package eu.siacs.conversations.utils.zlib;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.zip.Inflater;
|
||||||
|
import java.util.zip.InflaterInputStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ZLibInputStream is a zlib and input stream compatible version of an
|
||||||
|
* InflaterInputStream. This class solves the incompatibility between
|
||||||
|
* {@link InputStream#available()} and {@link InflaterInputStream#available()}.
|
||||||
|
*/
|
||||||
|
public class ZLibInputStream extends InflaterInputStream {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construct a ZLibInputStream, reading data from the underlying stream.
|
||||||
|
*
|
||||||
|
* @param is The {@code InputStream} to read data from.
|
||||||
|
* @throws IOException If an {@code IOException} occurs.
|
||||||
|
*/
|
||||||
|
public ZLibInputStream(InputStream is) throws IOException {
|
||||||
|
super(is, new Inflater(), 512);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide a more InputStream compatible version of available.
|
||||||
|
* A return value of 1 means that it is likly to read one byte without
|
||||||
|
* blocking, 0 means that the system is known to block for more input.
|
||||||
|
*
|
||||||
|
* @return 0 if no data is available, 1 otherwise
|
||||||
|
* @throws IOException
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public int available() throws IOException {
|
||||||
|
/* This is one of the funny code blocks.
|
||||||
|
* InflaterInputStream.available violates the contract of
|
||||||
|
* InputStream.available, which breaks kXML2.
|
||||||
|
*
|
||||||
|
* I'm not sure who's to blame, oracle/sun for a broken api or the
|
||||||
|
* google guys for mixing a sun bug with a xml reader that can't handle
|
||||||
|
* it....
|
||||||
|
*
|
||||||
|
* Anyway, this simple if breaks suns distorted reality, but helps
|
||||||
|
* to use the api as intended.
|
||||||
|
*/
|
||||||
|
if (inf.needsInput()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return super.available();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
89
src/eu/siacs/conversations/utils/zlib/ZLibOutputStream.java
Normal file
89
src/eu/siacs/conversations/utils/zlib/ZLibOutputStream.java
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
package eu.siacs.conversations.utils.zlib;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.lang.reflect.InvocationTargetException;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.zip.Deflater;
|
||||||
|
import java.util.zip.DeflaterOutputStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <p>Android 2.2 includes Java7 FLUSH_SYNC option, which will be used by this
|
||||||
|
* Implementation, preferable via reflection. The @hide was remove in API level
|
||||||
|
* 19. This class might thus go away in the future.</p>
|
||||||
|
* <p>Please use {@link ZLibOutputStream#SUPPORTED} to check for flush
|
||||||
|
* compatibility.</p>
|
||||||
|
*/
|
||||||
|
public class ZLibOutputStream extends DeflaterOutputStream {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reflection based flush method.
|
||||||
|
*/
|
||||||
|
|
||||||
|
private final static Method method;
|
||||||
|
/**
|
||||||
|
* SUPPORTED is true if a flush compatible method exists.
|
||||||
|
*/
|
||||||
|
public final static boolean SUPPORTED;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static block to initialize {@link #SUPPORTED} and {@link #method}.
|
||||||
|
*/
|
||||||
|
static {
|
||||||
|
Method m = null;
|
||||||
|
try {
|
||||||
|
m = Deflater.class.getMethod("deflate", byte[].class, int.class, int.class, int.class);
|
||||||
|
} catch (SecurityException e) {
|
||||||
|
} catch (NoSuchMethodException e) {
|
||||||
|
}
|
||||||
|
method = m;
|
||||||
|
SUPPORTED = (method != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new ZLib compatible output stream wrapping the given low level
|
||||||
|
* stream. ZLib compatiblity means we will send a zlib header.
|
||||||
|
* @param os OutputStream The underlying stream.
|
||||||
|
* @throws IOException In case of a lowlevel transfer problem.
|
||||||
|
* @throws NoSuchAlgorithmException In case of a {@link Deflater} error.
|
||||||
|
*/
|
||||||
|
public ZLibOutputStream(OutputStream os) throws IOException,
|
||||||
|
NoSuchAlgorithmException {
|
||||||
|
super(os, new Deflater(Deflater.BEST_COMPRESSION));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush the given stream, preferring Java7 FLUSH_SYNC if available.
|
||||||
|
* @throws IOException In case of a lowlevel exception.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void flush() throws IOException {
|
||||||
|
if (!SUPPORTED) {
|
||||||
|
super.flush();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int count = 0;
|
||||||
|
if (!def.needsInput()) {
|
||||||
|
do {
|
||||||
|
count = def.deflate(buf, 0, buf.length);
|
||||||
|
out.write(buf, 0, count);
|
||||||
|
} while (count > 0);
|
||||||
|
out.flush();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
count = (Integer) method.invoke(def, buf, 0, buf.length, 2);
|
||||||
|
out.write(buf, 0, count);
|
||||||
|
} while (count > 0);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw new IOException("Can't flush");
|
||||||
|
} catch (IllegalAccessException e) {
|
||||||
|
throw new IOException("Can't flush");
|
||||||
|
} catch (InvocationTargetException e) {
|
||||||
|
throw new IOException("Can't flush");
|
||||||
|
}
|
||||||
|
super.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -9,6 +9,7 @@ import eu.siacs.conversations.xmpp.stanzas.AbstractStanza;
|
|||||||
|
|
||||||
public class TagWriter {
|
public class TagWriter {
|
||||||
|
|
||||||
|
private OutputStream plainOutputStream;
|
||||||
private OutputStreamWriter outputStream;
|
private OutputStreamWriter outputStream;
|
||||||
private boolean finshed = false;
|
private boolean finshed = false;
|
||||||
private LinkedBlockingQueue<AbstractStanza> writeQueue = new LinkedBlockingQueue<AbstractStanza>();
|
private LinkedBlockingQueue<AbstractStanza> writeQueue = new LinkedBlockingQueue<AbstractStanza>();
|
||||||
@ -37,9 +38,14 @@ public class TagWriter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void setOutputStream(OutputStream out) {
|
public void setOutputStream(OutputStream out) {
|
||||||
|
this.plainOutputStream = out;
|
||||||
this.outputStream = new OutputStreamWriter(out);
|
this.outputStream = new OutputStreamWriter(out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public OutputStream getOutputStream() {
|
||||||
|
return this.plainOutputStream;
|
||||||
|
}
|
||||||
|
|
||||||
public TagWriter beginDocument() throws IOException {
|
public TagWriter beginDocument() throws IOException {
|
||||||
outputStream.write("<?xml version='1.0'?>");
|
outputStream.write("<?xml version='1.0'?>");
|
||||||
outputStream.flush();
|
outputStream.flush();
|
||||||
|
@ -37,6 +37,10 @@ public class XmlReader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public InputStream getInputStream() {
|
||||||
|
return is;
|
||||||
|
}
|
||||||
|
|
||||||
public void reset() {
|
public void reset() {
|
||||||
try {
|
try {
|
||||||
parser.setInput(new InputStreamReader(this.is));
|
parser.setInput(new InputStreamReader(this.is));
|
||||||
|
@ -38,6 +38,8 @@ import android.util.Log;
|
|||||||
import eu.siacs.conversations.entities.Account;
|
import eu.siacs.conversations.entities.Account;
|
||||||
import eu.siacs.conversations.utils.CryptoHelper;
|
import eu.siacs.conversations.utils.CryptoHelper;
|
||||||
import eu.siacs.conversations.utils.DNSHelper;
|
import eu.siacs.conversations.utils.DNSHelper;
|
||||||
|
import eu.siacs.conversations.utils.zlib.ZLibOutputStream;
|
||||||
|
import eu.siacs.conversations.utils.zlib.ZLibInputStream;
|
||||||
import eu.siacs.conversations.xml.Element;
|
import eu.siacs.conversations.xml.Element;
|
||||||
import eu.siacs.conversations.xml.Tag;
|
import eu.siacs.conversations.xml.Tag;
|
||||||
import eu.siacs.conversations.xml.TagWriter;
|
import eu.siacs.conversations.xml.TagWriter;
|
||||||
@ -177,6 +179,13 @@ public class XmppConnection implements Runnable {
|
|||||||
wakeLock.release();
|
wakeLock.release();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
this.changeStatus(Account.STATUS_OFFLINE);
|
||||||
|
Log.d(LOGTAG, "compression exception " + e.getMessage());
|
||||||
|
if (wakeLock.isHeld()) {
|
||||||
|
wakeLock.release();
|
||||||
|
}
|
||||||
|
return;
|
||||||
} catch (XmlPullParserException e) {
|
} catch (XmlPullParserException e) {
|
||||||
this.changeStatus(Account.STATUS_OFFLINE);
|
this.changeStatus(Account.STATUS_OFFLINE);
|
||||||
Log.d(LOGTAG, "xml exception " + e.getMessage());
|
Log.d(LOGTAG, "xml exception " + e.getMessage());
|
||||||
@ -194,7 +203,7 @@ public class XmppConnection implements Runnable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void processStream(Tag currentTag) throws XmlPullParserException,
|
private void processStream(Tag currentTag) throws XmlPullParserException,
|
||||||
IOException {
|
IOException, NoSuchAlgorithmException {
|
||||||
Tag nextTag = tagReader.readTag();
|
Tag nextTag = tagReader.readTag();
|
||||||
while ((nextTag != null) && (!nextTag.isEnd("stream"))) {
|
while ((nextTag != null) && (!nextTag.isEnd("stream"))) {
|
||||||
if (nextTag.isStart("error")) {
|
if (nextTag.isStart("error")) {
|
||||||
@ -208,6 +217,8 @@ public class XmppConnection implements Runnable {
|
|||||||
}
|
}
|
||||||
} else if (nextTag.isStart("proceed")) {
|
} else if (nextTag.isStart("proceed")) {
|
||||||
switchOverToTls(nextTag);
|
switchOverToTls(nextTag);
|
||||||
|
} else if (nextTag.isStart("compressed")) {
|
||||||
|
switchOverToZLib(nextTag);
|
||||||
} else if (nextTag.isStart("success")) {
|
} else if (nextTag.isStart("success")) {
|
||||||
Log.d(LOGTAG, account.getJid()
|
Log.d(LOGTAG, account.getJid()
|
||||||
+ ": logged in");
|
+ ": logged in");
|
||||||
@ -375,6 +386,33 @@ public class XmppConnection implements Runnable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void sendCompressionZlib() throws IOException {
|
||||||
|
tagWriter.writeElement(new Element("compress") {
|
||||||
|
public String toString() {
|
||||||
|
return
|
||||||
|
"<compress xmlns='http://jabber.org/protocol/compress'>"
|
||||||
|
+ "<method>zlib</method>"
|
||||||
|
+ "</compress>";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void switchOverToZLib(Tag currentTag) throws XmlPullParserException,
|
||||||
|
IOException, NoSuchAlgorithmException {
|
||||||
|
|
||||||
|
Log.d(LOGTAG,account.getJid()+": Starting zlib compressed stream");
|
||||||
|
|
||||||
|
tagReader.readTag(); // read tag close
|
||||||
|
|
||||||
|
tagWriter.setOutputStream(new ZLibOutputStream(tagWriter.getOutputStream()));
|
||||||
|
tagReader.setInputStream(new ZLibInputStream(tagReader.getInputStream()));
|
||||||
|
|
||||||
|
sendStartStream();
|
||||||
|
processStream(tagReader.readTag());
|
||||||
|
|
||||||
|
Log.d(LOGTAG,account.getJid()+": zlib compressed stream established");
|
||||||
|
}
|
||||||
|
|
||||||
private void sendStartTLS() throws IOException {
|
private void sendStartTLS() throws IOException {
|
||||||
Tag startTLS = Tag.empty("starttls");
|
Tag startTLS = Tag.empty("starttls");
|
||||||
startTLS.setAttribute("xmlns", "urn:ietf:params:xml:ns:xmpp-tls");
|
startTLS.setAttribute("xmlns", "urn:ietf:params:xml:ns:xmpp-tls");
|
||||||
@ -486,6 +524,8 @@ public class XmppConnection implements Runnable {
|
|||||||
if (this.streamFeatures.hasChild("starttls")
|
if (this.streamFeatures.hasChild("starttls")
|
||||||
&& account.isOptionSet(Account.OPTION_USETLS)) {
|
&& account.isOptionSet(Account.OPTION_USETLS)) {
|
||||||
sendStartTLS();
|
sendStartTLS();
|
||||||
|
} else if (compressionAvailable()) {
|
||||||
|
sendCompressionZlib();
|
||||||
} else if (this.streamFeatures.hasChild("register")&&(account.isOptionSet(Account.OPTION_REGISTER))) {
|
} else if (this.streamFeatures.hasChild("register")&&(account.isOptionSet(Account.OPTION_REGISTER))) {
|
||||||
sendRegistryRequest();
|
sendRegistryRequest();
|
||||||
} else if (!this.streamFeatures.hasChild("register")&&(account.isOptionSet(Account.OPTION_REGISTER))) {
|
} else if (!this.streamFeatures.hasChild("register")&&(account.isOptionSet(Account.OPTION_REGISTER))) {
|
||||||
@ -514,6 +554,24 @@ public class XmppConnection implements Runnable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean compressionAvailable() {
|
||||||
|
if (!this.streamFeatures.hasChild("compression", "http://jabber.org/features/compress")) return false;
|
||||||
|
if (!ZLibOutputStream.SUPPORTED) return false;
|
||||||
|
if (!account.isOptionSet(Account.OPTION_USECOMPRESSION)) return false;
|
||||||
|
|
||||||
|
Element compression = this.streamFeatures.findChild("compression", "http://jabber.org/features/compress");
|
||||||
|
for (Element child : compression.getChildren()) {
|
||||||
|
if (!"method".equals(child.getName())) continue;
|
||||||
|
|
||||||
|
if ("zlib".equalsIgnoreCase(child.getContent())) {
|
||||||
|
Log.d(LOGTAG, account.getJid() + ": compression available");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private List<String> extractMechanisms(Element stream) {
|
private List<String> extractMechanisms(Element stream) {
|
||||||
ArrayList<String> mechanisms = new ArrayList<String>(stream.getChildren().size());
|
ArrayList<String> mechanisms = new ArrayList<String>(stream.getChildren().size());
|
||||||
for(Element child : stream.getChildren()) {
|
for(Element child : stream.getChildren()) {
|
||||||
|
Loading…
Reference in New Issue
Block a user