offer alternative access to elements and children

instead of Element.findChild(name, namespace) we can now use
Element.getExtension(Extension.class) for registered extensions
This commit is contained in:
Daniel Gultsch 2023-01-14 11:52:07 +01:00
parent 49bf92f7ca
commit 9e7bbcc272
No known key found for this signature in database
GPG key ID: F43D18AD2A0982C2
13 changed files with 381 additions and 111 deletions

View file

@ -55,6 +55,8 @@ dependencies {
implementation "androidx.security:security-crypto:1.0.0" implementation "androidx.security:security-crypto:1.0.0"
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.robolectric:robolectric:4.9'
// legacy dependencies. Ideally everything below should be carefully reviewed and eventually moved up // legacy dependencies. Ideally everything below should be carefully reviewed and eventually moved up
@ -137,6 +139,12 @@ android {
} }
testOptions {
unitTests {
includeAndroidResources = false
}
}
splits { splits {
abi { abi {
universalApk true universalApk true

View file

@ -2,7 +2,7 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 1, "version": 1,
"identityHash": "4a70ff0733436f5a2a08e7abb8e6cc95", "identityHash": "d16845c3eb73e5fdbc9902903b74428a",
"entities": [ "entities": [
{ {
"tableName": "account", "tableName": "account",
@ -590,7 +590,7 @@
}, },
{ {
"tableName": "message", "tableName": "message",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `chatId` INTEGER NOT NULL, `receivedAt` INTEGER, `sentAt` INTEGER, `bareTo` TEXT, `toResource` TEXT, `bareFrom` TEXT, `fromResource` TEXT, `occupantId` TEXT, `messageId` TEXT, `stanzaId` TEXT, FOREIGN KEY(`chatId`) REFERENCES `chat`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `chatId` INTEGER NOT NULL, `receivedAt` INTEGER, `sentAt` INTEGER, `bareTo` TEXT, `toResource` TEXT, `bareFrom` TEXT, `fromResource` TEXT, `occupantId` TEXT, `messageId` TEXT, `stanzaId` TEXT, `acknowledged` INTEGER NOT NULL, FOREIGN KEY(`chatId`) REFERENCES `chat`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [ "fields": [
{ {
"fieldPath": "id", "fieldPath": "id",
@ -657,6 +657,12 @@
"columnName": "stanzaId", "columnName": "stanzaId",
"affinity": "TEXT", "affinity": "TEXT",
"notNull": false "notNull": false
},
{
"fieldPath": "acknowledged",
"columnName": "acknowledged",
"affinity": "INTEGER",
"notNull": true
} }
], ],
"primaryKey": { "primaryKey": {
@ -1189,7 +1195,7 @@
"views": [], "views": [],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4a70ff0733436f5a2a08e7abb8e6cc95')" "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd16845c3eb73e5fdbc9902903b74428a')"
] ]
} }
} }

View file

@ -1,18 +1,19 @@
package eu.siacs.conversations.xml; package eu.siacs.conversations.xml;
import com.google.common.base.Optional; import com.google.common.base.Optional;
import com.google.common.collect.Collections2;
import com.google.common.collect.Iterables;
import com.google.common.primitives.Ints; import com.google.common.primitives.Ints;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.Hashtable;
import java.util.List;
import eu.siacs.conversations.utils.XmlHelper; import eu.siacs.conversations.utils.XmlHelper;
import eu.siacs.conversations.xmpp.InvalidJid; import eu.siacs.conversations.xmpp.InvalidJid;
import eu.siacs.conversations.xmpp.Jid; import eu.siacs.conversations.xmpp.Jid;
import eu.siacs.conversations.xmpp.stanzas.MessagePacket; import eu.siacs.conversations.xmpp.stanzas.MessagePacket;
import im.conversations.android.xmpp.model.Extension;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Hashtable;
import java.util.List;
import org.jetbrains.annotations.NotNull;
public class Element { public class Element {
private final String name; private final String name;
@ -65,6 +66,19 @@ public class Element {
return null; return null;
} }
public <E extends Extension> E getExtension(final Class<E> clazz) {
final var extension = Iterables.find(this.children, clazz::isInstance);
if (extension == null) {
return null;
}
return clazz.cast(extension);
}
public <E extends Extension> Collection<E> getExtensions(final Class<E> clazz) {
return Collections2.transform(
Collections2.filter(this.children, clazz::isInstance), clazz::cast);
}
public String findChildContent(String name) { public String findChildContent(String name) {
Element element = findChild(name); Element element = findChild(name);
return element == null ? null : element.getContent(); return element == null ? null : element.getContent();

View file

@ -2,115 +2,122 @@ package eu.siacs.conversations.xml;
import android.util.Log; import android.util.Log;
import android.util.Xml; import android.util.Xml;
import eu.siacs.conversations.Config;
import org.xmlpull.v1.XmlPullParser; import im.conversations.android.xmpp.Extensions;
import org.xmlpull.v1.XmlPullParserException;
import java.io.Closeable; import java.io.Closeable;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import org.xmlpull.v1.XmlPullParser;
import eu.siacs.conversations.Config; import org.xmlpull.v1.XmlPullParserException;
public class XmlReader implements Closeable { public class XmlReader implements Closeable {
private final XmlPullParser parser; private final XmlPullParser parser;
private InputStream is; private InputStream is;
public XmlReader() { public XmlReader() {
this.parser = Xml.newPullParser(); this.parser = Xml.newPullParser();
try { try {
this.parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true); this.parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true);
} catch (XmlPullParserException e) { } catch (XmlPullParserException e) {
Log.d(Config.LOGTAG, "error setting namespace feature on parser"); Log.d(Config.LOGTAG, "error setting namespace feature on parser");
} }
} }
public void setInputStream(InputStream inputStream) throws IOException { public void setInputStream(InputStream inputStream) throws IOException {
if (inputStream == null) { if (inputStream == null) {
throw new IOException(); throw new IOException();
} }
this.is = inputStream; this.is = inputStream;
try { try {
parser.setInput(new InputStreamReader(this.is)); parser.setInput(new InputStreamReader(this.is));
} catch (XmlPullParserException e) { } catch (XmlPullParserException e) {
throw new IOException("error resetting parser"); throw new IOException("error resetting parser");
} }
} }
public void reset() throws IOException { public void reset() throws IOException {
if (this.is == null) { if (this.is == null) {
throw new IOException(); throw new IOException();
} }
try { try {
parser.setInput(new InputStreamReader(this.is)); parser.setInput(new InputStreamReader(this.is));
} catch (XmlPullParserException e) { } catch (XmlPullParserException e) {
throw new IOException("error resetting parser"); throw new IOException("error resetting parser");
} }
} }
@Override @Override
public void close() { public void close() {
this.is = null; this.is = null;
} }
public Tag readTag() throws IOException { public Tag readTag() throws IOException {
try { try {
while (this.is != null && parser.next() != XmlPullParser.END_DOCUMENT) { while (this.is != null && parser.next() != XmlPullParser.END_DOCUMENT) {
if (parser.getEventType() == XmlPullParser.START_TAG) { if (parser.getEventType() == XmlPullParser.START_TAG) {
Tag tag = Tag.start(parser.getName()); Tag tag = Tag.start(parser.getName());
final String xmlns = parser.getNamespace(); final String xmlns = parser.getNamespace();
for (int i = 0; i < parser.getAttributeCount(); ++i) { for (int i = 0; i < parser.getAttributeCount(); ++i) {
final String prefix = parser.getAttributePrefix(i); final String prefix = parser.getAttributePrefix(i);
String name; String name;
if (prefix != null && !prefix.isEmpty()) { if (prefix != null && !prefix.isEmpty()) {
name = prefix+":"+parser.getAttributeName(i); name = prefix + ":" + parser.getAttributeName(i);
} else { } else {
name = parser.getAttributeName(i); name = parser.getAttributeName(i);
} }
tag.setAttribute(name,parser.getAttributeValue(i)); tag.setAttribute(name, parser.getAttributeValue(i));
} }
if (xmlns != null) { if (xmlns != null) {
tag.setAttribute("xmlns", xmlns); tag.setAttribute("xmlns", xmlns);
} }
return tag; return tag;
} else if (parser.getEventType() == XmlPullParser.END_TAG) { } else if (parser.getEventType() == XmlPullParser.END_TAG) {
return Tag.end(parser.getName()); return Tag.end(parser.getName());
} else if (parser.getEventType() == XmlPullParser.TEXT) { } else if (parser.getEventType() == XmlPullParser.TEXT) {
return Tag.no(parser.getText()); return Tag.no(parser.getText());
} }
} }
} catch (Throwable throwable) { } catch (Throwable throwable) {
throw new IOException("xml parser mishandled "+throwable.getClass().getSimpleName()+"("+throwable.getMessage()+")", throwable); throw new IOException(
} "xml parser mishandled "
return null; + throwable.getClass().getSimpleName()
} + "("
+ throwable.getMessage()
+ ")",
throwable);
}
return null;
}
public Element readElement(Tag currentTag) throws IOException { public Element readElement(Tag currentTag) throws IOException {
Element element = new Element(currentTag.getName()); final var attributes = currentTag.getAttributes();
element.setAttributes(currentTag.getAttributes()); final var namespace = attributes.get("xmlns");
Tag nextTag = this.readTag(); final var name = currentTag.getName();
if (nextTag == null) { final Element element = Extensions.create(name, namespace);
throw new IOException("interrupted mid tag"); element.setAttributes(currentTag.getAttributes());
} Tag nextTag = this.readTag();
if (nextTag.isNo()) { if (nextTag == null) {
element.setContent(nextTag.getName()); throw new IOException("interrupted mid tag");
nextTag = this.readTag(); }
if (nextTag == null) { if (nextTag.isNo()) {
throw new IOException("interrupted mid tag"); element.setContent(nextTag.getName());
} nextTag = this.readTag();
} if (nextTag == null) {
while (!nextTag.isEnd(element.getName())) { throw new IOException("interrupted mid tag");
if (!nextTag.isNo()) { }
Element child = this.readElement(nextTag); }
element.addChild(child); while (!nextTag.isEnd(element.getName())) {
} if (!nextTag.isNo()) {
nextTag = this.readTag(); Element child = this.readElement(nextTag);
if (nextTag == null) { element.addChild(child);
throw new IOException("interrupted mid tag"); }
} nextTag = this.readTag();
} if (nextTag == null) {
return element; throw new IOException("interrupted mid tag");
} }
}
return element;
}
} }

View file

@ -0,0 +1,15 @@
package im.conversations.android.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE})
public @interface XmlElement {
String name() default "";
String namespace() default "";
}

View file

@ -0,0 +1,12 @@
package im.conversations.android.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PACKAGE)
public @interface XmlPackage {
String namespace();
}

View file

@ -0,0 +1,114 @@
package im.conversations.android.xmpp;
import com.google.common.base.Objects;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableMap;
import eu.siacs.conversations.xml.Element;
import im.conversations.android.annotation.XmlElement;
import im.conversations.android.annotation.XmlPackage;
import im.conversations.android.xmpp.model.Extension;
import im.conversations.android.xmpp.model.roster.Item;
import im.conversations.android.xmpp.model.roster.Query;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Map;
public final class Extensions {
private static final List<Class<? extends Extension>> ELEMENTS =
Arrays.asList(Query.class, Item.class);
private static final Map<Id, Class<? extends Extension>> EXTENSION_CLASS_MAP;
static {
final var builder = new ImmutableMap.Builder<Id, Class<? extends Extension>>();
for (final Class<? extends Extension> clazz : ELEMENTS) {
builder.put(id(clazz), clazz);
}
EXTENSION_CLASS_MAP = builder.build();
}
private static Id id(final Class<? extends Extension> clazz) {
final XmlElement xmlElement = clazz.getAnnotation(XmlElement.class);
final Package clazzPackage = clazz.getPackage();
final XmlPackage xmlPackage =
clazzPackage == null ? null : clazzPackage.getAnnotation(XmlPackage.class);
if (xmlElement == null) {
throw new IllegalStateException(
String.format("%s is not annotated as @XmlElement", clazz.getName()));
}
final String packageNamespace = xmlPackage == null ? null : xmlPackage.namespace();
final String elementName = xmlElement.name();
final String elementNamespace = xmlElement.namespace();
final String namespace;
if (!Strings.isNullOrEmpty(elementNamespace)) {
namespace = elementNamespace;
} else if (!Strings.isNullOrEmpty(packageNamespace)) {
namespace = packageNamespace;
} else {
throw new IllegalStateException(
String.format("%s does not declare a namespace", clazz.getName()));
}
final String name;
if (Strings.isNullOrEmpty(elementName)) {
name = clazz.getSimpleName().toLowerCase(Locale.ROOT);
} else {
name = elementName;
}
return new Id(name, namespace);
}
public static Element create(final String name, final String namespace) {
final Class<? extends Element> clazz = of(name, namespace);
if (clazz == null) {
return new Element(name, namespace);
}
final Constructor<? extends Element> constructor;
try {
constructor = clazz.getDeclaredConstructor();
} catch (final NoSuchMethodException e) {
throw new IllegalStateException(
String.format("%s has no default constructor", clazz.getName()));
}
try {
return constructor.newInstance();
} catch (final IllegalAccessException
| InstantiationException
| InvocationTargetException e) {
throw new IllegalStateException(
String.format("%s has inaccessible default constructor", clazz.getName()));
}
}
private static Class<? extends Element> of(final String name, final String namespace) {
return EXTENSION_CLASS_MAP.get(new Id(name, namespace));
}
private Extensions() {}
public static class Id {
public final String name;
public final String namespace;
public Id(String name, String namespace) {
this.name = name;
this.namespace = namespace;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Id id = (Id) o;
return Objects.equal(name, id.name) && Objects.equal(namespace, id.namespace);
}
@Override
public int hashCode() {
return Objects.hashCode(name, namespace);
}
}
}

View file

@ -0,0 +1,9 @@
package im.conversations.android.xmpp.model;
import eu.siacs.conversations.xml.Element;
public class Extension extends Element {
public Extension(String name, String xmlns) {
super(name, xmlns);
}
}

View file

@ -0,0 +1,13 @@
package im.conversations.android.xmpp.model.roster;
import eu.siacs.conversations.xml.Namespace;
import im.conversations.android.annotation.XmlElement;
import im.conversations.android.xmpp.model.Extension;
@XmlElement
public class Item extends Extension {
public Item() {
super("item", Namespace.ROSTER);
}
}

View file

@ -0,0 +1,17 @@
package im.conversations.android.xmpp.model.roster;
import eu.siacs.conversations.xml.Namespace;
import im.conversations.android.annotation.XmlElement;
import im.conversations.android.xmpp.model.Extension;
@XmlElement(name = "query", namespace = Namespace.ROSTER)
public class Query extends Extension {
public Query() {
super("query", Namespace.ROSTER);
}
public void setVersion(final String rosterVersion) {
this.setAttribute("ver", rosterVersion);
}
}

View file

@ -0,0 +1,5 @@
@XmlPackage(namespace = Namespace.ROSTER)
package im.conversations.android.xmpp.model.roster;
import eu.siacs.conversations.xml.Namespace;
import im.conversations.android.annotation.XmlPackage;

View file

@ -4,10 +4,11 @@ import android.content.Context;
import android.util.Log; import android.util.Log;
import com.google.common.base.Strings; import com.google.common.base.Strings;
import eu.siacs.conversations.Config; import eu.siacs.conversations.Config;
import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xmpp.Jid; import eu.siacs.conversations.xmpp.Jid;
import eu.siacs.conversations.xmpp.stanzas.IqPacket; import eu.siacs.conversations.xmpp.stanzas.IqPacket;
import im.conversations.android.xmpp.XmppConnection; import im.conversations.android.xmpp.XmppConnection;
import im.conversations.android.xmpp.model.roster.Item;
import im.conversations.android.xmpp.model.roster.Query;
import java.util.function.Consumer; import java.util.function.Consumer;
public class BindProcessor extends AbstractBaseProcessor implements Consumer<Jid> { public class BindProcessor extends AbstractBaseProcessor implements Consumer<Jid> {
@ -43,12 +44,27 @@ public class BindProcessor extends AbstractBaseProcessor implements Consumer<Jid
final var database = getDatabase(); final var database = getDatabase();
final String rosterVersion = database.accountDao().getRosterVersion(account.id); final String rosterVersion = database.accountDao().getRosterVersion(account.id);
final IqPacket iqPacket = new IqPacket(IqPacket.TYPE.GET); final IqPacket iqPacket = new IqPacket(IqPacket.TYPE.GET);
final Query rosterQuery = new Query();
iqPacket.addChild(rosterQuery);
if (Strings.isNullOrEmpty(rosterVersion)) { if (Strings.isNullOrEmpty(rosterVersion)) {
Log.d(Config.LOGTAG, account.address + ": fetching roster"); Log.d(Config.LOGTAG, account.address + ": fetching roster");
} else { } else {
Log.d(Config.LOGTAG, account.address + ": fetching roster version " + rosterVersion); Log.d(Config.LOGTAG, account.address + ": fetching roster version " + rosterVersion);
rosterQuery.setVersion(rosterVersion);
} }
iqPacket.query(Namespace.ROSTER).setAttribute("ver", rosterVersion); connection.sendIqPacket(
connection.sendIqPacket(iqPacket, result -> {}); iqPacket,
result -> {
if (result.getType() != IqPacket.TYPE.RESULT) {
return;
}
final Query query = result.getExtension(Query.class);
if (query == null) {
// No query in result means further modifications are sent via pushes
return;
}
// TODO delete entire roster
for (final Item item : query.getExtensions(Item.class)) {}
});
} }
} }

View file

@ -0,0 +1,34 @@
package im.conversations.android.xmpp;
import static org.hamcrest.CoreMatchers.instanceOf;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.Assert.assertEquals;
import eu.siacs.conversations.xml.Element;
import eu.siacs.conversations.xml.XmlElementReader;
import im.conversations.android.xmpp.model.roster.Item;
import im.conversations.android.xmpp.model.roster.Query;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.ConscryptMode;
@RunWith(RobolectricTestRunner.class)
@ConscryptMode(ConscryptMode.Mode.OFF)
public class XmlElementReaderTest {
@Test
public void readRosterItems() throws IOException {
final String xml =
"<query xmlns='jabber:iq:roster'><item subscription='none' jid='a@b.c'/><item"
+ " subscription='both' jid='d@e.f' ask='subscribe'/></query>";
final Element element = XmlElementReader.read(xml.getBytes(StandardCharsets.UTF_8));
assertThat(element, instanceOf(Query.class));
final Query query = (Query) element;
final Collection<Item> items = query.getExtensions(Item.class);
assertEquals(2, items.size());
}
}