add PubSubManager, AvatarManager and AxolotlManager

This commit is contained in:
Daniel Gultsch 2023-02-05 19:32:32 +01:00
parent f1e1cf9653
commit c077e4e8da
No known key found for this signature in database
GPG key ID: F43D18AD2A0982C2
68 changed files with 3430 additions and 14 deletions

View file

@ -7,7 +7,7 @@ buildscript {
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:7.4.1' classpath 'com.android.tools.build:gradle:7.4.1'
classpath "com.diffplug.spotless:spotless-plugin-gradle:6.12.1" classpath "com.diffplug.spotless:spotless-plugin-gradle:6.13.0"
} }
} }
@ -34,7 +34,7 @@ configurations {
} }
spotless { spotless {
ratchetFrom '2.12.0' ratchetFrom '2.12.2'
java { java {
target '**/*.java' target '**/*.java'
googleJavaFormat('1.8').aosp().reflowLongStrings() googleJavaFormat('1.8').aosp().reflowLongStrings()
@ -80,11 +80,11 @@ dependencies {
quicksyPlaystoreImplementation 'com.google.android.gms:play-services-auth-api-phone:18.0.1' quicksyPlaystoreImplementation 'com.google.android.gms:play-services-auth-api-phone:18.0.1'
implementation 'org.sufficientlysecure:openpgp-api:10.0' implementation 'org.sufficientlysecure:openpgp-api:10.0'
implementation 'com.theartofdev.edmodo:android-image-cropper:2.8.0' implementation 'com.theartofdev.edmodo:android-image-cropper:2.8.0'
implementation 'androidx.appcompat:appcompat:1.5.1' implementation 'androidx.appcompat:appcompat:1.6.0'
implementation 'androidx.exifinterface:exifinterface:1.3.5' implementation 'androidx.exifinterface:exifinterface:1.3.5'
implementation 'androidx.cardview:cardview:1.0.0' implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'com.google.android.material:material:1.7.0' implementation 'com.google.android.material:material:1.8.0'
implementation "androidx.emoji2:emoji2:1.2.0" implementation "androidx.emoji2:emoji2:1.2.0"
freeImplementation "androidx.emoji2:emoji2-bundled:1.2.0" freeImplementation "androidx.emoji2:emoji2-bundled:1.2.0"

View file

@ -2,7 +2,7 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 1, "version": 1,
"identityHash": "e2dbbac3327bc8ef188286642b379e7d", "identityHash": "2972255ca35c75ece48909471313d20a",
"entities": [ "entities": [
{ {
"tableName": "account", "tableName": "account",
@ -124,6 +124,634 @@
], ],
"foreignKeys": [] "foreignKeys": []
}, },
{
"tableName": "avatar_additional",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `avatarId` INTEGER NOT NULL, `avatar_external_url` TEXT, `avatar_external_id` TEXT, `avatar_external_type` TEXT, `avatar_external_bytes` INTEGER NOT NULL, `avatar_external_height` INTEGER NOT NULL, `avatar_external_width` INTEGER NOT NULL, FOREIGN KEY(`avatarId`) REFERENCES `avatar`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "avatarId",
"columnName": "avatarId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "external.url",
"columnName": "avatar_external_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "external.id",
"columnName": "avatar_external_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "external.type",
"columnName": "avatar_external_type",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "external.bytes",
"columnName": "avatar_external_bytes",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "external.height",
"columnName": "avatar_external_height",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "external.width",
"columnName": "avatar_external_width",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_avatar_additional_avatarId",
"unique": false,
"columnNames": [
"avatarId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_avatar_additional_avatarId` ON `${TABLE_NAME}` (`avatarId`)"
}
],
"foreignKeys": [
{
"table": "avatar",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"avatarId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "avatar",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `accountId` INTEGER NOT NULL, `address` TEXT NOT NULL, `thumb_id` TEXT, `thumb_type` TEXT, `thumb_bytes` INTEGER NOT NULL, `thumb_height` INTEGER NOT NULL, `thumb_width` INTEGER NOT NULL, FOREIGN KEY(`accountId`) REFERENCES `account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "address",
"columnName": "address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "thumbnail.id",
"columnName": "thumb_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbnail.type",
"columnName": "thumb_type",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbnail.bytes",
"columnName": "thumb_bytes",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "thumbnail.height",
"columnName": "thumb_height",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "thumbnail.width",
"columnName": "thumb_width",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_avatar_accountId_address",
"unique": true,
"columnNames": [
"accountId",
"address"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_avatar_accountId_address` ON `${TABLE_NAME}` (`accountId`, `address`)"
}
],
"foreignKeys": [
{
"table": "account",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"accountId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "axolotl_device_list",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `accountId` INTEGER NOT NULL, `address` TEXT NOT NULL, `receivedAt` INTEGER NOT NULL, `errorCondition` TEXT, `isParsingIssue` INTEGER NOT NULL, FOREIGN KEY(`accountId`) REFERENCES `account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "address",
"columnName": "address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "receivedAt",
"columnName": "receivedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "errorCondition",
"columnName": "errorCondition",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isParsingIssue",
"columnName": "isParsingIssue",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_axolotl_device_list_accountId_address",
"unique": true,
"columnNames": [
"accountId",
"address"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_axolotl_device_list_accountId_address` ON `${TABLE_NAME}` (`accountId`, `address`)"
}
],
"foreignKeys": [
{
"table": "account",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"accountId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "axolotl_device_list_item",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `deviceListId` INTEGER NOT NULL, `deviceId` INTEGER, FOREIGN KEY(`deviceListId`) REFERENCES `axolotl_device_list`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "deviceListId",
"columnName": "deviceListId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "deviceId",
"columnName": "deviceId",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_axolotl_device_list_item_deviceListId",
"unique": false,
"columnNames": [
"deviceListId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_axolotl_device_list_item_deviceListId` ON `${TABLE_NAME}` (`deviceListId`)"
},
{
"name": "index_axolotl_device_list_item_deviceListId_deviceId",
"unique": true,
"columnNames": [
"deviceListId",
"deviceId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_axolotl_device_list_item_deviceListId_deviceId` ON `${TABLE_NAME}` (`deviceListId`, `deviceId`)"
}
],
"foreignKeys": [
{
"table": "axolotl_device_list",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"deviceListId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "axolotl_identity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `accountId` INTEGER NOT NULL, `address` TEXT NOT NULL, `deviceId` INTEGER NOT NULL, `identityKey` BLOB NOT NULL, FOREIGN KEY(`accountId`) REFERENCES `account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "address",
"columnName": "address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "deviceId",
"columnName": "deviceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "identityKey",
"columnName": "identityKey",
"affinity": "BLOB",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_axolotl_identity_accountId_address_deviceId",
"unique": true,
"columnNames": [
"accountId",
"address",
"deviceId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_axolotl_identity_accountId_address_deviceId` ON `${TABLE_NAME}` (`accountId`, `address`, `deviceId`)"
}
],
"foreignKeys": [
{
"table": "account",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"accountId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "axolotl_identity_key_pair",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `accountId` INTEGER NOT NULL, `identityKeyPair` BLOB NOT NULL, FOREIGN KEY(`accountId`) REFERENCES `account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "identityKeyPair",
"columnName": "identityKeyPair",
"affinity": "BLOB",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_axolotl_identity_key_pair_accountId",
"unique": true,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_axolotl_identity_key_pair_accountId` ON `${TABLE_NAME}` (`accountId`)"
}
],
"foreignKeys": [
{
"table": "account",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"accountId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "axolotl_pre_key",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `accountId` INTEGER NOT NULL, `preKeyId` INTEGER NOT NULL, `preKeyRecord` BLOB NOT NULL, `removed` INTEGER NOT NULL, FOREIGN KEY(`accountId`) REFERENCES `account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "preKeyId",
"columnName": "preKeyId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "preKeyRecord",
"columnName": "preKeyRecord",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "removed",
"columnName": "removed",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_axolotl_pre_key_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_axolotl_pre_key_accountId` ON `${TABLE_NAME}` (`accountId`)"
}
],
"foreignKeys": [
{
"table": "account",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"accountId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "axolotl_session",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `accountId` INTEGER NOT NULL, `address` TEXT NOT NULL, `deviceId` INTEGER NOT NULL, `sessionRecord` BLOB NOT NULL, FOREIGN KEY(`accountId`) REFERENCES `account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "address",
"columnName": "address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "deviceId",
"columnName": "deviceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sessionRecord",
"columnName": "sessionRecord",
"affinity": "BLOB",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_axolotl_session_accountId_address_deviceId",
"unique": true,
"columnNames": [
"accountId",
"address",
"deviceId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_axolotl_session_accountId_address_deviceId` ON `${TABLE_NAME}` (`accountId`, `address`, `deviceId`)"
}
],
"foreignKeys": [
{
"table": "account",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"accountId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "axolotl_signed_pre_key",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `accountId` INTEGER NOT NULL, `signedPreKeyId` INTEGER NOT NULL, `signedPreKeyRecord` BLOB NOT NULL, `removed` INTEGER NOT NULL, FOREIGN KEY(`accountId`) REFERENCES `account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "signedPreKeyId",
"columnName": "signedPreKeyId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "signedPreKeyRecord",
"columnName": "signedPreKeyRecord",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "removed",
"columnName": "removed",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_axolotl_signed_pre_key_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_axolotl_signed_pre_key_accountId` ON `${TABLE_NAME}` (`accountId`)"
}
],
"foreignKeys": [
{
"table": "account",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"accountId"
],
"referencedColumns": [
"id"
]
}
]
},
{ {
"tableName": "blocked", "tableName": "blocked",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `accountId` INTEGER NOT NULL, `address` TEXT NOT NULL, FOREIGN KEY(`accountId`) REFERENCES `account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `accountId` INTEGER NOT NULL, `address` TEXT NOT NULL, FOREIGN KEY(`accountId`) REFERENCES `account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
@ -179,6 +807,85 @@
} }
] ]
}, },
{
"tableName": "bookmark",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `accountId` INTEGER NOT NULL, `address` TEXT NOT NULL, `name` TEXT, `nick` TEXT, `autoJoin` INTEGER NOT NULL, `password` TEXT, FOREIGN KEY(`accountId`) REFERENCES `account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "address",
"columnName": "address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "nick",
"columnName": "nick",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "autoJoin",
"columnName": "autoJoin",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "password",
"columnName": "password",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_bookmark_accountId_address",
"unique": true,
"columnNames": [
"accountId",
"address"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmark_accountId_address` ON `${TABLE_NAME}` (`accountId`, `address`)"
}
],
"foreignKeys": [
{
"table": "account",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"accountId"
],
"referencedColumns": [
"id"
]
}
]
},
{ {
"tableName": "chat", "tableName": "chat",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `accountId` INTEGER NOT NULL, `address` TEXT NOT NULL, `type` TEXT, `archived` INTEGER NOT NULL, FOREIGN KEY(`accountId`) REFERENCES `account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `accountId` INTEGER NOT NULL, `address` TEXT NOT NULL, `type` TEXT, `archived` INTEGER NOT NULL, FOREIGN KEY(`accountId`) REFERENCES `account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
@ -981,6 +1688,67 @@
} }
] ]
}, },
{
"tableName": "nick",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `accountId` INTEGER NOT NULL, `address` TEXT NOT NULL, `nick` TEXT, FOREIGN KEY(`accountId`) REFERENCES `account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "address",
"columnName": "address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "nick",
"columnName": "nick",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_nick_accountId_address",
"unique": true,
"columnNames": [
"accountId",
"address"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_nick_accountId_address` ON `${TABLE_NAME}` (`accountId`, `address`)"
}
],
"foreignKeys": [
{
"table": "account",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"accountId"
],
"referencedColumns": [
"id"
]
}
]
},
{ {
"tableName": "presence", "tableName": "presence",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `accountId` INTEGER NOT NULL, `address` TEXT NOT NULL, `resource` TEXT NOT NULL, `type` TEXT, `show` TEXT, `status` TEXT, `vCardPhoto` TEXT, `occupantId` TEXT, `mucUserAffiliation` TEXT, `mucUserRole` TEXT, `mucUserJid` TEXT, `mucUserSelf` INTEGER NOT NULL, `discoId` INTEGER, FOREIGN KEY(`accountId`) REFERENCES `account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`discoId`) REFERENCES `disco`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `accountId` INTEGER NOT NULL, `address` TEXT NOT NULL, `resource` TEXT NOT NULL, `type` TEXT, `show` TEXT, `status` TEXT, `vCardPhoto` TEXT, `occupantId` TEXT, `mucUserAffiliation` TEXT, `mucUserRole` TEXT, `mucUserJid` TEXT, `mucUserSelf` INTEGER NOT NULL, `discoId` INTEGER, FOREIGN KEY(`accountId`) REFERENCES `account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`discoId`) REFERENCES `disco`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
@ -1344,7 +2112,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, 'e2dbbac3327bc8ef188286642b379e7d')" "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2972255ca35c75ece48909471313d20a')"
] ]
} }
} }

View file

@ -5,6 +5,7 @@ import com.google.common.base.Strings;
import com.google.common.collect.Collections2; import com.google.common.collect.Collections2;
import com.google.common.collect.Iterables; import com.google.common.collect.Iterables;
import com.google.common.primitives.Ints; import com.google.common.primitives.Ints;
import com.google.common.primitives.Longs;
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;
@ -189,6 +190,11 @@ public class Element {
} }
} }
public long getLongAttribute(final String name) {
final var value = Longs.tryParse(Strings.nullToEmpty(this.attributes.get(name)));
return value == null ? 0 : value;
}
public Optional<Integer> getOptionalIntAttribute(final String name) { public Optional<Integer> getOptionalIntAttribute(final String name) {
final String value = getAttribute(name); final String value = getAttribute(name);
if (value == null) { if (value == null) {

View file

@ -1,14 +1,18 @@
package eu.siacs.conversations.xml; package eu.siacs.conversations.xml;
public final class Namespace { public final class Namespace {
public static final String AVATAR_CONVERSION = "urn:xmpp:pep-vcard-conversion:0"; public static final String AVATAR_CONVERSION = "urn:xmpp:pep-vcard-conversion:0";
public static final String AVATAR_DATA = "urn:xmpp:avatar:data"; public static final String AVATAR_DATA = "urn:xmpp:avatar:data";
public static final String AVATAR_METADATA = "urn:xmpp:avatar:metadata"; public static final String AVATAR_METADATA = "urn:xmpp:avatar:metadata";
public static final String AXOLOTL = "eu.siacs.conversations.axolotl";
public static final String AXOLOTL_BUNDLES = AXOLOTL + ".bundles";
public static final String AXOLOTL_DEVICE_LIST = AXOLOTL + ".devicelist";
public static final String BIND = "urn:ietf:params:xml:ns:xmpp-bind"; public static final String BIND = "urn:ietf:params:xml:ns:xmpp-bind";
public static final String BIND2 = "urn:xmpp:bind:0"; public static final String BIND2 = "urn:xmpp:bind:0";
public static final String BLOCKING = "urn:xmpp:blocking"; public static final String BLOCKING = "urn:xmpp:blocking";
public static final String BOOKMARKS = "storage:bookmarks"; public static final String BOOKMARKS = "storage:bookmarks";
public static final String BOOKMARKS2 = "urn:xmpp:bookmarks:0"; public static final String BOOKMARKS2 = "urn:xmpp:bookmarks:1";
public static final String BOOKMARKS2_COMPAT = BOOKMARKS2 + "#compat"; public static final String BOOKMARKS2_COMPAT = BOOKMARKS2 + "#compat";
public static final String BOOKMARKS_CONVERSION = "urn:xmpp:bookmarks-conversion:0"; public static final String BOOKMARKS_CONVERSION = "urn:xmpp:bookmarks-conversion:0";
public static final String BYTE_STREAMS = "http://jabber.org/protocol/bytestreams"; public static final String BYTE_STREAMS = "http://jabber.org/protocol/bytestreams";
@ -70,9 +74,15 @@ public final class Namespace {
public static final String PARS = "urn:xmpp:pars:0"; public static final String PARS = "urn:xmpp:pars:0";
public static final String PING = "urn:xmpp:ping"; public static final String PING = "urn:xmpp:ping";
public static final String PUBSUB = "http://jabber.org/protocol/pubsub"; public static final String PUBSUB = "http://jabber.org/protocol/pubsub";
public static final String PUBSUB_ERROR = PUBSUB + "#errors";
public static final String PUBSUB_OWNER = PUBSUB + "#owner"; public static final String PUBSUB_OWNER = PUBSUB + "#owner";
public static final String PUBSUB_PUBLISH_OPTIONS = PUBSUB + "#publish-options"; public static final String PUBSUB_PUBLISH_OPTIONS = PUBSUB + "#publish-options";
public static final String PUBSUB_ERROR = PUBSUB + "#errors";
public static final String PUB_SUB = "http://jabber.org/protocol/pubsub";
public static final String PUB_SUB_ERROR = PUB_SUB + "#errors";
public static final String PUB_SUB_EVENT = PUB_SUB + "#event";
public static final String PUB_SUB_OWNER = PUB_SUB + "#owner";
public static final String PUB_SUB_PERSISTENT_ITEMS = PUB_SUB + "#persistent-items";
public static final String PUB_SUB_PUBLISH_OPTIONS = PUB_SUB + "#publish-options";
public static final String PUSH = "urn:xmpp:push:0"; public static final String PUSH = "urn:xmpp:push:0";
public static final String REGISTER = "jabber:iq:register"; public static final String REGISTER = "jabber:iq:register";
public static final String REGISTER_STREAM_FEATURE = "http://jabber.org/features/iq-register"; public static final String REGISTER_STREAM_FEATURE = "http://jabber.org/features/iq-register";

View file

@ -0,0 +1,145 @@
package im.conversations.android.database;
import android.content.Context;
import im.conversations.android.database.dao.AxolotlDao;
import im.conversations.android.database.model.Account;
import im.conversations.android.xmpp.axolotl.AxolotlAddress;
import java.util.List;
import org.whispersystems.libsignal.IdentityKey;
import org.whispersystems.libsignal.IdentityKeyPair;
import org.whispersystems.libsignal.InvalidKeyIdException;
import org.whispersystems.libsignal.SignalProtocolAddress;
import org.whispersystems.libsignal.state.PreKeyRecord;
import org.whispersystems.libsignal.state.SessionRecord;
import org.whispersystems.libsignal.state.SignalProtocolStore;
import org.whispersystems.libsignal.state.SignedPreKeyRecord;
public class AxolotlDatabaseStore implements SignalProtocolStore {
private final Context context;
private final Account account;
public AxolotlDatabaseStore(final Context context, final Account account) {
this.context = context;
this.account = account;
}
private AxolotlDao axolotlDao() {
return ConversationsDatabase.getInstance(context).axolotlDao();
}
@Override
public IdentityKeyPair getIdentityKeyPair() {
return axolotlDao().getOrCreateIdentityKeyPair(account);
}
@Override
public int getLocalRegistrationId() {
return account.getPublicDeviceIdInt();
}
@Override
public boolean saveIdentity(
final SignalProtocolAddress signalProtocolAddress, IdentityKey identityKey) {
final var address = AxolotlAddress.cast(signalProtocolAddress);
return axolotlDao()
.setIdentity(account, address.getJid(), address.getDeviceId(), identityKey);
}
@Override
public boolean isTrustedIdentity(
SignalProtocolAddress address, IdentityKey identityKey, Direction direction) {
// TODO return false for Direction==Sending and Trust == untrusted
return true;
}
@Override
public PreKeyRecord loadPreKey(int preKeyId) throws InvalidKeyIdException {
final var preKey = axolotlDao().getPreKey(account.id, preKeyId);
if (preKey == null) {
throw new InvalidKeyIdException(String.format("PreKey %d does not exist", preKeyId));
}
return preKey;
}
@Override
public void storePreKey(int preKeyId, PreKeyRecord preKeyRecord) {
axolotlDao().setPreKey(account, preKeyId, preKeyRecord);
}
@Override
public boolean containsPreKey(int preKeyId) {
return axolotlDao().hasPreKey(account.id, preKeyId);
}
@Override
public void removePreKey(int preKeyId) {
axolotlDao().markPreKeyAsRemoved(account.id, preKeyId);
}
@Override
public SessionRecord loadSession(final SignalProtocolAddress signalProtocolAddress) {
final var address = AxolotlAddress.cast(signalProtocolAddress);
final var sessionRecord =
axolotlDao().getSessionRecord(account.id, address.getJid(), address.getDeviceId());
return sessionRecord == null ? new SessionRecord() : sessionRecord;
}
@Override
public List<Integer> getSubDeviceSessions(String name) {
return axolotlDao().getSessionDeviceIds(account.id, name);
}
@Override
public void storeSession(SignalProtocolAddress signalProtocolAddress, SessionRecord record) {
final var address = AxolotlAddress.cast(signalProtocolAddress);
axolotlDao().setSessionRecord(account, address.getJid(), address.getDeviceId(), record);
}
@Override
public boolean containsSession(SignalProtocolAddress signalProtocolAddress) {
final var address = AxolotlAddress.cast(signalProtocolAddress);
return axolotlDao().hasSession(account.id, address.getJid(), address.getDeviceId());
}
@Override
public void deleteSession(SignalProtocolAddress signalProtocolAddress) {
final var address = AxolotlAddress.cast(signalProtocolAddress);
axolotlDao().deleteSession(account.id, address.getJid(), address.getDeviceId());
}
@Override
public void deleteAllSessions(String name) {
axolotlDao().deleteSessions(account.id, name);
}
@Override
public SignedPreKeyRecord loadSignedPreKey(int signedPreKeyId) throws InvalidKeyIdException {
final var signedPreKeyRecord = axolotlDao().getSignedPreKey(account.id, signedPreKeyId);
if (signedPreKeyRecord == null) {
throw new InvalidKeyIdException(
String.format("signedPreKey %d not found", signedPreKeyId));
}
return signedPreKeyRecord;
}
@Override
public List<SignedPreKeyRecord> loadSignedPreKeys() {
return axolotlDao().getSignedPreKeys(account.id);
}
@Override
public void storeSignedPreKey(int signedPreKeyId, SignedPreKeyRecord record) {
axolotlDao().setSignedPreKey(account, signedPreKeyId, record);
}
@Override
public boolean containsSignedPreKey(int signedPreKeyId) {
return axolotlDao().hasSignedPreKey(account.id, signedPreKeyId);
}
@Override
public void removeSignedPreKey(int signedPreKeyId) {
axolotlDao().markSignedPreKeyAsRemoved(account.id, signedPreKeyId);
}
}

View file

@ -6,13 +6,27 @@ import androidx.room.Room;
import androidx.room.RoomDatabase; import androidx.room.RoomDatabase;
import androidx.room.TypeConverters; import androidx.room.TypeConverters;
import im.conversations.android.database.dao.AccountDao; import im.conversations.android.database.dao.AccountDao;
import im.conversations.android.database.dao.AvatarDao;
import im.conversations.android.database.dao.AxolotlDao;
import im.conversations.android.database.dao.BlockingDao; import im.conversations.android.database.dao.BlockingDao;
import im.conversations.android.database.dao.BookmarkDao;
import im.conversations.android.database.dao.DiscoDao; import im.conversations.android.database.dao.DiscoDao;
import im.conversations.android.database.dao.MessageDao; import im.conversations.android.database.dao.MessageDao;
import im.conversations.android.database.dao.NickDao;
import im.conversations.android.database.dao.PresenceDao; import im.conversations.android.database.dao.PresenceDao;
import im.conversations.android.database.dao.RosterDao; import im.conversations.android.database.dao.RosterDao;
import im.conversations.android.database.entity.AccountEntity; import im.conversations.android.database.entity.AccountEntity;
import im.conversations.android.database.entity.AvatarAdditionalEntity;
import im.conversations.android.database.entity.AvatarEntity;
import im.conversations.android.database.entity.AxolotlDeviceListEntity;
import im.conversations.android.database.entity.AxolotlDeviceListItemEntity;
import im.conversations.android.database.entity.AxolotlIdentityEntity;
import im.conversations.android.database.entity.AxolotlIdentityKeyPairEntity;
import im.conversations.android.database.entity.AxolotlPreKeyEntity;
import im.conversations.android.database.entity.AxolotlSessionEntity;
import im.conversations.android.database.entity.AxolotlSignedPreKeyEntity;
import im.conversations.android.database.entity.BlockedItemEntity; import im.conversations.android.database.entity.BlockedItemEntity;
import im.conversations.android.database.entity.BookmarkEntity;
import im.conversations.android.database.entity.ChatEntity; import im.conversations.android.database.entity.ChatEntity;
import im.conversations.android.database.entity.DiscoEntity; import im.conversations.android.database.entity.DiscoEntity;
import im.conversations.android.database.entity.DiscoExtensionEntity; import im.conversations.android.database.entity.DiscoExtensionEntity;
@ -24,6 +38,7 @@ import im.conversations.android.database.entity.DiscoItemEntity;
import im.conversations.android.database.entity.MessageEntity; import im.conversations.android.database.entity.MessageEntity;
import im.conversations.android.database.entity.MessagePartEntity; import im.conversations.android.database.entity.MessagePartEntity;
import im.conversations.android.database.entity.MessageVersionEntity; import im.conversations.android.database.entity.MessageVersionEntity;
import im.conversations.android.database.entity.NickEntity;
import im.conversations.android.database.entity.PresenceEntity; import im.conversations.android.database.entity.PresenceEntity;
import im.conversations.android.database.entity.ReactionEntity; import im.conversations.android.database.entity.ReactionEntity;
import im.conversations.android.database.entity.RosterItemEntity; import im.conversations.android.database.entity.RosterItemEntity;
@ -32,7 +47,17 @@ import im.conversations.android.database.entity.RosterItemGroupEntity;
@Database( @Database(
entities = { entities = {
AccountEntity.class, AccountEntity.class,
AvatarAdditionalEntity.class,
AvatarEntity.class,
AxolotlDeviceListEntity.class,
AxolotlDeviceListItemEntity.class,
AxolotlIdentityEntity.class,
AxolotlIdentityKeyPairEntity.class,
AxolotlPreKeyEntity.class,
AxolotlSessionEntity.class,
AxolotlSignedPreKeyEntity.class,
BlockedItemEntity.class, BlockedItemEntity.class,
BookmarkEntity.class,
ChatEntity.class, ChatEntity.class,
DiscoEntity.class, DiscoEntity.class,
DiscoExtensionEntity.class, DiscoExtensionEntity.class,
@ -44,6 +69,7 @@ import im.conversations.android.database.entity.RosterItemGroupEntity;
MessageEntity.class, MessageEntity.class,
MessagePartEntity.class, MessagePartEntity.class,
MessageVersionEntity.class, MessageVersionEntity.class,
NickEntity.class,
PresenceEntity.class, PresenceEntity.class,
ReactionEntity.class, ReactionEntity.class,
RosterItemEntity.class, RosterItemEntity.class,
@ -73,12 +99,20 @@ public abstract class ConversationsDatabase extends RoomDatabase {
public abstract AccountDao accountDao(); public abstract AccountDao accountDao();
public abstract AvatarDao avatarDao();
public abstract AxolotlDao axolotlDao();
public abstract BlockingDao blockingDao(); public abstract BlockingDao blockingDao();
public abstract BookmarkDao bookmarkDao();
public abstract DiscoDao discoDao(); public abstract DiscoDao discoDao();
public abstract MessageDao messageDao(); public abstract MessageDao messageDao();
public abstract NickDao nickDao();
public abstract PresenceDao presenceDao(); public abstract PresenceDao presenceDao();
public abstract RosterDao rosterDao(); public abstract RosterDao rosterDao();

View file

@ -2,7 +2,14 @@ package im.conversations.android.database;
import androidx.room.TypeConverter; import androidx.room.TypeConverter;
import eu.siacs.conversations.xmpp.Jid; import eu.siacs.conversations.xmpp.Jid;
import java.io.IOException;
import java.time.Instant; import java.time.Instant;
import org.whispersystems.libsignal.IdentityKey;
import org.whispersystems.libsignal.IdentityKeyPair;
import org.whispersystems.libsignal.InvalidKeyException;
import org.whispersystems.libsignal.state.PreKeyRecord;
import org.whispersystems.libsignal.state.SessionRecord;
import org.whispersystems.libsignal.state.SignedPreKeyRecord;
public final class Converters { public final class Converters {
@ -27,4 +34,89 @@ public final class Converters {
public static String fromJid(final Jid jid) { public static String fromJid(final Jid jid) {
return jid == null ? null : jid.toEscapedString(); return jid == null ? null : jid.toEscapedString();
} }
@TypeConverter
public static byte[] fromIdentityKey(final IdentityKey identityKey) {
return identityKey == null ? null : identityKey.serialize();
}
@TypeConverter
public static IdentityKey toIdentityKey(final byte[] serialized) {
if (serialized == null || serialized.length == 0) {
return null;
}
try {
return new IdentityKey(serialized, 0);
} catch (final InvalidKeyException e) {
throw new RuntimeException(e);
}
}
@TypeConverter
public static byte[] fromSessionRecord(final SessionRecord sessionRecord) {
return sessionRecord == null ? null : sessionRecord.serialize();
}
@TypeConverter
public static SessionRecord toSessionRecord(final byte[] serialized) {
if (serialized == null || serialized.length == 0) {
return null;
}
try {
return new SessionRecord(serialized);
} catch (final IOException e) {
throw new RuntimeException(e);
}
}
@TypeConverter
public static byte[] fromSignedPreKey(final SignedPreKeyRecord signedPreKeyRecord) {
return signedPreKeyRecord == null ? null : signedPreKeyRecord.serialize();
}
@TypeConverter
public static SignedPreKeyRecord toSignedPreKey(final byte[] serialized) {
if (serialized == null || serialized.length == 0) {
return null;
}
try {
return new SignedPreKeyRecord(serialized);
} catch (final IOException e) {
throw new RuntimeException(e);
}
}
@TypeConverter
public static byte[] fromPreKey(final PreKeyRecord preKeyRecord) {
return preKeyRecord == null ? null : preKeyRecord.serialize();
}
@TypeConverter
public static PreKeyRecord toPreKey(final byte[] serialized) {
if (serialized == null || serialized.length == 0) {
return null;
}
try {
return new PreKeyRecord(serialized);
} catch (final IOException e) {
throw new RuntimeException(e);
}
}
@TypeConverter
public static byte[] fromIdentityKeyPair(final IdentityKeyPair identityKeyPair) {
return identityKeyPair == null ? null : identityKeyPair.serialize();
}
@TypeConverter
public static IdentityKeyPair toIdentityKeyPair(final byte[] serialized) {
if (serialized == null || serialized.length == 0) {
return null;
}
try {
return new IdentityKeyPair(serialized);
} catch (final InvalidKeyException e) {
throw new RuntimeException(e);
}
}
} }

View file

@ -0,0 +1,31 @@
package im.conversations.android.database.dao;
import androidx.room.Dao;
import androidx.room.Insert;
import androidx.room.OnConflictStrategy;
import com.google.common.collect.Collections2;
import eu.siacs.conversations.xmpp.Jid;
import im.conversations.android.database.entity.AvatarAdditionalEntity;
import im.conversations.android.database.entity.AvatarEntity;
import im.conversations.android.database.model.Account;
import im.conversations.android.xmpp.model.avatar.Info;
import java.util.Collection;
@Dao
public abstract class AvatarDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
protected abstract long insert(AvatarEntity avatar);
@Insert
protected abstract void insert(Collection<AvatarAdditionalEntity> entities);
public void set(
final Account account,
final Jid address,
final Info thumbnail,
final Collection<Info> additional) {
final long id = insert(AvatarEntity.of(account, address, thumbnail));
insert(Collections2.transform(additional, a -> AvatarAdditionalEntity.of(id, a)));
}
}

View file

@ -0,0 +1,204 @@
package im.conversations.android.database.dao;
import androidx.room.Dao;
import androidx.room.Insert;
import androidx.room.OnConflictStrategy;
import androidx.room.Query;
import androidx.room.Transaction;
import com.google.common.collect.Collections2;
import eu.siacs.conversations.xmpp.Jid;
import im.conversations.android.database.entity.AxolotlDeviceListEntity;
import im.conversations.android.database.entity.AxolotlDeviceListItemEntity;
import im.conversations.android.database.entity.AxolotlIdentityEntity;
import im.conversations.android.database.entity.AxolotlIdentityKeyPairEntity;
import im.conversations.android.database.entity.AxolotlPreKeyEntity;
import im.conversations.android.database.entity.AxolotlSessionEntity;
import im.conversations.android.database.entity.AxolotlSignedPreKeyEntity;
import im.conversations.android.database.model.Account;
import im.conversations.android.xmpp.model.error.Condition;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import org.whispersystems.libsignal.IdentityKey;
import org.whispersystems.libsignal.IdentityKeyPair;
import org.whispersystems.libsignal.ecc.Curve;
import org.whispersystems.libsignal.state.PreKeyRecord;
import org.whispersystems.libsignal.state.SessionRecord;
import org.whispersystems.libsignal.state.SignedPreKeyRecord;
@Dao
public abstract class AxolotlDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
protected abstract long insert(AxolotlDeviceListEntity entity);
@Insert
protected abstract void insert(Collection<AxolotlDeviceListItemEntity> entities);
@Transaction
public void setDeviceList(Account account, Jid from, Set<Integer> deviceIds) {
final var listId = insert(AxolotlDeviceListEntity.of(account.id, from));
insert(
Collections2.transform(
deviceIds, deviceId -> AxolotlDeviceListItemEntity.of(listId, deviceId)));
}
@Query(
"SELECT EXISTS(SELECT deviceId FROM axolotl_device_list JOIN axolotl_device_list_item"
+ " ON axolotl_device_list.id=axolotl_device_list_item.deviceId WHERE"
+ " accountId=:account AND address=:address AND deviceId=:deviceId)")
public abstract boolean hasDeviceId(final long account, final Jid address, final int deviceId);
@Transaction
public void setDeviceListError(final Account account, final Jid address, Condition condition) {
insert(AxolotlDeviceListEntity.of(account.id, address, condition.getName()));
}
@Transaction
public void setDeviceListParsingError(final Account account, final Jid address) {
insert(AxolotlDeviceListEntity.ofParsingIssue(account.id, address));
}
@Transaction
public IdentityKeyPair getOrCreateIdentityKeyPair(final Account account) {
final var existing = getIdentityKeyPair(account.id);
if (existing != null) {
return existing;
}
final var ecKeyPair = Curve.generateKeyPair();
final var identityKeyPair =
new IdentityKeyPair(
new IdentityKey(ecKeyPair.getPublicKey()), ecKeyPair.getPrivateKey());
insert(AxolotlIdentityKeyPairEntity.of(account, identityKeyPair));
return identityKeyPair;
}
@Insert
protected abstract void insert(AxolotlIdentityKeyPairEntity entity);
@Query("SELECT identityKeyPair FROM axolotl_identity_key_pair WHERE accountId=:account")
protected abstract IdentityKeyPair getIdentityKeyPair(long account);
@Query(
"SELECT signedPreKeyRecord FROM axolotl_signed_pre_key WHERE accountId=:account AND"
+ " signedPreKeyId=:signedPreKeyId")
public abstract SignedPreKeyRecord getSignedPreKey(long account, int signedPreKeyId);
@Query(
"SELECT NOT EXISTS(SELECT signedPreKeyRecord FROM axolotl_signed_pre_key WHERE"
+ " accountId=:account AND signedPreKeyId=:signedPreKeyId)")
public abstract boolean hasNotSignedPreKey(long account, int signedPreKeyId);
@Query(
"SELECT signedPreKeyRecord FROM axolotl_signed_pre_key WHERE accountId=:account ORDER"
+ " BY signedPreKeyId DESC LIMIT 1")
public abstract SignedPreKeyRecord getLatestSignedPreKey(long account);
@Transaction
public boolean setIdentity(
Account account, Jid address, int deviceId, IdentityKey identityKey) {
final var existing = getIdentityKey(account.id, address, deviceId);
if (existing == null || !existing.equals(identityKey)) {
insert(AxolotlIdentityEntity.of(account, address, deviceId, identityKey));
return true;
} else {
return false;
}
}
@Insert(onConflict = OnConflictStrategy.REPLACE)
protected abstract void insert(AxolotlIdentityEntity axolotlIdentityEntity);
@Query(
"SELECT identityKey FROM AXOLOTL_IDENTITY WHERE accountId=:account AND"
+ " address=:address AND deviceId=:deviceId")
protected abstract IdentityKey getIdentityKey(long account, Jid address, int deviceId);
@Query(
"SELECT preKeyRecord FROM axolotl_pre_key WHERE accountId=:account AND"
+ " preKeyid=:preKeyId")
public abstract PreKeyRecord getPreKey(long account, int preKeyId);
@Query("SELECT MAX(preKeyId) FROM axolotl_pre_key WHERE accountId=:account")
public abstract Integer getMaxPreKeyId(final long account);
@Query("SELECT COUNT(id) FROM axolotl_pre_key WHERE accountId=:account AND removed=0")
public abstract int getExistingPreKeyCount(final long account);
public void setPreKey(final Account account, int preKeyId, PreKeyRecord preKeyRecord) {
insert(AxolotlPreKeyEntity.of(account, preKeyId, preKeyRecord));
}
@Insert(onConflict = OnConflictStrategy.REPLACE)
protected abstract void insert(AxolotlPreKeyEntity axolotlPreKeyEntity);
public void setPreKeys(final Account account, final Collection<PreKeyRecord> preKeyRecords) {
insertPreKeys(
Collections2.transform(
preKeyRecords, r -> AxolotlPreKeyEntity.of(account, r.getId(), r)));
}
@Insert(onConflict = OnConflictStrategy.REPLACE)
protected abstract void insertPreKeys(Collection<AxolotlPreKeyEntity> entities);
@Insert(onConflict = OnConflictStrategy.REPLACE)
protected abstract void insert(AxolotlSessionEntity axolotlSessionEntity);
@Query(
"SELECT EXISTS(SELECT id FROM axolotl_pre_key WHERE accountId=:account AND"
+ " preKeyId=:preKeyId)")
public abstract boolean hasPreKey(long account, int preKeyId);
@Query(
"SELECT EXISTS(SELECT id FROM axolotl_signed_pre_key WHERE accountId=:account AND"
+ " signedPreKeyId=:signedPreKeyId)")
public abstract boolean hasSignedPreKey(long account, int signedPreKeyId);
@Query("UPDATE axolotl_pre_key SET removed=1 WHERE accountId=:account AND preKeyId=:preKeyId")
public abstract void markPreKeyAsRemoved(long account, int preKeyId);
@Query(
"UPDATE axolotl_signed_pre_key SET removed=1 WHERE accountId=:account AND"
+ " signedPreKeyId=:signedPreKeyId")
public abstract void markSignedPreKeyAsRemoved(long account, int signedPreKeyId);
@Query(
"SELECT sessionRecord FROM axolotl_session WHERE accountId=:account AND"
+ " address=:address AND deviceId=:deviceId")
public abstract SessionRecord getSessionRecord(long account, Jid address, int deviceId);
@Query("SELECT deviceId FROM axolotl_session WHERE accountId=:account AND address=:address")
public abstract List<Integer> getSessionDeviceIds(long account, String address);
public void setSessionRecord(Account account, Jid address, int deviceId, SessionRecord record) {
insert(AxolotlSessionEntity.of(account, address, deviceId, record));
}
@Query(
"SELECT EXISTS(SELECT id FROM axolotl_session WHERE accountId=:account AND"
+ " address=:address AND deviceId=:deviceId)")
public abstract boolean hasSession(long account, Jid address, int deviceId);
@Query(
"DELETE FROM axolotl_session WHERE accountId=:account AND address=:address AND"
+ " deviceId=:deviceId")
public abstract void deleteSession(long account, Jid address, int deviceId);
@Query("DELETE FROM axolotl_session WHERE accountId=:account AND address=:address")
public abstract void deleteSessions(long account, String address);
@Query(
"SELECT signedPreKeyRecord FROM axolotl_signed_pre_key WHERE accountId=:account AND"
+ " removed=0")
public abstract List<SignedPreKeyRecord> getSignedPreKeys(long account);
@Query("SELECT preKeyRecord FROM axolotl_pre_key WHERE accountId=:account AND removed=0")
public abstract List<PreKeyRecord> getPreKeys(long account);
public void setSignedPreKey(Account account, int signedPreKeyId, SignedPreKeyRecord record) {
insert(AxolotlSignedPreKeyEntity.of(account, signedPreKeyId, record));
}
@Insert(onConflict = OnConflictStrategy.REPLACE)
protected abstract void insert(AxolotlSignedPreKeyEntity signedPreKeyEntity);
}

View file

@ -0,0 +1,49 @@
package im.conversations.android.database.dao;
import androidx.room.Dao;
import androidx.room.Insert;
import androidx.room.Query;
import androidx.room.Transaction;
import com.google.common.collect.Collections2;
import eu.siacs.conversations.xmpp.Jid;
import im.conversations.android.database.entity.BookmarkEntity;
import im.conversations.android.database.model.Account;
import im.conversations.android.xmpp.model.bookmark.Conference;
import java.util.Collection;
import java.util.Map;
import java.util.Objects;
@Dao
public abstract class BookmarkDao {
@Query("DELETE FROM bookmark WHERE accountId=:account")
public abstract void deleteAll(final long account);
@Query("DELETE FROM bookmark WHERE accountId=:account and address IN(:addresses)")
public abstract void delete(final long account, Collection<Jid> addresses);
@Insert
protected abstract void insert(Collection<BookmarkEntity> bookmarks);
@Transaction
public void updateItems(final Account account, Map<String, Conference> items) {
final Collection<Jid> addresses =
Collections2.transform(items.keySet(), BookmarkEntity::jidOrNull);
delete(account.id, addresses);
final var entities =
Collections2.transform(
items.entrySet(), entry -> BookmarkEntity.of(account.id, entry));
// non null filtering is required because BookmarkEntity.of() can return null values if the
insert(Collections2.filter(entities, Objects::nonNull));
}
@Transaction
public void setItems(Account account, Map<String, Conference> items) {
deleteAll(account.id);
final var entities =
Collections2.transform(
items.entrySet(), entry -> BookmarkEntity.of(account.id, entry));
// non null filtering is required because BookmarkEntity.of() can return null values if the
insert(Collections2.filter(entities, Objects::nonNull));
}
}

View file

@ -0,0 +1,19 @@
package im.conversations.android.database.dao;
import androidx.room.Dao;
import androidx.room.Insert;
import androidx.room.OnConflictStrategy;
import eu.siacs.conversations.xmpp.Jid;
import im.conversations.android.database.entity.NickEntity;
import im.conversations.android.database.model.Account;
@Dao
public abstract class NickDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
protected abstract long insert(NickEntity nickEntity);
public long set(final Account account, final Jid address, final String nick) {
return insert(NickEntity.of(account.id, address, nick));
}
}

View file

@ -0,0 +1,38 @@
package im.conversations.android.database.entity;
import androidx.annotation.NonNull;
import androidx.room.Embedded;
import androidx.room.Entity;
import androidx.room.ForeignKey;
import androidx.room.Index;
import androidx.room.PrimaryKey;
import im.conversations.android.database.model.AvatarExternal;
import im.conversations.android.xmpp.model.avatar.Info;
@Entity(
tableName = "avatar_additional",
foreignKeys =
@ForeignKey(
entity = AvatarEntity.class,
parentColumns = {"id"},
childColumns = {"avatarId"},
onDelete = ForeignKey.CASCADE),
indices = {@Index(value = {"avatarId"})})
public class AvatarAdditionalEntity {
@PrimaryKey(autoGenerate = true)
public Long id;
@NonNull public Long avatarId;
@NonNull
@Embedded(prefix = "avatar_external_")
public AvatarExternal external;
public static AvatarAdditionalEntity of(final long avatarId, Info info) {
final var entity = new AvatarAdditionalEntity();
entity.avatarId = avatarId;
entity.external = AvatarExternal.of(info);
return entity;
}
}

View file

@ -0,0 +1,47 @@
package im.conversations.android.database.entity;
import androidx.annotation.NonNull;
import androidx.room.Embedded;
import androidx.room.Entity;
import androidx.room.ForeignKey;
import androidx.room.Index;
import androidx.room.PrimaryKey;
import eu.siacs.conversations.xmpp.Jid;
import im.conversations.android.database.model.Account;
import im.conversations.android.database.model.AvatarThumbnail;
import im.conversations.android.xmpp.model.avatar.Info;
@Entity(
tableName = "avatar",
foreignKeys =
@ForeignKey(
entity = AccountEntity.class,
parentColumns = {"id"},
childColumns = {"accountId"},
onDelete = ForeignKey.CASCADE),
indices = {
@Index(
value = {"accountId", "address"},
unique = true)
})
public class AvatarEntity {
@PrimaryKey(autoGenerate = true)
public Long id;
@NonNull public Long accountId;
@NonNull public Jid address;
@Embedded(prefix = "thumb_")
@NonNull
public AvatarThumbnail thumbnail;
public static AvatarEntity of(final Account account, final Jid address, final Info info) {
final var entity = new AvatarEntity();
entity.accountId = account.id;
entity.address = address;
entity.thumbnail = AvatarThumbnail.of(info);
return entity;
}
}

View file

@ -0,0 +1,66 @@
package im.conversations.android.database.entity;
import androidx.annotation.NonNull;
import androidx.room.Entity;
import androidx.room.ForeignKey;
import androidx.room.Index;
import androidx.room.PrimaryKey;
import eu.siacs.conversations.xmpp.Jid;
import java.time.Instant;
@Entity(
tableName = "axolotl_device_list",
foreignKeys =
@ForeignKey(
entity = AccountEntity.class,
parentColumns = {"id"},
childColumns = {"accountId"},
onDelete = ForeignKey.CASCADE),
indices = {
@Index(
value = {"accountId", "address"},
unique = true)
})
public class AxolotlDeviceListEntity {
@PrimaryKey(autoGenerate = true)
public Long id;
@NonNull public Long accountId;
@NonNull public Jid address;
@NonNull public Instant receivedAt;
public String errorCondition;
public boolean isParsingIssue;
public static AxolotlDeviceListEntity of(long accountId, final Jid address) {
final var entity = new AxolotlDeviceListEntity();
entity.accountId = accountId;
entity.address = address;
entity.receivedAt = Instant.now();
entity.isParsingIssue = false;
return entity;
}
public static AxolotlDeviceListEntity of(
final long accountId, final Jid address, final String errorCondition) {
final var entity = new AxolotlDeviceListEntity();
entity.accountId = accountId;
entity.address = address;
entity.receivedAt = Instant.now();
entity.errorCondition = errorCondition;
return entity;
}
public static AxolotlDeviceListEntity ofParsingIssue(final long account, Jid address) {
final var entity = new AxolotlDeviceListEntity();
entity.accountId = account;
entity.address = address;
entity.receivedAt = Instant.now();
entity.isParsingIssue = true;
return entity;
}
}

View file

@ -0,0 +1,38 @@
package im.conversations.android.database.entity;
import androidx.annotation.NonNull;
import androidx.room.Entity;
import androidx.room.ForeignKey;
import androidx.room.Index;
import androidx.room.PrimaryKey;
@Entity(
tableName = "axolotl_device_list_item",
foreignKeys =
@ForeignKey(
entity = AxolotlDeviceListEntity.class,
parentColumns = {"id"},
childColumns = {"deviceListId"},
onDelete = ForeignKey.CASCADE),
indices = {
@Index(value = {"deviceListId"}),
@Index(
value = {"deviceListId", "deviceId"},
unique = true)
})
public class AxolotlDeviceListItemEntity {
@PrimaryKey(autoGenerate = true)
public Long id;
@NonNull public Long deviceListId;
public Integer deviceId;
public static AxolotlDeviceListItemEntity of(final long deviceListId, final int deviceId) {
final var entity = new AxolotlDeviceListItemEntity();
entity.deviceListId = deviceListId;
entity.deviceId = deviceId;
return entity;
}
}

View file

@ -0,0 +1,47 @@
package im.conversations.android.database.entity;
import androidx.annotation.NonNull;
import androidx.room.Entity;
import androidx.room.ForeignKey;
import androidx.room.Index;
import androidx.room.PrimaryKey;
import eu.siacs.conversations.xmpp.Jid;
import im.conversations.android.database.model.Account;
import org.whispersystems.libsignal.IdentityKey;
@Entity(
tableName = "axolotl_identity",
foreignKeys =
@ForeignKey(
entity = AccountEntity.class,
parentColumns = {"id"},
childColumns = {"accountId"},
onDelete = ForeignKey.CASCADE),
indices = {
@Index(
value = {"accountId", "address", "deviceId"},
unique = true)
})
public class AxolotlIdentityEntity {
@PrimaryKey(autoGenerate = true)
public Long id;
@NonNull public Long accountId;
@NonNull public Jid address;
@NonNull public Integer deviceId;
@NonNull public IdentityKey identityKey;
public static AxolotlIdentityEntity of(
Account account, Jid address, int deviceId, IdentityKey identityKey) {
final var entity = new AxolotlIdentityEntity();
entity.accountId = account.id;
entity.address = address;
entity.deviceId = deviceId;
entity.identityKey = identityKey;
return entity;
}
}

View file

@ -0,0 +1,40 @@
package im.conversations.android.database.entity;
import androidx.annotation.NonNull;
import androidx.room.Entity;
import androidx.room.ForeignKey;
import androidx.room.Index;
import androidx.room.PrimaryKey;
import im.conversations.android.database.model.Account;
import org.whispersystems.libsignal.IdentityKeyPair;
@Entity(
tableName = "axolotl_identity_key_pair",
foreignKeys =
@ForeignKey(
entity = AccountEntity.class,
parentColumns = {"id"},
childColumns = {"accountId"},
onDelete = ForeignKey.CASCADE),
indices = {
@Index(
value = {"accountId"},
unique = true)
})
public class AxolotlIdentityKeyPairEntity {
@PrimaryKey(autoGenerate = true)
public Long id;
@NonNull public Long accountId;
@NonNull public IdentityKeyPair identityKeyPair;
public static AxolotlIdentityKeyPairEntity of(
final Account account, final IdentityKeyPair identityKeyPair) {
final var entity = new AxolotlIdentityKeyPairEntity();
entity.accountId = account.id;
entity.identityKeyPair = identityKeyPair;
return entity;
}
}

View file

@ -0,0 +1,45 @@
package im.conversations.android.database.entity;
import androidx.annotation.NonNull;
import androidx.room.Entity;
import androidx.room.ForeignKey;
import androidx.room.Index;
import androidx.room.PrimaryKey;
import im.conversations.android.database.model.Account;
import org.whispersystems.libsignal.state.PreKeyRecord;
@Entity(
tableName = "axolotl_pre_key",
foreignKeys =
@ForeignKey(
entity = AccountEntity.class,
parentColumns = {"id"},
childColumns = {"accountId"},
onDelete = ForeignKey.CASCADE),
indices = {
@Index(
value = {"accountId"},
unique = false)
})
public class AxolotlPreKeyEntity {
@PrimaryKey(autoGenerate = true)
public Long id;
@NonNull public Long accountId;
@NonNull public Integer preKeyId;
@NonNull public PreKeyRecord preKeyRecord;
public boolean removed = false;
public static AxolotlPreKeyEntity of(Account account, int preKeyId, PreKeyRecord preKeyRecord) {
final var entity = new AxolotlPreKeyEntity();
entity.accountId = account.id;
entity.preKeyId = preKeyId;
entity.preKeyRecord = preKeyRecord;
entity.removed = false;
return entity;
}
}

View file

@ -0,0 +1,47 @@
package im.conversations.android.database.entity;
import androidx.annotation.NonNull;
import androidx.room.Entity;
import androidx.room.ForeignKey;
import androidx.room.Index;
import androidx.room.PrimaryKey;
import eu.siacs.conversations.xmpp.Jid;
import im.conversations.android.database.model.Account;
import org.whispersystems.libsignal.state.SessionRecord;
@Entity(
tableName = "axolotl_session",
foreignKeys =
@ForeignKey(
entity = AccountEntity.class,
parentColumns = {"id"},
childColumns = {"accountId"},
onDelete = ForeignKey.CASCADE),
indices = {
@Index(
value = {"accountId", "address", "deviceId"},
unique = true)
})
public class AxolotlSessionEntity {
@PrimaryKey(autoGenerate = true)
public Long id;
@NonNull public Long accountId;
@NonNull public Jid address;
@NonNull public Integer deviceId;
@NonNull public SessionRecord sessionRecord;
public static AxolotlSessionEntity of(
Account account, Jid address, int deviceId, SessionRecord record) {
final var entity = new AxolotlSessionEntity();
entity.accountId = account.id;
entity.address = address;
entity.deviceId = deviceId;
entity.sessionRecord = record;
return entity;
}
}

View file

@ -0,0 +1,46 @@
package im.conversations.android.database.entity;
import androidx.annotation.NonNull;
import androidx.room.Entity;
import androidx.room.ForeignKey;
import androidx.room.Index;
import androidx.room.PrimaryKey;
import im.conversations.android.database.model.Account;
import org.whispersystems.libsignal.state.SignedPreKeyRecord;
@Entity(
tableName = "axolotl_signed_pre_key",
foreignKeys =
@ForeignKey(
entity = AccountEntity.class,
parentColumns = {"id"},
childColumns = {"accountId"},
onDelete = ForeignKey.CASCADE),
indices = {
@Index(
value = {"accountId"},
unique = false)
})
public class AxolotlSignedPreKeyEntity {
@PrimaryKey(autoGenerate = true)
public Long id;
@NonNull public Long accountId;
@NonNull public Integer signedPreKeyId;
@NonNull public SignedPreKeyRecord signedPreKeyRecord;
public boolean removed = false;
public static AxolotlSignedPreKeyEntity of(
Account account, int signedPreKeyId, SignedPreKeyRecord record) {
final var entity = new AxolotlSignedPreKeyEntity();
entity.accountId = account.id;
entity.signedPreKeyId = signedPreKeyId;
entity.signedPreKeyRecord = record;
entity.removed = false;
return entity;
}
}

View file

@ -0,0 +1,64 @@
package im.conversations.android.database.entity;
import androidx.annotation.NonNull;
import androidx.room.Entity;
import androidx.room.ForeignKey;
import androidx.room.Index;
import androidx.room.PrimaryKey;
import eu.siacs.conversations.xmpp.Jid;
import im.conversations.android.xmpp.model.bookmark.Conference;
import java.util.Map;
@Entity(
tableName = "bookmark",
foreignKeys =
@ForeignKey(
entity = AccountEntity.class,
parentColumns = {"id"},
childColumns = {"accountId"},
onDelete = ForeignKey.CASCADE),
indices = {
@Index(
value = {"accountId", "address"},
unique = true)
})
public class BookmarkEntity {
@PrimaryKey(autoGenerate = true)
public Long id;
@NonNull public Long accountId;
@NonNull public Jid address;
public String name;
public String nick;
public boolean autoJoin;
public String password;
public static BookmarkEntity of(
final long accountId, final Map.Entry<String, Conference> entry) {
final var address = jidOrNull(entry.getKey());
final var conference = entry.getValue();
if (address == null) {
return null;
}
final var entity = new BookmarkEntity();
entity.accountId = accountId;
entity.address = address;
entity.autoJoin = conference.isAutoJoin();
entity.name = conference.getConferenceName();
return entity;
}
public static Jid jidOrNull(final String address) {
try {
return address == null ? null : Jid.ofEscaped(address);
} catch (final IllegalArgumentException e) {
return null;
}
}
}

View file

@ -0,0 +1,41 @@
package im.conversations.android.database.entity;
import androidx.annotation.NonNull;
import androidx.room.Entity;
import androidx.room.ForeignKey;
import androidx.room.Index;
import androidx.room.PrimaryKey;
import eu.siacs.conversations.xmpp.Jid;
@Entity(
tableName = "nick",
foreignKeys =
@ForeignKey(
entity = AccountEntity.class,
parentColumns = {"id"},
childColumns = {"accountId"},
onDelete = ForeignKey.CASCADE),
indices = {
@Index(
value = {"accountId", "address"},
unique = true)
})
public class NickEntity {
@PrimaryKey(autoGenerate = true)
public Long id;
@NonNull public Long accountId;
@NonNull public Jid address;
public String nick;
public static NickEntity of(long account, Jid address, String nick) {
final var entity = new NickEntity();
entity.accountId = account;
entity.address = address;
entity.nick = nick;
return entity;
}
}

View file

@ -5,6 +5,7 @@ import com.google.common.base.Objects;
import com.google.common.base.Preconditions; import com.google.common.base.Preconditions;
import com.google.common.hash.Hashing; import com.google.common.hash.Hashing;
import com.google.common.io.ByteSource; import com.google.common.io.ByteSource;
import com.google.common.primitives.Ints;
import eu.siacs.conversations.xmpp.Jid; import eu.siacs.conversations.xmpp.Jid;
import im.conversations.android.IDs; import im.conversations.android.IDs;
import java.io.IOException; import java.io.IOException;
@ -51,7 +52,15 @@ public class Account {
return IDs.uuid( return IDs.uuid(
ByteSource.wrap(randomSeed).slice(0, 16).hash(Hashing.sha256()).asBytes()); ByteSource.wrap(randomSeed).slice(0, 16).hash(Hashing.sha256()).asBytes());
} catch (final IOException e) { } catch (final IOException e) {
return UUID.randomUUID(); throw new RuntimeException(e);
}
}
public int getPublicDeviceIdInt() {
try {
return Math.abs(Ints.fromByteArray(ByteSource.wrap(randomSeed).slice(0, 4).read()));
} catch (final IOException e) {
throw new RuntimeException(e);
} }
} }
} }

View file

@ -0,0 +1,18 @@
package im.conversations.android.database.model;
public abstract class AvatarBase {
public final String id;
public final String type;
public final long bytes;
public final long height;
public final long width;
public AvatarBase(String id, String type, long bytes, long height, long width) {
this.id = id;
this.type = type;
this.bytes = bytes;
this.height = height;
this.width = width;
}
}

View file

@ -0,0 +1,23 @@
package im.conversations.android.database.model;
import im.conversations.android.xmpp.model.avatar.Info;
public class AvatarExternal extends AvatarBase {
public final String url;
public AvatarExternal(String id, String type, long bytes, long height, long width, String url) {
super(id, type, bytes, height, width);
this.url = url;
}
public static AvatarExternal of(Info info) {
return new AvatarExternal(
info.getId(),
info.getType(),
info.getBytes(),
info.getHeight(),
info.getWidth(),
info.getUrl());
}
}

View file

@ -0,0 +1,15 @@
package im.conversations.android.database.model;
import im.conversations.android.xmpp.model.avatar.Info;
public class AvatarThumbnail extends AvatarBase {
public AvatarThumbnail(String id, String type, long bytes, long height, long width) {
super(id, type, bytes, height, width);
}
public static AvatarThumbnail of(Info info) {
return new AvatarThumbnail(
info.getId(), info.getType(), info.getBytes(), info.getHeight(), info.getWidth());
}
}

View file

@ -0,0 +1,24 @@
package im.conversations.android.xmpp;
import im.conversations.android.xmpp.model.error.Error;
import im.conversations.android.xmpp.model.stanza.Iq;
public class IqErrorException extends Exception {
private final Iq response;
public IqErrorException(Iq response) {
super(getErrorText(response));
this.response = response;
}
public Error getError() {
return this.response.getError();
}
private static String getErrorText(final Iq response) {
final var error = response.getError();
final var text = error == null ? null : error.getText();
return text == null ? null : text.getContent();
}
}

View file

@ -4,11 +4,15 @@ import android.content.Context;
import com.google.common.collect.ClassToInstanceMap; import com.google.common.collect.ClassToInstanceMap;
import com.google.common.collect.ImmutableClassToInstanceMap; import com.google.common.collect.ImmutableClassToInstanceMap;
import im.conversations.android.xmpp.manager.AbstractManager; import im.conversations.android.xmpp.manager.AbstractManager;
import im.conversations.android.xmpp.manager.AvatarManager;
import im.conversations.android.xmpp.manager.AxolotlManager;
import im.conversations.android.xmpp.manager.BlockingManager; import im.conversations.android.xmpp.manager.BlockingManager;
import im.conversations.android.xmpp.manager.BookmarkManager; import im.conversations.android.xmpp.manager.BookmarkManager;
import im.conversations.android.xmpp.manager.CarbonsManager; import im.conversations.android.xmpp.manager.CarbonsManager;
import im.conversations.android.xmpp.manager.DiscoManager; import im.conversations.android.xmpp.manager.DiscoManager;
import im.conversations.android.xmpp.manager.NickManager;
import im.conversations.android.xmpp.manager.PresenceManager; import im.conversations.android.xmpp.manager.PresenceManager;
import im.conversations.android.xmpp.manager.PubSubManager;
import im.conversations.android.xmpp.manager.RosterManager; import im.conversations.android.xmpp.manager.RosterManager;
public final class Managers { public final class Managers {
@ -18,11 +22,15 @@ public final class Managers {
public static ClassToInstanceMap<AbstractManager> initialize( public static ClassToInstanceMap<AbstractManager> initialize(
final Context context, final XmppConnection connection) { final Context context, final XmppConnection connection) {
return new ImmutableClassToInstanceMap.Builder<AbstractManager>() return new ImmutableClassToInstanceMap.Builder<AbstractManager>()
.put(AvatarManager.class, new AvatarManager(context, connection))
.put(AxolotlManager.class, new AxolotlManager(context, connection))
.put(BlockingManager.class, new BlockingManager(context, connection)) .put(BlockingManager.class, new BlockingManager(context, connection))
.put(BookmarkManager.class, new BookmarkManager(context, connection)) .put(BookmarkManager.class, new BookmarkManager(context, connection))
.put(CarbonsManager.class, new CarbonsManager(context, connection)) .put(CarbonsManager.class, new CarbonsManager(context, connection))
.put(DiscoManager.class, new DiscoManager(context, connection)) .put(DiscoManager.class, new DiscoManager(context, connection))
.put(NickManager.class, new NickManager(context, connection))
.put(PresenceManager.class, new PresenceManager(context, connection)) .put(PresenceManager.class, new PresenceManager(context, connection))
.put(PubSubManager.class, new PubSubManager(context, connection))
.put(RosterManager.class, new RosterManager(context, connection)) .put(RosterManager.class, new RosterManager(context, connection))
.build(); .build();
} }

View file

@ -2019,8 +2019,7 @@ public class XmppConnection implements Runnable {
} else if (type == Iq.Type.TIMEOUT) { } else if (type == Iq.Type.TIMEOUT) {
future.setException(new TimeoutException()); future.setException(new TimeoutException());
} else { } else {
// TODO some sort of IqErrorException future.setException(new IqErrorException(result));
future.setException(new IOException());
} }
}); });
return future; return future;

View file

@ -0,0 +1,31 @@
package im.conversations.android.xmpp.axolotl;
import com.google.common.base.Preconditions;
import eu.siacs.conversations.xmpp.Jid;
import org.whispersystems.libsignal.SignalProtocolAddress;
public class AxolotlAddress extends SignalProtocolAddress {
private final Jid jid;
public AxolotlAddress(final Jid jid, int deviceId) {
super(jid.toEscapedString(), deviceId);
Preconditions.checkArgument(jid.isBareJid(), "AxolotlAddresses must use bare JIDs");
this.jid = jid;
}
public Jid getJid() {
return this.jid;
}
public static AxolotlAddress cast(final SignalProtocolAddress signalProtocolAddress) {
if (signalProtocolAddress instanceof AxolotlAddress) {
return (AxolotlAddress) signalProtocolAddress;
}
throw new IllegalArgumentException(
String.format(
"This %s is not a %s",
SignalProtocolAddress.class.getSimpleName(),
AxolotlAddress.class.getSimpleName()));
}
}

View file

@ -2,9 +2,13 @@ package im.conversations.android.xmpp.manager;
import android.content.Context; import android.content.Context;
import im.conversations.android.xmpp.XmppConnection; import im.conversations.android.xmpp.XmppConnection;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
public class AbstractManager extends XmppConnection.Delegate { public class AbstractManager extends XmppConnection.Delegate {
protected static final Executor IO_EXECUTOR = Executors.newSingleThreadExecutor();
protected AbstractManager(final Context context, final XmppConnection connection) { protected AbstractManager(final Context context, final XmppConnection connection) {
super(context, connection); super(context, connection);
} }

View file

@ -0,0 +1,162 @@
package im.conversations.android.xmpp.manager;
import android.content.Context;
import com.google.common.collect.Collections2;
import com.google.common.collect.Iterables;
import com.google.common.hash.Hashing;
import com.google.common.io.Files;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import com.google.common.util.concurrent.SettableFuture;
import eu.siacs.conversations.xmpp.Jid;
import im.conversations.android.xmpp.XmppConnection;
import im.conversations.android.xmpp.model.avatar.Data;
import im.conversations.android.xmpp.model.avatar.Info;
import im.conversations.android.xmpp.model.avatar.Metadata;
import im.conversations.android.xmpp.model.pubsub.Items;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class AvatarManager extends AbstractManager {
private static final Logger LOGGER = LoggerFactory.getLogger(AvatarManager.class);
private final Map<Fetch, ListenableFuture<byte[]>> avatarFetches = new HashMap<>();
public AvatarManager(Context context, XmppConnection connection) {
super(context, connection);
}
public void handleItems(final Jid from, final Items items) {
final var itemsMap = items.getItemMap(Metadata.class);
final var firstEntry = Iterables.getFirst(itemsMap.entrySet(), null);
if (firstEntry == null) {
return;
}
final var itemId = firstEntry.getKey();
final var metadata = firstEntry.getValue();
final var info = metadata.getExtensions(Info.class);
final var thumbnailOptional =
Iterables.tryFind(info, i -> Objects.equals(itemId, i.getId()));
if (thumbnailOptional.isPresent()) {
final var thumbnail = thumbnailOptional.get();
if (thumbnail.getUrl() != null) {
LOGGER.warn(
"Thumbnail avatar from {} is hosted on remote URL. We require it to be"
+ " hosted on PEP",
from);
return;
}
final var additional =
Collections2.filter(
info,
i -> !Objects.equals(itemId, i.getId()) && Objects.nonNull(i.getUrl()));
getDatabase().avatarDao().set(getAccount(), from.asBareJid(), thumbnail, additional);
} else {
LOGGER.warn(
"Avatar metadata from {} is lacking thumbnail (info.id must match item id",
from);
}
}
public ListenableFuture<byte[]> getAvatar(final Jid address, final String id) {
final var fetch = new Fetch(address, id);
final SettableFuture<byte[]> future;
synchronized (avatarFetches) {
final var existing = avatarFetches.get(fetch);
if (existing != null) {
return existing;
}
future = SettableFuture.create();
avatarFetches.put(fetch, future);
}
future.setFuture(getCachedOrFetch(address, id));
future.addListener(
() -> {
synchronized (this.avatarFetches) {
this.avatarFetches.remove(fetch);
}
},
MoreExecutors.directExecutor());
return future;
}
private byte[] getCachedAvatar(final Jid address, final String id) throws IOException {
final var cache = getCacheFile(address, id);
final byte[] avatar = Files.toByteArray(cache);
if (Hashing.sha1().hashBytes(avatar).toString().equalsIgnoreCase(id)) {
LOGGER.debug("Avatar {} of {} came from cache", id, address);
return avatar;
} else {
throw new IllegalStateException("Cache contained corrupted file");
}
}
private ListenableFuture<byte[]> getCachedOrFetch(final Jid address, final String id) {
final var cachedFuture = Futures.submit(() -> getCachedAvatar(address, id), IO_EXECUTOR);
return Futures.catchingAsync(
cachedFuture,
Exception.class,
exception -> fetchAndCacheAvatar(address, id),
MoreExecutors.directExecutor());
}
private ListenableFuture<byte[]> fetchAvatar(final Jid address, final String id) {
return Futures.transform(
getManager(PubSubManager.class).fetchItem(address, id, Data.class),
Data::asBytes,
MoreExecutors.directExecutor());
}
private ListenableFuture<byte[]> fetchAndCacheAvatar(final Jid address, final String id) {
return Futures.transform(
fetchAvatar(address, id),
avatar -> {
final var sha1Hash = Hashing.sha1().hashBytes(avatar).toString();
if (sha1Hash.equalsIgnoreCase(id)) {
final var cache = getCacheFile(address, id);
try {
Files.write(avatar, cache);
} catch (final IOException e) {
throw new RuntimeException("Could not store avatar", e);
}
LOGGER.info("Cached avatar {} from {}", id, address);
return avatar;
}
throw new IllegalStateException("Avatar sha1hash did not match expected value");
},
IO_EXECUTOR);
}
private File getCacheFile(final Jid address, final String id) {
final var accountCacheDirectory =
new File(context.getCacheDir(), String.valueOf(getAccount().id));
final var userCacheDirectory =
new File(
accountCacheDirectory,
Hashing.sha256()
.hashString(address.toEscapedString(), StandardCharsets.UTF_8)
.toString());
if (userCacheDirectory.mkdirs()) {
LOGGER.debug("Created directory {}", userCacheDirectory.getAbsolutePath());
}
return new File(userCacheDirectory, id);
}
private static final class Fetch {
public final Jid address;
public final String id;
private Fetch(Jid address, String id) {
this.address = address;
this.id = id;
}
}
}

View file

@ -0,0 +1,281 @@
package im.conversations.android.xmpp.manager;
import android.content.Context;
import androidx.annotation.NonNull;
import com.google.common.collect.ImmutableSet;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xmpp.Jid;
import im.conversations.android.database.AxolotlDatabaseStore;
import im.conversations.android.xmpp.IqErrorException;
import im.conversations.android.xmpp.XmppConnection;
import im.conversations.android.xmpp.axolotl.AxolotlAddress;
import im.conversations.android.xmpp.model.axolotl.Bundle;
import im.conversations.android.xmpp.model.axolotl.DeviceList;
import im.conversations.android.xmpp.model.pubsub.Items;
import java.util.Collection;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.TimeoutException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.libsignal.IdentityKey;
import org.whispersystems.libsignal.InvalidKeyException;
import org.whispersystems.libsignal.SessionBuilder;
import org.whispersystems.libsignal.SessionCipher;
import org.whispersystems.libsignal.UntrustedIdentityException;
import org.whispersystems.libsignal.state.PreKeyBundle;
import org.whispersystems.libsignal.state.SignalProtocolStore;
import org.whispersystems.libsignal.state.SignedPreKeyRecord;
import org.whispersystems.libsignal.util.KeyHelper;
public class AxolotlManager extends AbstractManager {
private static final Logger LOGGER = LoggerFactory.getLogger(AxolotlManager.class);
private static final int NUM_PRE_KEYS_IN_BUNDLE = 30;
private final SignalProtocolStore signalProtocolStore;
public AxolotlManager(Context context, XmppConnection connection) {
super(context, connection);
this.signalProtocolStore = new AxolotlDatabaseStore(context, connection.getAccount());
}
public void handleItems(final Jid from, final Items items) {
final var deviceList = items.getFirstItem(DeviceList.class);
if (from == null || deviceList == null) {
return;
}
final var deviceIds = deviceList.getDeviceIds();
LOGGER.info("Received {} from {}", deviceIds, from);
getDatabase().axolotlDao().setDeviceList(getAccount(), from, deviceIds);
}
public ListenableFuture<Set<Integer>> fetchDeviceIds(final Jid address) {
final var deviceIdsFuture =
Futures.transform(
getManager(PubSubManager.class)
.fetchMostRecentItem(
address, Namespace.AXOLOTL_DEVICE_LIST, DeviceList.class),
DeviceList::getDeviceIds,
MoreExecutors.directExecutor());
// TODO refactor callback into class
Futures.addCallback(
deviceIdsFuture,
new FutureCallback<>() {
@Override
public void onSuccess(Set<Integer> deviceIds) {
getDatabase().axolotlDao().setDeviceList(getAccount(), address, deviceIds);
}
@Override
public void onFailure(@NonNull Throwable throwable) {
if (throwable instanceof TimeoutException) {
return;
}
if (throwable instanceof IqErrorException) {
final var iqErrorException = (IqErrorException) throwable;
final var error = iqErrorException.getError();
final var condition = error == null ? null : error.getCondition();
if (condition != null) {
getDatabase()
.axolotlDao()
.setDeviceListError(getAccount(), address, condition);
return;
}
}
getDatabase().axolotlDao().setDeviceListParsingError(getAccount(), address);
}
},
MoreExecutors.directExecutor());
return deviceIdsFuture;
}
public ListenableFuture<Bundle> fetchBundle(final Jid address, final int deviceId) {
final var node = String.format(Locale.ROOT, "%s:%d", Namespace.AXOLOTL_BUNDLES, deviceId);
return getManager(PubSubManager.class).fetchMostRecentItem(address, node, Bundle.class);
}
public ListenableFuture<SessionCipher> getOrCreateSessionCipher(
final AxolotlAddress axolotlAddress) {
if (signalProtocolStore.containsSession(axolotlAddress)) {
return Futures.immediateFuture(new SessionCipher(signalProtocolStore, axolotlAddress));
} else {
final var bundleFuture =
fetchBundle(axolotlAddress.getJid(), axolotlAddress.getDeviceId());
return Futures.transform(
bundleFuture,
bundle -> {
buildSession(axolotlAddress, bundle);
return new SessionCipher(signalProtocolStore, axolotlAddress);
},
MoreExecutors.directExecutor());
}
}
private void buildSession(final AxolotlAddress address, final Bundle bundle) {
final var sessionBuilder = new SessionBuilder(signalProtocolStore, address);
final var deviceId = address.getDeviceId();
final var preKey = bundle.getRandomPreKey();
final var signedPreKey = bundle.getSignedPreKey();
final var signedPreKeySignature = bundle.getSignedPreKeySignature();
final var identityKey = bundle.getIdentityKey();
if (preKey == null) {
throw new IllegalArgumentException("No PreKey found in bundle");
}
if (signedPreKey == null) {
throw new IllegalArgumentException("No signed PreKey found in bundle");
}
if (signedPreKeySignature == null) {
throw new IllegalArgumentException("No signed PreKey signature found in bundle");
}
if (identityKey == null) {
throw new IllegalArgumentException("No IdentityKey found in bundle");
}
final var preKeyBundle =
new PreKeyBundle(
0,
deviceId,
preKey.getId(),
preKey.asECPublicKey(),
signedPreKey.getId(),
signedPreKey.asECPublicKey(),
signedPreKeySignature.asBytes(),
new IdentityKey(identityKey.asECPublicKey()));
try {
sessionBuilder.process(preKeyBundle);
} catch (final InvalidKeyException | UntrustedIdentityException e) {
throw new RuntimeException(e);
}
}
public void publishIfNecessary() {
final int myDeviceId = getAccount().getPublicDeviceIdInt();
if (getDatabase()
.axolotlDao()
.hasDeviceId(getAccount().id, getAccount().address, myDeviceId)
&& getManager(DiscoManager.class)
.hasAccountFeature(Namespace.PUB_SUB_PERSISTENT_ITEMS)) {
LOGGER.info(
"device id seems to be current and server supports persistent items. nothing"
+ " to do");
return;
}
final var future = publishBundleAndDeviceId();
Futures.addCallback(
future,
new FutureCallback<>() {
@Override
public void onSuccess(Void result) {
LOGGER.info("Successfully publish bundle and device ID {}", myDeviceId);
}
@Override
public void onFailure(@NonNull Throwable throwable) {
LOGGER.warn(
"Could not publish bundle and device ID for account {} ",
getAccount().address,
throwable);
}
},
MoreExecutors.directExecutor());
}
private ListenableFuture<Void> publishBundleAndDeviceId() {
final ListenableFuture<Void> bundleFuture = publishBundle();
return Futures.transformAsync(
bundleFuture, ignored -> publishDeviceId(), MoreExecutors.directExecutor());
}
private ListenableFuture<Void> publishDeviceId() {
final var currentDeviceIdsFuture = fetchDeviceIds(getAccount().address);
return Futures.transformAsync(
currentDeviceIdsFuture,
currentDeviceIds -> {
final var myDeviceId = getAccount().getPublicDeviceIdInt();
if (currentDeviceIds.contains(myDeviceId)) {
return Futures.immediateVoidFuture();
} else {
final var deviceIds =
new ImmutableSet.Builder<Integer>()
.addAll(currentDeviceIds)
.add(myDeviceId)
.build();
return publishDeviceIds(deviceIds);
}
},
MoreExecutors.directExecutor());
}
private ListenableFuture<Void> publishDeviceIds(final Collection<Integer> deviceIds) {
final var deviceList = new DeviceList();
deviceList.setDeviceIds(deviceIds);
return getManager(PubSubManager.class)
.publishSingleton(getAccount().address, deviceList, Namespace.AXOLOTL_DEVICE_LIST);
}
private ListenableFuture<Void> publishBundle() {
final ListenableFuture<Bundle> bundleFuture =
Futures.submit(this::prepareBundle, IO_EXECUTOR);
return Futures.transformAsync(
bundleFuture,
bundle -> {
final var node =
String.format(
Locale.ROOT,
"%s:%d",
Namespace.AXOLOTL_BUNDLES,
signalProtocolStore.getLocalRegistrationId());
return getManager(PubSubManager.class)
.publishSingleton(getAccount().address, bundle, node);
},
MoreExecutors.directExecutor());
}
private Bundle prepareBundle() {
refillPreKeys();
final var bundle = new Bundle();
bundle.setIdentityKey(
signalProtocolStore.getIdentityKeyPair().getPublicKey().getPublicKey());
final var signedPreKeyRecord =
getDatabase().axolotlDao().getLatestSignedPreKey(getAccount().id);
if (signedPreKeyRecord == null) {
throw new IllegalStateException("No signed PreKeys have been created yet");
}
bundle.setSignedPreKey(
signedPreKeyRecord.getKeyPair().getPublicKey(), signedPreKeyRecord.getSignature());
bundle.setPreKeys(getDatabase().axolotlDao().getPreKeys(getAccount().id));
return bundle;
}
private void refillPreKeys() {
final var accountId = getAccount().id;
final var axolotlDao = getDatabase().axolotlDao();
final var existing = axolotlDao.getExistingPreKeyCount(accountId);
final var max = axolotlDao.getMaxPreKeyId(accountId);
final var count = NUM_PRE_KEYS_IN_BUNDLE - existing;
final int start = max == null ? 0 : max + 1;
final var preKeys = KeyHelper.generatePreKeys(start, count);
final int signedPreKeyId = (start + count) / NUM_PRE_KEYS_IN_BUNDLE - 1;
if (getDatabase().axolotlDao().hasNotSignedPreKey(getAccount().id, signedPreKeyId)) {
final SignedPreKeyRecord signedPreKeyRecord;
try {
signedPreKeyRecord =
KeyHelper.generateSignedPreKey(
signalProtocolStore.getIdentityKeyPair(), signedPreKeyId);
} catch (final InvalidKeyException e) {
throw new IllegalStateException("Could not generate SignedPreKey", e);
}
signalProtocolStore.storeSignedPreKey(signedPreKeyRecord.getId(), signedPreKeyRecord);
LOGGER.info("Generated SignedPreKey #{}", signedPreKeyRecord.getId());
}
axolotlDao.setPreKeys(getAccount(), preKeys);
if (count > 0) {
LOGGER.info("Generated {} PreKeys starting with {}", preKeys.size(), start);
}
}
}

View file

@ -1,12 +1,74 @@
package im.conversations.android.xmpp.manager; package im.conversations.android.xmpp.manager;
import android.content.Context; import android.content.Context;
import androidx.annotation.NonNull;
import com.google.common.collect.Collections2;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.MoreExecutors;
import eu.siacs.conversations.xmpp.Jid;
import im.conversations.android.database.entity.BookmarkEntity;
import im.conversations.android.xmpp.XmppConnection; import im.conversations.android.xmpp.XmppConnection;
import im.conversations.android.xmpp.model.bookmark.Conference;
import im.conversations.android.xmpp.model.pubsub.Items;
import im.conversations.android.xmpp.model.pubsub.event.Retract;
import java.util.Collection;
import java.util.Map;
import java.util.Objects;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class BookmarkManager extends AbstractManager { public class BookmarkManager extends AbstractManager {
private static final Logger LOGGER = LoggerFactory.getLogger(BookmarkManager.class);
public BookmarkManager(Context context, XmppConnection connection) { public BookmarkManager(Context context, XmppConnection connection) {
super(context, connection); super(context, connection);
} }
public void fetch() {} public void fetch() {
final var future =
getManager(PubSubManager.class).fetchItems(getAccount().address, Conference.class);
Futures.addCallback(
future,
new FutureCallback<>() {
@Override
public void onSuccess(final Map<String, Conference> bookmarks) {
getDatabase().bookmarkDao().setItems(getAccount(), bookmarks);
}
@Override
public void onFailure(@NonNull final Throwable throwable) {
LOGGER.warn("Could not fetch bookmarks", throwable);
}
},
MoreExecutors.directExecutor());
}
private void updateItems(final Map<String, Conference> items) {
getDatabase().bookmarkDao().updateItems(getAccount(), items);
}
private void deleteItems(Collection<Retract> retractions) {
final Collection<Jid> addresses =
Collections2.transform(retractions, r -> BookmarkEntity.jidOrNull(r.getId()));
getDatabase()
.bookmarkDao()
.delete(getAccount().id, Collections2.filter(addresses, Objects::nonNull));
}
public void deleteAllItems() {
getDatabase().bookmarkDao().deleteAll(getAccount().id);
}
public void handleItems(final Items items) {
final var retractions = items.getRetractions();
final var itemMap = items.getItemMap(Conference.class);
if (retractions.size() > 0) {
deleteItems(retractions);
}
if (itemMap.size() > 0) {
updateItems(itemMap);
}
}
} }

View file

@ -73,7 +73,11 @@ public class DiscoManager extends AbstractManager {
Collections.singleton(Namespace.VERSION); Collections.singleton(Namespace.VERSION);
private static final Collection<String> FEATURES_NOTIFY = private static final Collection<String> FEATURES_NOTIFY =
Arrays.asList(Namespace.NICK, Namespace.AVATAR_METADATA, Namespace.BOOKMARKS2); Arrays.asList(
Namespace.NICK,
Namespace.AVATAR_METADATA,
Namespace.BOOKMARKS2,
Namespace.AXOLOTL_DEVICE_LIST);
public DiscoManager(Context context, XmppConnection connection) { public DiscoManager(Context context, XmppConnection connection) {
super(context, connection); super(context, connection);
@ -240,6 +244,10 @@ public class DiscoManager extends AbstractManager {
return getDatabase().discoDao().hasFeature(getAccount().id, entity, feature); return getDatabase().discoDao().hasFeature(getAccount().id, entity, feature);
} }
public boolean hasAccountFeature(final String feature) {
return hasFeature(getAccount().address, feature);
}
public boolean hasServerFeature(final String feature) { public boolean hasServerFeature(final String feature) {
return hasFeature(getAccount().address.getDomain(), feature); return hasFeature(getAccount().address.getDomain(), feature);
} }

View file

@ -0,0 +1,28 @@
package im.conversations.android.xmpp.manager;
import android.content.Context;
import com.google.common.base.Strings;
import eu.siacs.conversations.xmpp.Jid;
import im.conversations.android.xmpp.XmppConnection;
import im.conversations.android.xmpp.model.nick.Nick;
import im.conversations.android.xmpp.model.pubsub.Items;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class NickManager extends AbstractManager {
private static final Logger LOGGER = LoggerFactory.getLogger(NickManager.class);
public NickManager(Context context, XmppConnection connection) {
super(context, connection);
}
public void handleItems(final Jid from, Items items) {
final var item = items.getFirstItem(Nick.class);
final var nick = item == null ? null : item.getContent();
if (from == null || Strings.isNullOrEmpty(nick)) {
return;
}
getDatabase().nickDao().set(getAccount(), from.asBareJid(), nick);
}
}

View file

@ -0,0 +1,197 @@
package im.conversations.android.xmpp.manager;
import android.content.Context;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xmpp.Jid;
import im.conversations.android.xmpp.ExtensionFactory;
import im.conversations.android.xmpp.XmppConnection;
import im.conversations.android.xmpp.model.Extension;
import im.conversations.android.xmpp.model.pubsub.Items;
import im.conversations.android.xmpp.model.pubsub.PubSub;
import im.conversations.android.xmpp.model.pubsub.event.Event;
import im.conversations.android.xmpp.model.pubsub.event.Purge;
import im.conversations.android.xmpp.model.stanza.Iq;
import im.conversations.android.xmpp.model.stanza.Message;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class PubSubManager extends AbstractManager {
private static final Logger LOGGER = LoggerFactory.getLogger(PubSubManager.class);
private static final String SINGLETON_ITEM_ID = "current";
public PubSubManager(Context context, XmppConnection connection) {
super(context, connection);
}
public void handleEvent(final Message message) {
final var event = message.getExtension(Event.class);
if (event.hasExtension(Purge.class)) {
handlePurge(message);
} else if (event.hasExtension(Event.ItemsWrapper.class)) {
handleItems(message);
}
}
public <T extends Extension> ListenableFuture<Map<String, T>> fetchItems(
final Jid address, final Class<T> clazz) {
final var id = ExtensionFactory.id(clazz);
if (id == null) {
return Futures.immediateFailedFuture(
new IllegalArgumentException(
String.format("%s is not a registered extension", clazz.getName())));
}
return fetchItems(address, id.namespace, clazz);
}
public <T extends Extension> ListenableFuture<Map<String, T>> fetchItems(
final Jid address, final String node, final Class<T> clazz) {
final Iq request = new Iq(Iq.Type.GET);
request.setTo(address);
final var pubSub = request.addExtension(new PubSub());
final var itemsWrapper = pubSub.addExtension(new PubSub.ItemsWrapper());
itemsWrapper.setNode(node);
return Futures.transform(
connection.sendIqPacket(request),
response -> {
final var pubSubResponse = response.getExtension(PubSub.class);
if (pubSubResponse == null) {
throw new IllegalStateException();
}
final var items = pubSubResponse.getItems();
if (items == null) {
throw new IllegalStateException();
}
return items.getItemMap(clazz);
},
MoreExecutors.directExecutor());
}
public <T extends Extension> ListenableFuture<T> fetchItem(
final Jid address, final String itemId, final Class<T> clazz) {
final var id = ExtensionFactory.id(clazz);
if (id == null) {
return Futures.immediateFailedFuture(
new IllegalArgumentException(
String.format("%s is not a registered extension", clazz.getName())));
}
return fetchItem(address, id.namespace, itemId, clazz);
}
public <T extends Extension> ListenableFuture<T> fetchItem(
final Jid address, final String node, final String itemId, final Class<T> clazz) {
final Iq request = new Iq(Iq.Type.GET);
request.setTo(address);
final var pubSub = request.addExtension(new PubSub());
final var itemsWrapper = pubSub.addExtension(new PubSub.ItemsWrapper());
itemsWrapper.setNode(node);
final var item = itemsWrapper.addExtension(new PubSub.Item());
item.setId(itemId);
return Futures.transform(
connection.sendIqPacket(request),
response -> {
final var pubSubResponse = response.getExtension(PubSub.class);
if (pubSubResponse == null) {
throw new IllegalStateException();
}
final var items = pubSubResponse.getItems();
if (items == null) {
throw new IllegalStateException();
}
return items.getItemOrThrow(itemId, clazz);
},
MoreExecutors.directExecutor());
}
public <T extends Extension> ListenableFuture<T> fetchMostRecentItem(
final Jid address, final String node, final Class<T> clazz) {
final Iq request = new Iq(Iq.Type.GET);
request.setTo(address);
final var pubSub = request.addExtension(new PubSub());
final var itemsWrapper = pubSub.addExtension(new PubSub.ItemsWrapper());
itemsWrapper.setNode(node);
itemsWrapper.setMaxItems(1);
return Futures.transform(
connection.sendIqPacket(request),
response -> {
final var pubSubResponse = response.getExtension(PubSub.class);
if (pubSubResponse == null) {
throw new IllegalStateException();
}
final var items = pubSubResponse.getItems();
if (items == null) {
throw new IllegalStateException();
}
return items.getOnlyItem(clazz);
},
MoreExecutors.directExecutor());
}
private void handleItems(final Message message) {
final var from = message.getFrom();
final var event = message.getExtension(Event.class);
final Items items = event.getItems();
final var node = items.getNode();
if (connection.fromAccount(message) && Namespace.BOOKMARKS2.equals(node)) {
getManager(BookmarkManager.class).handleItems(items);
return;
}
if (Namespace.AVATAR_METADATA.equals(node)) {
getManager(AvatarManager.class).handleItems(from, items);
return;
}
if (Namespace.NICK.equals(node)) {
getManager(NickManager.class).handleItems(from, items);
return;
}
if (Namespace.AXOLOTL_DEVICE_LIST.equals(node)) {
getManager(AxolotlManager.class).handleItems(from, items);
}
}
private void handlePurge(final Message message) {
final var from = message.getFrom();
final var event = message.getExtension(Event.class);
final var purge = event.getPurge();
final var node = purge.getNode();
if (connection.fromAccount(message) && Namespace.BOOKMARKS2.equals(node)) {
getManager(BookmarkManager.class).deleteAllItems();
}
}
public ListenableFuture<Void> publishSingleton(Jid address, Extension item) {
final var id = ExtensionFactory.id(item.getClass());
return publish(address, item, SINGLETON_ITEM_ID, id.namespace);
}
public ListenableFuture<Void> publishSingleton(Jid address, Extension item, final String node) {
return publish(address, item, SINGLETON_ITEM_ID, node);
}
public ListenableFuture<Void> publish(Jid address, Extension item, final String itemId) {
final var id = ExtensionFactory.id(item.getClass());
return publish(address, item, itemId, id.namespace);
}
public ListenableFuture<Void> publish(
final Jid address,
final Extension itemPayload,
final String itemId,
final String node) {
final var iq = new Iq(Iq.Type.SET);
iq.setTo(address);
final var pubSub = iq.addExtension(new PubSub());
final var pubSubItemsWrapper = pubSub.addExtension(new PubSub.ItemsWrapper());
pubSubItemsWrapper.setNode(node);
final var item = pubSubItemsWrapper.addExtension(new PubSub.Item());
item.setId(itemId);
item.addExtension(itemPayload);
return Futures.transform(
connection.sendIqPacket(iq), result -> null, MoreExecutors.directExecutor());
}
}

View file

@ -0,0 +1,32 @@
package im.conversations.android.xmpp.model;
import com.google.common.base.CharMatcher;
import com.google.common.base.Strings;
import com.google.common.io.BaseEncoding;
import eu.siacs.conversations.xml.Element;
public interface ByteContent {
String getContent();
default byte[] asBytes() {
final var content = this.getContent();
if (Strings.isNullOrEmpty(content)) {
throw new IllegalStateException(
String.format("%s element is lacking content", getClass().getName()));
}
final var contentCleaned = CharMatcher.whitespace().removeFrom(content);
if (BaseEncoding.base64().canDecode(contentCleaned)) {
return BaseEncoding.base64().decode(contentCleaned);
} else {
throw new IllegalStateException(
String.format("%s element contains invalid base64", getClass().getName()));
}
}
default void setContent(final byte[] bytes) {
setContent(BaseEncoding.base64().encode(bytes));
}
Element setContent(final String content);
}

View file

@ -0,0 +1,14 @@
package im.conversations.android.xmpp.model.avatar;
import eu.siacs.conversations.xml.Namespace;
import im.conversations.android.annotation.XmlElement;
import im.conversations.android.xmpp.model.ByteContent;
import im.conversations.android.xmpp.model.Extension;
@XmlElement(namespace = Namespace.AVATAR_DATA)
public class Data extends Extension implements ByteContent {
public Data() {
super(Data.class);
}
}

View file

@ -0,0 +1,37 @@
package im.conversations.android.xmpp.model.avatar;
import eu.siacs.conversations.xml.Namespace;
import im.conversations.android.annotation.XmlElement;
import im.conversations.android.xmpp.model.Extension;
@XmlElement(namespace = Namespace.AVATAR_METADATA)
public class Info extends Extension {
public Info() {
super(Info.class);
}
public long getHeight() {
return this.getLongAttribute("height");
}
public long getWidth() {
return this.getLongAttribute("width");
}
public long getBytes() {
return this.getLongAttribute("bytes");
}
public String getType() {
return this.getAttribute("type");
}
public String getUrl() {
return this.getAttribute("url");
}
public String getId() {
return this.getAttribute("id");
}
}

View file

@ -0,0 +1,13 @@
package im.conversations.android.xmpp.model.avatar;
import eu.siacs.conversations.xml.Namespace;
import im.conversations.android.annotation.XmlElement;
import im.conversations.android.xmpp.model.Extension;
@XmlElement(namespace = Namespace.AVATAR_METADATA)
public class Metadata extends Extension {
public Metadata() {
super(Metadata.class);
}
}

View file

@ -0,0 +1,58 @@
package im.conversations.android.xmpp.model.axolotl;
import com.google.common.collect.Iterables;
import im.conversations.android.annotation.XmlElement;
import im.conversations.android.xmpp.model.Extension;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import org.whispersystems.libsignal.ecc.ECPublicKey;
import org.whispersystems.libsignal.state.PreKeyRecord;
@XmlElement
public class Bundle extends Extension {
public Bundle() {
super(Bundle.class);
}
public SignedPreKey getSignedPreKey() {
return this.getExtension(SignedPreKey.class);
}
public SignedPreKeySignature getSignedPreKeySignature() {
return this.getExtension(SignedPreKeySignature.class);
}
public IdentityKey getIdentityKey() {
return this.getExtension(IdentityKey.class);
}
public PreKey getRandomPreKey() {
final var preKeys = this.getExtension(PreKeys.class);
final Collection<PreKey> preKeyList =
preKeys == null ? Collections.emptyList() : preKeys.getExtensions(PreKey.class);
return Iterables.get(preKeyList, (int) (preKeyList.size() * Math.random()), null);
}
public void setIdentityKey(final ECPublicKey ecPublicKey) {
final var identityKey = this.addExtension(new IdentityKey());
identityKey.setContent(ecPublicKey);
}
public void setSignedPreKey(final ECPublicKey ecPublicKey, final byte[] signature) {
final var signedPreKey = this.addExtension(new SignedPreKey());
signedPreKey.setContent(ecPublicKey);
final var signedPreKeySignature = this.addExtension(new SignedPreKeySignature());
signedPreKeySignature.setContent(signature);
}
public void setPreKeys(final List<PreKeyRecord> preKeyRecords) {
final var preKeys = this.addExtension(new PreKeys());
for (final PreKeyRecord preKeyRecord : preKeyRecords) {
final var preKey = preKeys.addExtension(new PreKey());
preKey.setId(preKeyRecord.getId());
preKey.setContent(preKeyRecord.getKeyPair().getPublicKey());
}
}
}

View file

@ -0,0 +1,22 @@
package im.conversations.android.xmpp.model.axolotl;
import com.google.common.base.Strings;
import com.google.common.primitives.Ints;
import im.conversations.android.annotation.XmlElement;
import im.conversations.android.xmpp.model.Extension;
@XmlElement
public class Device extends Extension {
public Device() {
super(Device.class);
}
public Integer getDeviceId() {
return Ints.tryParse(Strings.nullToEmpty(this.getAttribute("id")));
}
public void setDeviceId(int deviceId) {
this.setAttribute("id", deviceId);
}
}

View file

@ -0,0 +1,35 @@
package im.conversations.android.xmpp.model.axolotl;
import com.google.common.collect.Collections2;
import com.google.common.collect.ImmutableSet;
import im.conversations.android.annotation.XmlElement;
import im.conversations.android.xmpp.model.Extension;
import java.util.Collection;
import java.util.Objects;
import java.util.Set;
@XmlElement(name = "list")
public class DeviceList extends Extension {
public DeviceList() {
super(DeviceList.class);
}
public Collection<Device> getDevices() {
return this.getExtensions(Device.class);
}
public Set<Integer> getDeviceIds() {
return ImmutableSet.copyOf(
Collections2.filter(
Collections2.transform(getDevices(), Device::getDeviceId),
Objects::nonNull));
}
public void setDeviceIds(Collection<Integer> deviceIds) {
for (final Integer deviceId : deviceIds) {
final var device = this.addExtension(new Device());
device.setDeviceId(deviceId);
}
}
}

View file

@ -0,0 +1,23 @@
package im.conversations.android.xmpp.model.axolotl;
import im.conversations.android.xmpp.model.ByteContent;
import org.whispersystems.libsignal.InvalidKeyException;
import org.whispersystems.libsignal.ecc.Curve;
import org.whispersystems.libsignal.ecc.ECPublicKey;
public interface ECPublicKeyContent extends ByteContent {
default ECPublicKey asECPublicKey() {
try {
return Curve.decodePoint(asBytes(), 0);
} catch (InvalidKeyException e) {
throw new IllegalStateException(
String.format("%s does not contain a valid ECPublicKey", getClass().getName()),
e);
}
}
default void setContent(final ECPublicKey ecPublicKey) {
setContent(ecPublicKey.serialize());
}
}

View file

@ -0,0 +1,12 @@
package im.conversations.android.xmpp.model.axolotl;
import im.conversations.android.annotation.XmlElement;
import im.conversations.android.xmpp.model.Extension;
@XmlElement(name = "identityKey")
public class IdentityKey extends Extension implements ECPublicKeyContent {
public IdentityKey() {
super(IdentityKey.class);
}
}

View file

@ -0,0 +1,21 @@
package im.conversations.android.xmpp.model.axolotl;
import com.google.common.primitives.Ints;
import im.conversations.android.annotation.XmlElement;
import im.conversations.android.xmpp.model.Extension;
@XmlElement(name = "preKeyPublic")
public class PreKey extends Extension implements ECPublicKeyContent {
public PreKey() {
super(PreKey.class);
}
public int getId() {
return Ints.saturatedCast(this.getLongAttribute("preKeyId"));
}
public void setId(int id) {
this.setAttribute("id", id);
}
}

View file

@ -0,0 +1,12 @@
package im.conversations.android.xmpp.model.axolotl;
import im.conversations.android.annotation.XmlElement;
import im.conversations.android.xmpp.model.Extension;
@XmlElement(name = "prekeys")
public class PreKeys extends Extension {
public PreKeys() {
super(PreKeys.class);
}
}

View file

@ -0,0 +1,17 @@
package im.conversations.android.xmpp.model.axolotl;
import com.google.common.primitives.Ints;
import im.conversations.android.annotation.XmlElement;
import im.conversations.android.xmpp.model.Extension;
@XmlElement(name = "signedPreKeyPublic")
public class SignedPreKey extends Extension implements ECPublicKeyContent {
public SignedPreKey() {
super(SignedPreKey.class);
}
public int getId() {
return Ints.saturatedCast(this.getLongAttribute("signedPreKeyId"));
}
}

View file

@ -0,0 +1,13 @@
package im.conversations.android.xmpp.model.axolotl;
import im.conversations.android.annotation.XmlElement;
import im.conversations.android.xmpp.model.ByteContent;
import im.conversations.android.xmpp.model.Extension;
@XmlElement(name = "signedPreKeySignature")
public class SignedPreKeySignature extends Extension implements ByteContent {
public SignedPreKeySignature() {
super(SignedPreKeySignature.class);
}
}

View file

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

View file

@ -0,0 +1,20 @@
package im.conversations.android.xmpp.model.bookmark;
import im.conversations.android.annotation.XmlElement;
import im.conversations.android.xmpp.model.Extension;
@XmlElement
public class Conference extends Extension {
public Conference() {
super(Conference.class);
}
public boolean isAutoJoin() {
return this.getAttributeAsBoolean("autojoin");
}
public String getConferenceName() {
return this.getAttribute("name");
}
}

View file

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

View file

@ -6,7 +6,7 @@ import im.conversations.android.xmpp.model.Extension;
public abstract class Condition extends Extension { public abstract class Condition extends Extension {
public Condition(Class<? extends Extension> clazz) { private Condition(Class<? extends Extension> clazz) {
super(clazz); super(clazz);
} }

View file

@ -18,4 +18,8 @@ public class Error extends Extension {
public void setCondition(final Condition condition) { public void setCondition(final Condition condition) {
this.addExtension(condition); this.addExtension(condition);
} }
public Text getText() {
return this.getExtension(Text.class);
}
} }

View file

@ -0,0 +1,13 @@
package im.conversations.android.xmpp.model.error;
import eu.siacs.conversations.xml.Namespace;
import im.conversations.android.annotation.XmlElement;
import im.conversations.android.xmpp.model.Extension;
@XmlElement(namespace = Namespace.STANZAS)
public class Text extends Extension {
public Text() {
super(Text.class);
}
}

View file

@ -0,0 +1,13 @@
package im.conversations.android.xmpp.model.nick;
import eu.siacs.conversations.xml.Namespace;
import im.conversations.android.annotation.XmlElement;
import im.conversations.android.xmpp.model.Extension;
@XmlElement(namespace = Namespace.NICK)
public class Nick extends Extension {
public Nick() {
super(Nick.class);
}
}

View file

@ -0,0 +1,10 @@
package im.conversations.android.xmpp.model.pubsub;
import im.conversations.android.xmpp.model.Extension;
public interface Item {
<T extends Extension> T getExtension(final Class<T> clazz);
String getId();
}

View file

@ -0,0 +1,52 @@
package im.conversations.android.xmpp.model.pubsub;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterables;
import im.conversations.android.xmpp.model.Extension;
import im.conversations.android.xmpp.model.pubsub.event.Retract;
import java.util.Collection;
import java.util.Map;
import java.util.NoSuchElementException;
public interface Items {
Collection<? extends Item> getItems();
String getNode();
Collection<Retract> getRetractions();
default <T extends Extension> Map<String, T> getItemMap(final Class<T> clazz) {
final ImmutableMap.Builder<String, T> builder = ImmutableMap.builder();
for (final Item item : getItems()) {
final var id = item.getId();
final T extension = item.getExtension(clazz);
if (extension == null || Strings.isNullOrEmpty(id)) {
continue;
}
builder.put(id, extension);
}
return builder.buildKeepingLast();
}
default <T extends Extension> T getItemOrThrow(final String id, final Class<T> clazz) {
final var map = getItemMap(clazz);
final var item = map.get(id);
if (item == null) {
throw new NoSuchElementException(
String.format("An item with id %s does not exist", id));
}
return item;
}
default <T extends Extension> T getFirstItem(final Class<T> clazz) {
final var map = getItemMap(clazz);
return Iterables.getFirst(map.values(), null);
}
default <T extends Extension> T getOnlyItem(final Class<T> clazz) {
final var map = getItemMap(clazz);
return Iterables.getOnlyElement(map.values());
}
}

View file

@ -0,0 +1,64 @@
package im.conversations.android.xmpp.model.pubsub;
import im.conversations.android.annotation.XmlElement;
import im.conversations.android.xmpp.model.Extension;
import im.conversations.android.xmpp.model.pubsub.event.Retract;
import java.util.Collection;
@XmlElement(name = "pubsub")
public class PubSub extends Extension {
public PubSub() {
super(PubSub.class);
}
public Items getItems() {
return this.getExtension(ItemsWrapper.class);
}
@XmlElement(name = "items")
public static class ItemsWrapper extends Extension implements Items {
public ItemsWrapper() {
super(ItemsWrapper.class);
}
public String getNode() {
return this.getAttribute("node");
}
public Collection<? extends im.conversations.android.xmpp.model.pubsub.Item> getItems() {
return this.getExtensions(Item.class);
}
public Collection<Retract> getRetractions() {
return this.getExtensions(Retract.class);
}
public void setNode(String node) {
this.setAttribute("node", node);
}
public void setMaxItems(final int maxItems) {
this.setAttribute("max_items", maxItems);
}
}
@XmlElement(name = "item")
public static class Item extends Extension
implements im.conversations.android.xmpp.model.pubsub.Item {
public Item() {
super(Item.class);
}
@Override
public String getId() {
return this.getAttribute("id");
}
public void setId(String itemId) {
this.setAttribute("id", itemId);
}
}
}

View file

@ -0,0 +1,56 @@
package im.conversations.android.xmpp.model.pubsub.event;
import im.conversations.android.annotation.XmlElement;
import im.conversations.android.xmpp.model.Extension;
import im.conversations.android.xmpp.model.pubsub.Items;
import java.util.Collection;
@XmlElement
public class Event extends Extension {
public Event() {
super(Event.class);
}
public Items getItems() {
return this.getExtension(ItemsWrapper.class);
}
public Purge getPurge() {
return this.getExtension(Purge.class);
}
@XmlElement(name = "items")
public static class ItemsWrapper extends Extension implements Items {
public ItemsWrapper() {
super(ItemsWrapper.class);
}
public String getNode() {
return this.getAttribute("node");
}
public Collection<? extends im.conversations.android.xmpp.model.pubsub.Item> getItems() {
return this.getExtensions(Item.class);
}
public Collection<Retract> getRetractions() {
return this.getExtensions(Retract.class);
}
}
@XmlElement(name = "item")
public static class Item extends Extension
implements im.conversations.android.xmpp.model.pubsub.Item {
public Item() {
super(Item.class);
}
@Override
public String getId() {
return this.getAttribute("id");
}
}
}

View file

@ -0,0 +1,16 @@
package im.conversations.android.xmpp.model.pubsub.event;
import im.conversations.android.annotation.XmlElement;
import im.conversations.android.xmpp.model.Extension;
@XmlElement
public class Purge extends Extension {
public Purge() {
super(Purge.class);
}
public String getNode() {
return this.getAttribute("node");
}
}

View file

@ -0,0 +1,16 @@
package im.conversations.android.xmpp.model.pubsub.event;
import im.conversations.android.annotation.XmlElement;
import im.conversations.android.xmpp.model.Extension;
@XmlElement
public class Retract extends Extension {
public Retract() {
super(Retract.class);
}
public String getId() {
return this.getAttribute("id");
}
}

View file

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

View file

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

View file

@ -5,6 +5,7 @@ import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xmpp.Jid; import eu.siacs.conversations.xmpp.Jid;
import im.conversations.android.xmpp.Entity; import im.conversations.android.xmpp.Entity;
import im.conversations.android.xmpp.XmppConnection; import im.conversations.android.xmpp.XmppConnection;
import im.conversations.android.xmpp.manager.AxolotlManager;
import im.conversations.android.xmpp.manager.BlockingManager; import im.conversations.android.xmpp.manager.BlockingManager;
import im.conversations.android.xmpp.manager.BookmarkManager; import im.conversations.android.xmpp.manager.BookmarkManager;
import im.conversations.android.xmpp.manager.DiscoManager; import im.conversations.android.xmpp.manager.DiscoManager;
@ -47,6 +48,8 @@ public class BindProcessor extends XmppConnection.Delegate implements Consumer<J
getManager(BookmarkManager.class).fetch(); getManager(BookmarkManager.class).fetch();
getManager(AxolotlManager.class).publishIfNecessary();
getManager(PresenceManager.class).sendPresence(); getManager(PresenceManager.class).sendPresence();
} }
} }

View file

@ -4,8 +4,10 @@ import android.content.Context;
import com.google.common.base.Strings; import com.google.common.base.Strings;
import im.conversations.android.xmpp.XmppConnection; import im.conversations.android.xmpp.XmppConnection;
import im.conversations.android.xmpp.manager.CarbonsManager; import im.conversations.android.xmpp.manager.CarbonsManager;
import im.conversations.android.xmpp.manager.PubSubManager;
import im.conversations.android.xmpp.model.carbons.Received; import im.conversations.android.xmpp.model.carbons.Received;
import im.conversations.android.xmpp.model.carbons.Sent; import im.conversations.android.xmpp.model.carbons.Sent;
import im.conversations.android.xmpp.model.pubsub.event.Event;
import im.conversations.android.xmpp.model.stanza.Message; import im.conversations.android.xmpp.model.stanza.Message;
import java.util.function.Consumer; import java.util.function.Consumer;
import org.slf4j.Logger; import org.slf4j.Logger;
@ -32,10 +34,17 @@ public class MessageProcessor extends XmppConnection.Delegate implements Consume
if (isRoot && connection.fromServer(message) && message.hasExtension(Received.class)) { if (isRoot && connection.fromServer(message) && message.hasExtension(Received.class)) {
getManager(CarbonsManager.class).handleReceived(message.getExtension(Received.class)); getManager(CarbonsManager.class).handleReceived(message.getExtension(Received.class));
return;
} }
if (isRoot && connection.fromServer(message) && message.hasExtension(Sent.class)) { if (isRoot && connection.fromServer(message) && message.hasExtension(Sent.class)) {
getManager(CarbonsManager.class).handleSent(message.getExtension(Sent.class)); getManager(CarbonsManager.class).handleSent(message.getExtension(Sent.class));
return;
}
if (isRoot && message.hasExtension(Event.class)) {
getManager(PubSubManager.class).handleEvent(message);
return;
} }
final String body = message.getBody(); final String body = message.getBody();

View file

@ -0,0 +1,65 @@
package im.conversations.android.xmpp;
import static org.hamcrest.CoreMatchers.instanceOf;
import static org.hamcrest.MatcherAssert.assertThat;
import eu.siacs.conversations.xml.Element;
import eu.siacs.conversations.xml.XmlElementReader;
import im.conversations.android.xmpp.model.bookmark.Conference;
import im.conversations.android.xmpp.model.pubsub.PubSub;
import im.conversations.android.xmpp.model.stanza.Iq;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import org.junit.Assert;
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 PubSubTest {
@Test
public void parseBookmarkResult() throws IOException {
final String xml =
"<iq type='result'\n"
+ " to='juliet@capulet.lit/balcony'\n"
+ " id='retrieve1' xmlns='jabber:client'>\n"
+ " <pubsub xmlns='http://jabber.org/protocol/pubsub'>\n"
+ " <items node='urn:xmpp:bookmarks:1'>\n"
+ " <item id='theplay@conference.shakespeare.lit'>\n"
+ " <conference xmlns='urn:xmpp:bookmarks:1'\n"
+ " name='The Play&apos;s the Thing'\n"
+ " autojoin='true'>\n"
+ " <nick>JC</nick>\n"
+ " </conference>\n"
+ " </item>\n"
+ " <item id='orchard@conference.shakespeare.lit'>\n"
+ " <conference xmlns='urn:xmpp:bookmarks:1'\n"
+ " name='The Orcard'\n"
+ " autojoin='1'>\n"
+ " <nick>JC</nick>\n"
+ " <extensions>\n"
+ " <state xmlns='http://myclient.example/bookmark/state'"
+ " minimized='true'/>\n"
+ " </extensions>\n"
+ " </conference>\n"
+ " </item>\n"
+ " </items>\n"
+ " </pubsub>\n"
+ "</iq>";
final Element element = XmlElementReader.read(xml.getBytes(StandardCharsets.UTF_8));
assertThat(element, instanceOf(Iq.class));
final Iq iq = (Iq) element;
final var pubSub = iq.getExtension(PubSub.class);
Assert.assertNotNull(pubSub);
final var items = pubSub.getItems();
Assert.assertNotNull(items);
final var itemMap = items.getItemMap(Conference.class);
Assert.assertEquals(2, itemMap.size());
final var conference = itemMap.get("orchard@conference.shakespeare.lit");
Assert.assertNotNull(conference);
Assert.assertEquals("The Orcard", conference.getConferenceName());
}
}