diff --git a/build.gradle b/build.gradle index 0c3c998bc..d5f999bc3 100644 --- a/build.gradle +++ b/build.gradle @@ -55,6 +55,8 @@ dependencies { 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 @@ -137,6 +139,12 @@ android { } + testOptions { + unitTests { + includeAndroidResources = false + } + } + splits { abi { universalApk true diff --git a/schemas/im.conversations.android.database.ConversationsDatabase/1.json b/schemas/im.conversations.android.database.ConversationsDatabase/1.json index 91b5916c2..e0bb78f30 100644 --- a/schemas/im.conversations.android.database.ConversationsDatabase/1.json +++ b/schemas/im.conversations.android.database.ConversationsDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "4a70ff0733436f5a2a08e7abb8e6cc95", + "identityHash": "d16845c3eb73e5fdbc9902903b74428a", "entities": [ { "tableName": "account", @@ -590,7 +590,7 @@ }, { "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": [ { "fieldPath": "id", @@ -657,6 +657,12 @@ "columnName": "stanzaId", "affinity": "TEXT", "notNull": false + }, + { + "fieldPath": "acknowledged", + "columnName": "acknowledged", + "affinity": "INTEGER", + "notNull": true } ], "primaryKey": { @@ -1189,7 +1195,7 @@ "views": [], "setupQueries": [ "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')" ] } } \ No newline at end of file diff --git a/src/main/java/eu/siacs/conversations/xml/Element.java b/src/main/java/eu/siacs/conversations/xml/Element.java index fe5ad57b5..65cb5083e 100644 --- a/src/main/java/eu/siacs/conversations/xml/Element.java +++ b/src/main/java/eu/siacs/conversations/xml/Element.java @@ -1,18 +1,19 @@ package eu.siacs.conversations.xml; 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 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.xmpp.InvalidJid; import eu.siacs.conversations.xmpp.Jid; 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 { private final String name; @@ -65,6 +66,19 @@ public class Element { return null; } + public E getExtension(final Class clazz) { + final var extension = Iterables.find(this.children, clazz::isInstance); + if (extension == null) { + return null; + } + return clazz.cast(extension); + } + + public Collection getExtensions(final Class clazz) { + return Collections2.transform( + Collections2.filter(this.children, clazz::isInstance), clazz::cast); + } + public String findChildContent(String name) { Element element = findChild(name); return element == null ? null : element.getContent(); diff --git a/src/main/java/eu/siacs/conversations/xml/XmlReader.java b/src/main/java/eu/siacs/conversations/xml/XmlReader.java index 240b92b7a..a142677db 100644 --- a/src/main/java/eu/siacs/conversations/xml/XmlReader.java +++ b/src/main/java/eu/siacs/conversations/xml/XmlReader.java @@ -2,115 +2,122 @@ package eu.siacs.conversations.xml; import android.util.Log; import android.util.Xml; - -import org.xmlpull.v1.XmlPullParser; -import org.xmlpull.v1.XmlPullParserException; - +import eu.siacs.conversations.Config; +import im.conversations.android.xmpp.Extensions; import java.io.Closeable; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; - -import eu.siacs.conversations.Config; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; public class XmlReader implements Closeable { - private final XmlPullParser parser; - private InputStream is; + private final XmlPullParser parser; + private InputStream is; - public XmlReader() { - this.parser = Xml.newPullParser(); - try { - this.parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true); - } catch (XmlPullParserException e) { - Log.d(Config.LOGTAG, "error setting namespace feature on parser"); - } - } + public XmlReader() { + this.parser = Xml.newPullParser(); + try { + this.parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true); + } catch (XmlPullParserException e) { + Log.d(Config.LOGTAG, "error setting namespace feature on parser"); + } + } - public void setInputStream(InputStream inputStream) throws IOException { - if (inputStream == null) { - throw new IOException(); - } - this.is = inputStream; - try { - parser.setInput(new InputStreamReader(this.is)); - } catch (XmlPullParserException e) { - throw new IOException("error resetting parser"); - } - } + public void setInputStream(InputStream inputStream) throws IOException { + if (inputStream == null) { + throw new IOException(); + } + this.is = inputStream; + try { + parser.setInput(new InputStreamReader(this.is)); + } catch (XmlPullParserException e) { + throw new IOException("error resetting parser"); + } + } - public void reset() throws IOException { - if (this.is == null) { - throw new IOException(); - } - try { - parser.setInput(new InputStreamReader(this.is)); - } catch (XmlPullParserException e) { - throw new IOException("error resetting parser"); - } - } + public void reset() throws IOException { + if (this.is == null) { + throw new IOException(); + } + try { + parser.setInput(new InputStreamReader(this.is)); + } catch (XmlPullParserException e) { + throw new IOException("error resetting parser"); + } + } - @Override - public void close() { - this.is = null; - } + @Override + public void close() { + this.is = null; + } - public Tag readTag() throws IOException { - try { - while (this.is != null && parser.next() != XmlPullParser.END_DOCUMENT) { - if (parser.getEventType() == XmlPullParser.START_TAG) { - Tag tag = Tag.start(parser.getName()); - final String xmlns = parser.getNamespace(); - for (int i = 0; i < parser.getAttributeCount(); ++i) { - final String prefix = parser.getAttributePrefix(i); - String name; - if (prefix != null && !prefix.isEmpty()) { - name = prefix+":"+parser.getAttributeName(i); - } else { - name = parser.getAttributeName(i); - } - tag.setAttribute(name,parser.getAttributeValue(i)); - } - if (xmlns != null) { - tag.setAttribute("xmlns", xmlns); - } - return tag; - } else if (parser.getEventType() == XmlPullParser.END_TAG) { - return Tag.end(parser.getName()); - } else if (parser.getEventType() == XmlPullParser.TEXT) { - return Tag.no(parser.getText()); - } - } + public Tag readTag() throws IOException { + try { + while (this.is != null && parser.next() != XmlPullParser.END_DOCUMENT) { + if (parser.getEventType() == XmlPullParser.START_TAG) { + Tag tag = Tag.start(parser.getName()); + final String xmlns = parser.getNamespace(); + for (int i = 0; i < parser.getAttributeCount(); ++i) { + final String prefix = parser.getAttributePrefix(i); + String name; + if (prefix != null && !prefix.isEmpty()) { + name = prefix + ":" + parser.getAttributeName(i); + } else { + name = parser.getAttributeName(i); + } + tag.setAttribute(name, parser.getAttributeValue(i)); + } + if (xmlns != null) { + tag.setAttribute("xmlns", xmlns); + } + return tag; + } else if (parser.getEventType() == XmlPullParser.END_TAG) { + return Tag.end(parser.getName()); + } else if (parser.getEventType() == XmlPullParser.TEXT) { + return Tag.no(parser.getText()); + } + } - } catch (Throwable throwable) { - throw new IOException("xml parser mishandled "+throwable.getClass().getSimpleName()+"("+throwable.getMessage()+")", throwable); - } - return null; - } + } catch (Throwable throwable) { + throw new IOException( + "xml parser mishandled " + + throwable.getClass().getSimpleName() + + "(" + + throwable.getMessage() + + ")", + throwable); + } + return null; + } - public Element readElement(Tag currentTag) throws IOException { - Element element = new Element(currentTag.getName()); - element.setAttributes(currentTag.getAttributes()); - Tag nextTag = this.readTag(); - if (nextTag == null) { - throw new IOException("interrupted mid tag"); - } - if (nextTag.isNo()) { - element.setContent(nextTag.getName()); - nextTag = this.readTag(); - if (nextTag == null) { - throw new IOException("interrupted mid tag"); - } - } - while (!nextTag.isEnd(element.getName())) { - if (!nextTag.isNo()) { - Element child = this.readElement(nextTag); - element.addChild(child); - } - nextTag = this.readTag(); - if (nextTag == null) { - throw new IOException("interrupted mid tag"); - } - } - return element; - } + public Element readElement(Tag currentTag) throws IOException { + final var attributes = currentTag.getAttributes(); + final var namespace = attributes.get("xmlns"); + final var name = currentTag.getName(); + final Element element = Extensions.create(name, namespace); + element.setAttributes(currentTag.getAttributes()); + Tag nextTag = this.readTag(); + if (nextTag == null) { + throw new IOException("interrupted mid tag"); + } + if (nextTag.isNo()) { + element.setContent(nextTag.getName()); + nextTag = this.readTag(); + if (nextTag == null) { + throw new IOException("interrupted mid tag"); + } + } + while (!nextTag.isEnd(element.getName())) { + if (!nextTag.isNo()) { + Element child = this.readElement(nextTag); + element.addChild(child); + } + nextTag = this.readTag(); + if (nextTag == null) { + throw new IOException("interrupted mid tag"); + } + } + return element; + } } diff --git a/src/main/java/im/conversations/android/annotation/XmlElement.java b/src/main/java/im/conversations/android/annotation/XmlElement.java new file mode 100644 index 000000000..9f3cbf7c4 --- /dev/null +++ b/src/main/java/im/conversations/android/annotation/XmlElement.java @@ -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 ""; +} diff --git a/src/main/java/im/conversations/android/annotation/XmlPackage.java b/src/main/java/im/conversations/android/annotation/XmlPackage.java new file mode 100644 index 000000000..7619ca155 --- /dev/null +++ b/src/main/java/im/conversations/android/annotation/XmlPackage.java @@ -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(); +} diff --git a/src/main/java/im/conversations/android/xmpp/Extensions.java b/src/main/java/im/conversations/android/xmpp/Extensions.java new file mode 100644 index 000000000..962973bd1 --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/Extensions.java @@ -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> ELEMENTS = + Arrays.asList(Query.class, Item.class); + + private static final Map> EXTENSION_CLASS_MAP; + + static { + final var builder = new ImmutableMap.Builder>(); + for (final Class clazz : ELEMENTS) { + builder.put(id(clazz), clazz); + } + EXTENSION_CLASS_MAP = builder.build(); + } + + private static Id id(final Class 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 clazz = of(name, namespace); + if (clazz == null) { + return new Element(name, namespace); + } + final Constructor 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 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); + } + } +} diff --git a/src/main/java/im/conversations/android/xmpp/model/Extension.java b/src/main/java/im/conversations/android/xmpp/model/Extension.java new file mode 100644 index 000000000..2912d6960 --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/Extension.java @@ -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); + } +} diff --git a/src/main/java/im/conversations/android/xmpp/model/roster/Item.java b/src/main/java/im/conversations/android/xmpp/model/roster/Item.java new file mode 100644 index 000000000..6aebbcf0f --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/roster/Item.java @@ -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); + } +} diff --git a/src/main/java/im/conversations/android/xmpp/model/roster/Query.java b/src/main/java/im/conversations/android/xmpp/model/roster/Query.java new file mode 100644 index 000000000..89e5c9c45 --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/roster/Query.java @@ -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); + } +} diff --git a/src/main/java/im/conversations/android/xmpp/model/roster/package-info.java b/src/main/java/im/conversations/android/xmpp/model/roster/package-info.java new file mode 100644 index 000000000..cae4676ae --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/roster/package-info.java @@ -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; diff --git a/src/main/java/im/conversations/android/xmpp/processor/BindProcessor.java b/src/main/java/im/conversations/android/xmpp/processor/BindProcessor.java index 62b5413d8..8b539a430 100644 --- a/src/main/java/im/conversations/android/xmpp/processor/BindProcessor.java +++ b/src/main/java/im/conversations/android/xmpp/processor/BindProcessor.java @@ -4,10 +4,11 @@ import android.content.Context; import android.util.Log; import com.google.common.base.Strings; import eu.siacs.conversations.Config; -import eu.siacs.conversations.xml.Namespace; import eu.siacs.conversations.xmpp.Jid; import eu.siacs.conversations.xmpp.stanzas.IqPacket; 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; public class BindProcessor extends AbstractBaseProcessor implements Consumer { @@ -43,12 +44,27 @@ public class BindProcessor extends AbstractBaseProcessor implements Consumer {}); + connection.sendIqPacket( + 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)) {} + }); } } diff --git a/src/test/java/im/conversations/android/xmpp/XmlElementReaderTest.java b/src/test/java/im/conversations/android/xmpp/XmlElementReaderTest.java new file mode 100644 index 000000000..1b4fe8d74 --- /dev/null +++ b/src/test/java/im/conversations/android/xmpp/XmlElementReaderTest.java @@ -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 = + ""; + final Element element = XmlElementReader.read(xml.getBytes(StandardCharsets.UTF_8)); + assertThat(element, instanceOf(Query.class)); + final Query query = (Query) element; + final Collection items = query.getExtensions(Item.class); + assertEquals(2, items.size()); + } +}