From c077e4e8dab4bd2e7b378fbffbd8118e4e736e0e Mon Sep 17 00:00:00 2001 From: Daniel Gultsch Date: Sun, 5 Feb 2023 19:32:32 +0100 Subject: [PATCH] add PubSubManager, AvatarManager and AxolotlManager --- build.gradle | 8 +- .../1.json | 772 +++++++++++++++++- .../eu/siacs/conversations/xml/Element.java | 6 + .../eu/siacs/conversations/xml/Namespace.java | 14 +- .../database/AxolotlDatabaseStore.java | 145 ++++ .../database/ConversationsDatabase.java | 34 + .../android/database/Converters.java | 92 +++ .../android/database/dao/AvatarDao.java | 31 + .../android/database/dao/AxolotlDao.java | 204 +++++ .../android/database/dao/BookmarkDao.java | 49 ++ .../android/database/dao/NickDao.java | 19 + .../entity/AvatarAdditionalEntity.java | 38 + .../android/database/entity/AvatarEntity.java | 47 ++ .../entity/AxolotlDeviceListEntity.java | 66 ++ .../entity/AxolotlDeviceListItemEntity.java | 38 + .../entity/AxolotlIdentityEntity.java | 47 ++ .../entity/AxolotlIdentityKeyPairEntity.java | 40 + .../database/entity/AxolotlPreKeyEntity.java | 45 + .../database/entity/AxolotlSessionEntity.java | 47 ++ .../entity/AxolotlSignedPreKeyEntity.java | 46 ++ .../database/entity/BookmarkEntity.java | 64 ++ .../android/database/entity/NickEntity.java | 41 + .../android/database/model/Account.java | 11 +- .../android/database/model/AvatarBase.java | 18 + .../database/model/AvatarExternal.java | 23 + .../database/model/AvatarThumbnail.java | 15 + .../android/xmpp/IqErrorException.java | 24 + .../conversations/android/xmpp/Managers.java | 8 + .../android/xmpp/XmppConnection.java | 3 +- .../android/xmpp/axolotl/AxolotlAddress.java | 31 + .../android/xmpp/manager/AbstractManager.java | 4 + .../android/xmpp/manager/AvatarManager.java | 162 ++++ .../android/xmpp/manager/AxolotlManager.java | 281 +++++++ .../android/xmpp/manager/BookmarkManager.java | 64 +- .../android/xmpp/manager/DiscoManager.java | 10 +- .../android/xmpp/manager/NickManager.java | 28 + .../android/xmpp/manager/PubSubManager.java | 197 +++++ .../android/xmpp/model/ByteContent.java | 32 + .../android/xmpp/model/avatar/Data.java | 14 + .../android/xmpp/model/avatar/Info.java | 37 + .../android/xmpp/model/avatar/Metadata.java | 13 + .../android/xmpp/model/axolotl/Bundle.java | 58 ++ .../android/xmpp/model/axolotl/Device.java | 22 + .../xmpp/model/axolotl/DeviceList.java | 35 + .../model/axolotl/ECPublicKeyContent.java | 23 + .../xmpp/model/axolotl/IdentityKey.java | 12 + .../android/xmpp/model/axolotl/PreKey.java | 21 + .../android/xmpp/model/axolotl/PreKeys.java | 12 + .../xmpp/model/axolotl/SignedPreKey.java | 17 + .../model/axolotl/SignedPreKeySignature.java | 13 + .../xmpp/model/axolotl/package-info.java | 5 + .../xmpp/model/bookmark/Conference.java | 20 + .../xmpp/model/bookmark/package-info.java | 5 + .../android/xmpp/model/error/Condition.java | 2 +- .../android/xmpp/model/error/Error.java | 4 + .../android/xmpp/model/error/Text.java | 13 + .../android/xmpp/model/nick/Nick.java | 13 + .../android/xmpp/model/pubsub/Item.java | 10 + .../android/xmpp/model/pubsub/Items.java | 52 ++ .../android/xmpp/model/pubsub/PubSub.java | 64 ++ .../xmpp/model/pubsub/event/Event.java | 56 ++ .../xmpp/model/pubsub/event/Purge.java | 16 + .../xmpp/model/pubsub/event/Retract.java | 16 + .../xmpp/model/pubsub/event/package-info.java | 5 + .../xmpp/model/pubsub/package-info.java | 5 + .../android/xmpp/processor/BindProcessor.java | 3 + .../xmpp/processor/MessageProcessor.java | 9 + .../android/xmpp/PubSubTest.java | 65 ++ 68 files changed, 3430 insertions(+), 14 deletions(-) create mode 100644 src/main/java/im/conversations/android/database/AxolotlDatabaseStore.java create mode 100644 src/main/java/im/conversations/android/database/dao/AvatarDao.java create mode 100644 src/main/java/im/conversations/android/database/dao/AxolotlDao.java create mode 100644 src/main/java/im/conversations/android/database/dao/BookmarkDao.java create mode 100644 src/main/java/im/conversations/android/database/dao/NickDao.java create mode 100644 src/main/java/im/conversations/android/database/entity/AvatarAdditionalEntity.java create mode 100644 src/main/java/im/conversations/android/database/entity/AvatarEntity.java create mode 100644 src/main/java/im/conversations/android/database/entity/AxolotlDeviceListEntity.java create mode 100644 src/main/java/im/conversations/android/database/entity/AxolotlDeviceListItemEntity.java create mode 100644 src/main/java/im/conversations/android/database/entity/AxolotlIdentityEntity.java create mode 100644 src/main/java/im/conversations/android/database/entity/AxolotlIdentityKeyPairEntity.java create mode 100644 src/main/java/im/conversations/android/database/entity/AxolotlPreKeyEntity.java create mode 100644 src/main/java/im/conversations/android/database/entity/AxolotlSessionEntity.java create mode 100644 src/main/java/im/conversations/android/database/entity/AxolotlSignedPreKeyEntity.java create mode 100644 src/main/java/im/conversations/android/database/entity/BookmarkEntity.java create mode 100644 src/main/java/im/conversations/android/database/entity/NickEntity.java create mode 100644 src/main/java/im/conversations/android/database/model/AvatarBase.java create mode 100644 src/main/java/im/conversations/android/database/model/AvatarExternal.java create mode 100644 src/main/java/im/conversations/android/database/model/AvatarThumbnail.java create mode 100644 src/main/java/im/conversations/android/xmpp/IqErrorException.java create mode 100644 src/main/java/im/conversations/android/xmpp/axolotl/AxolotlAddress.java create mode 100644 src/main/java/im/conversations/android/xmpp/manager/AvatarManager.java create mode 100644 src/main/java/im/conversations/android/xmpp/manager/AxolotlManager.java create mode 100644 src/main/java/im/conversations/android/xmpp/manager/NickManager.java create mode 100644 src/main/java/im/conversations/android/xmpp/manager/PubSubManager.java create mode 100644 src/main/java/im/conversations/android/xmpp/model/ByteContent.java create mode 100644 src/main/java/im/conversations/android/xmpp/model/avatar/Data.java create mode 100644 src/main/java/im/conversations/android/xmpp/model/avatar/Info.java create mode 100644 src/main/java/im/conversations/android/xmpp/model/avatar/Metadata.java create mode 100644 src/main/java/im/conversations/android/xmpp/model/axolotl/Bundle.java create mode 100644 src/main/java/im/conversations/android/xmpp/model/axolotl/Device.java create mode 100644 src/main/java/im/conversations/android/xmpp/model/axolotl/DeviceList.java create mode 100644 src/main/java/im/conversations/android/xmpp/model/axolotl/ECPublicKeyContent.java create mode 100644 src/main/java/im/conversations/android/xmpp/model/axolotl/IdentityKey.java create mode 100644 src/main/java/im/conversations/android/xmpp/model/axolotl/PreKey.java create mode 100644 src/main/java/im/conversations/android/xmpp/model/axolotl/PreKeys.java create mode 100644 src/main/java/im/conversations/android/xmpp/model/axolotl/SignedPreKey.java create mode 100644 src/main/java/im/conversations/android/xmpp/model/axolotl/SignedPreKeySignature.java create mode 100644 src/main/java/im/conversations/android/xmpp/model/axolotl/package-info.java create mode 100644 src/main/java/im/conversations/android/xmpp/model/bookmark/Conference.java create mode 100644 src/main/java/im/conversations/android/xmpp/model/bookmark/package-info.java create mode 100644 src/main/java/im/conversations/android/xmpp/model/error/Text.java create mode 100644 src/main/java/im/conversations/android/xmpp/model/nick/Nick.java create mode 100644 src/main/java/im/conversations/android/xmpp/model/pubsub/Item.java create mode 100644 src/main/java/im/conversations/android/xmpp/model/pubsub/Items.java create mode 100644 src/main/java/im/conversations/android/xmpp/model/pubsub/PubSub.java create mode 100644 src/main/java/im/conversations/android/xmpp/model/pubsub/event/Event.java create mode 100644 src/main/java/im/conversations/android/xmpp/model/pubsub/event/Purge.java create mode 100644 src/main/java/im/conversations/android/xmpp/model/pubsub/event/Retract.java create mode 100644 src/main/java/im/conversations/android/xmpp/model/pubsub/event/package-info.java create mode 100644 src/main/java/im/conversations/android/xmpp/model/pubsub/package-info.java create mode 100644 src/test/java/im/conversations/android/xmpp/PubSubTest.java diff --git a/build.gradle b/build.gradle index 06aa31ae5..6d646cfc1 100644 --- a/build.gradle +++ b/build.gradle @@ -7,7 +7,7 @@ buildscript { } dependencies { 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 { - ratchetFrom '2.12.0' + ratchetFrom '2.12.2' java { target '**/*.java' googleJavaFormat('1.8').aosp().reflowLongStrings() @@ -80,11 +80,11 @@ dependencies { quicksyPlaystoreImplementation 'com.google.android.gms:play-services-auth-api-phone:18.0.1' implementation 'org.sufficientlysecure:openpgp-api:10.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.cardview:cardview:1.0.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" freeImplementation "androidx.emoji2:emoji2-bundled:1.2.0" diff --git a/schemas/im.conversations.android.database.ConversationsDatabase/1.json b/schemas/im.conversations.android.database.ConversationsDatabase/1.json index 1a91e1323..8f46a5878 100644 --- a/schemas/im.conversations.android.database.ConversationsDatabase/1.json +++ b/schemas/im.conversations.android.database.ConversationsDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "e2dbbac3327bc8ef188286642b379e7d", + "identityHash": "2972255ca35c75ece48909471313d20a", "entities": [ { "tableName": "account", @@ -124,6 +124,634 @@ ], "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", "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", "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", "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": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e2dbbac3327bc8ef188286642b379e7d')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2972255ca35c75ece48909471313d20a')" ] } } \ No newline at end of file diff --git a/src/main/java/eu/siacs/conversations/xml/Element.java b/src/main/java/eu/siacs/conversations/xml/Element.java index 70b7f5cba..b43ea4f25 100644 --- a/src/main/java/eu/siacs/conversations/xml/Element.java +++ b/src/main/java/eu/siacs/conversations/xml/Element.java @@ -5,6 +5,7 @@ import com.google.common.base.Strings; import com.google.common.collect.Collections2; import com.google.common.collect.Iterables; import com.google.common.primitives.Ints; +import com.google.common.primitives.Longs; import eu.siacs.conversations.utils.XmlHelper; import eu.siacs.conversations.xmpp.InvalidJid; 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 getOptionalIntAttribute(final String name) { final String value = getAttribute(name); if (value == null) { diff --git a/src/main/java/eu/siacs/conversations/xml/Namespace.java b/src/main/java/eu/siacs/conversations/xml/Namespace.java index b0209c735..eeebc4ad5 100644 --- a/src/main/java/eu/siacs/conversations/xml/Namespace.java +++ b/src/main/java/eu/siacs/conversations/xml/Namespace.java @@ -1,14 +1,18 @@ package eu.siacs.conversations.xml; public final class Namespace { + 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_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 BIND2 = "urn:xmpp:bind:0"; public static final String BLOCKING = "urn:xmpp:blocking"; 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 BOOKMARKS_CONVERSION = "urn:xmpp:bookmarks-conversion:0"; 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 PING = "urn:xmpp:ping"; 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_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 REGISTER = "jabber:iq:register"; public static final String REGISTER_STREAM_FEATURE = "http://jabber.org/features/iq-register"; diff --git a/src/main/java/im/conversations/android/database/AxolotlDatabaseStore.java b/src/main/java/im/conversations/android/database/AxolotlDatabaseStore.java new file mode 100644 index 000000000..8e783c9e4 --- /dev/null +++ b/src/main/java/im/conversations/android/database/AxolotlDatabaseStore.java @@ -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 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 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); + } +} diff --git a/src/main/java/im/conversations/android/database/ConversationsDatabase.java b/src/main/java/im/conversations/android/database/ConversationsDatabase.java index a5e96dd75..6af016032 100644 --- a/src/main/java/im/conversations/android/database/ConversationsDatabase.java +++ b/src/main/java/im/conversations/android/database/ConversationsDatabase.java @@ -6,13 +6,27 @@ import androidx.room.Room; import androidx.room.RoomDatabase; import androidx.room.TypeConverters; 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.BookmarkDao; import im.conversations.android.database.dao.DiscoDao; 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.RosterDao; 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.BookmarkEntity; import im.conversations.android.database.entity.ChatEntity; import im.conversations.android.database.entity.DiscoEntity; 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.MessagePartEntity; 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.ReactionEntity; import im.conversations.android.database.entity.RosterItemEntity; @@ -32,7 +47,17 @@ import im.conversations.android.database.entity.RosterItemGroupEntity; @Database( entities = { AccountEntity.class, + AvatarAdditionalEntity.class, + AvatarEntity.class, + AxolotlDeviceListEntity.class, + AxolotlDeviceListItemEntity.class, + AxolotlIdentityEntity.class, + AxolotlIdentityKeyPairEntity.class, + AxolotlPreKeyEntity.class, + AxolotlSessionEntity.class, + AxolotlSignedPreKeyEntity.class, BlockedItemEntity.class, + BookmarkEntity.class, ChatEntity.class, DiscoEntity.class, DiscoExtensionEntity.class, @@ -44,6 +69,7 @@ import im.conversations.android.database.entity.RosterItemGroupEntity; MessageEntity.class, MessagePartEntity.class, MessageVersionEntity.class, + NickEntity.class, PresenceEntity.class, ReactionEntity.class, RosterItemEntity.class, @@ -73,12 +99,20 @@ public abstract class ConversationsDatabase extends RoomDatabase { public abstract AccountDao accountDao(); + public abstract AvatarDao avatarDao(); + + public abstract AxolotlDao axolotlDao(); + public abstract BlockingDao blockingDao(); + public abstract BookmarkDao bookmarkDao(); + public abstract DiscoDao discoDao(); public abstract MessageDao messageDao(); + public abstract NickDao nickDao(); + public abstract PresenceDao presenceDao(); public abstract RosterDao rosterDao(); diff --git a/src/main/java/im/conversations/android/database/Converters.java b/src/main/java/im/conversations/android/database/Converters.java index 1e9e140cf..3c6f51a1a 100644 --- a/src/main/java/im/conversations/android/database/Converters.java +++ b/src/main/java/im/conversations/android/database/Converters.java @@ -2,7 +2,14 @@ package im.conversations.android.database; import androidx.room.TypeConverter; import eu.siacs.conversations.xmpp.Jid; +import java.io.IOException; 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 { @@ -27,4 +34,89 @@ public final class Converters { public static String fromJid(final Jid jid) { 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); + } + } } diff --git a/src/main/java/im/conversations/android/database/dao/AvatarDao.java b/src/main/java/im/conversations/android/database/dao/AvatarDao.java new file mode 100644 index 000000000..fc337683e --- /dev/null +++ b/src/main/java/im/conversations/android/database/dao/AvatarDao.java @@ -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 entities); + + public void set( + final Account account, + final Jid address, + final Info thumbnail, + final Collection additional) { + final long id = insert(AvatarEntity.of(account, address, thumbnail)); + insert(Collections2.transform(additional, a -> AvatarAdditionalEntity.of(id, a))); + } +} diff --git a/src/main/java/im/conversations/android/database/dao/AxolotlDao.java b/src/main/java/im/conversations/android/database/dao/AxolotlDao.java new file mode 100644 index 000000000..d723283b0 --- /dev/null +++ b/src/main/java/im/conversations/android/database/dao/AxolotlDao.java @@ -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 entities); + + @Transaction + public void setDeviceList(Account account, Jid from, Set 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 preKeyRecords) { + insertPreKeys( + Collections2.transform( + preKeyRecords, r -> AxolotlPreKeyEntity.of(account, r.getId(), r))); + } + + @Insert(onConflict = OnConflictStrategy.REPLACE) + protected abstract void insertPreKeys(Collection 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 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 getSignedPreKeys(long account); + + @Query("SELECT preKeyRecord FROM axolotl_pre_key WHERE accountId=:account AND removed=0") + public abstract List 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); +} diff --git a/src/main/java/im/conversations/android/database/dao/BookmarkDao.java b/src/main/java/im/conversations/android/database/dao/BookmarkDao.java new file mode 100644 index 000000000..c1ff277ea --- /dev/null +++ b/src/main/java/im/conversations/android/database/dao/BookmarkDao.java @@ -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 addresses); + + @Insert + protected abstract void insert(Collection bookmarks); + + @Transaction + public void updateItems(final Account account, Map items) { + final Collection 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 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)); + } +} diff --git a/src/main/java/im/conversations/android/database/dao/NickDao.java b/src/main/java/im/conversations/android/database/dao/NickDao.java new file mode 100644 index 000000000..e0997bd2c --- /dev/null +++ b/src/main/java/im/conversations/android/database/dao/NickDao.java @@ -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)); + } +} diff --git a/src/main/java/im/conversations/android/database/entity/AvatarAdditionalEntity.java b/src/main/java/im/conversations/android/database/entity/AvatarAdditionalEntity.java new file mode 100644 index 000000000..01fdc5de8 --- /dev/null +++ b/src/main/java/im/conversations/android/database/entity/AvatarAdditionalEntity.java @@ -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; + } +} diff --git a/src/main/java/im/conversations/android/database/entity/AvatarEntity.java b/src/main/java/im/conversations/android/database/entity/AvatarEntity.java new file mode 100644 index 000000000..4d28b3a7a --- /dev/null +++ b/src/main/java/im/conversations/android/database/entity/AvatarEntity.java @@ -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; + } +} diff --git a/src/main/java/im/conversations/android/database/entity/AxolotlDeviceListEntity.java b/src/main/java/im/conversations/android/database/entity/AxolotlDeviceListEntity.java new file mode 100644 index 000000000..ea0b245a0 --- /dev/null +++ b/src/main/java/im/conversations/android/database/entity/AxolotlDeviceListEntity.java @@ -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; + } +} diff --git a/src/main/java/im/conversations/android/database/entity/AxolotlDeviceListItemEntity.java b/src/main/java/im/conversations/android/database/entity/AxolotlDeviceListItemEntity.java new file mode 100644 index 000000000..b2789eb25 --- /dev/null +++ b/src/main/java/im/conversations/android/database/entity/AxolotlDeviceListItemEntity.java @@ -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; + } +} diff --git a/src/main/java/im/conversations/android/database/entity/AxolotlIdentityEntity.java b/src/main/java/im/conversations/android/database/entity/AxolotlIdentityEntity.java new file mode 100644 index 000000000..ae88326e7 --- /dev/null +++ b/src/main/java/im/conversations/android/database/entity/AxolotlIdentityEntity.java @@ -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; + } +} diff --git a/src/main/java/im/conversations/android/database/entity/AxolotlIdentityKeyPairEntity.java b/src/main/java/im/conversations/android/database/entity/AxolotlIdentityKeyPairEntity.java new file mode 100644 index 000000000..a152b0db4 --- /dev/null +++ b/src/main/java/im/conversations/android/database/entity/AxolotlIdentityKeyPairEntity.java @@ -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; + } +} diff --git a/src/main/java/im/conversations/android/database/entity/AxolotlPreKeyEntity.java b/src/main/java/im/conversations/android/database/entity/AxolotlPreKeyEntity.java new file mode 100644 index 000000000..fab4e3f62 --- /dev/null +++ b/src/main/java/im/conversations/android/database/entity/AxolotlPreKeyEntity.java @@ -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; + } +} diff --git a/src/main/java/im/conversations/android/database/entity/AxolotlSessionEntity.java b/src/main/java/im/conversations/android/database/entity/AxolotlSessionEntity.java new file mode 100644 index 000000000..94b1f1b22 --- /dev/null +++ b/src/main/java/im/conversations/android/database/entity/AxolotlSessionEntity.java @@ -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; + } +} diff --git a/src/main/java/im/conversations/android/database/entity/AxolotlSignedPreKeyEntity.java b/src/main/java/im/conversations/android/database/entity/AxolotlSignedPreKeyEntity.java new file mode 100644 index 000000000..a760ebd49 --- /dev/null +++ b/src/main/java/im/conversations/android/database/entity/AxolotlSignedPreKeyEntity.java @@ -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; + } +} diff --git a/src/main/java/im/conversations/android/database/entity/BookmarkEntity.java b/src/main/java/im/conversations/android/database/entity/BookmarkEntity.java new file mode 100644 index 000000000..792a2b744 --- /dev/null +++ b/src/main/java/im/conversations/android/database/entity/BookmarkEntity.java @@ -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 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; + } + } +} diff --git a/src/main/java/im/conversations/android/database/entity/NickEntity.java b/src/main/java/im/conversations/android/database/entity/NickEntity.java new file mode 100644 index 000000000..88388eaf5 --- /dev/null +++ b/src/main/java/im/conversations/android/database/entity/NickEntity.java @@ -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; + } +} diff --git a/src/main/java/im/conversations/android/database/model/Account.java b/src/main/java/im/conversations/android/database/model/Account.java index d36bd11c4..43a6d3797 100644 --- a/src/main/java/im/conversations/android/database/model/Account.java +++ b/src/main/java/im/conversations/android/database/model/Account.java @@ -5,6 +5,7 @@ import com.google.common.base.Objects; import com.google.common.base.Preconditions; import com.google.common.hash.Hashing; import com.google.common.io.ByteSource; +import com.google.common.primitives.Ints; import eu.siacs.conversations.xmpp.Jid; import im.conversations.android.IDs; import java.io.IOException; @@ -51,7 +52,15 @@ public class Account { return IDs.uuid( ByteSource.wrap(randomSeed).slice(0, 16).hash(Hashing.sha256()).asBytes()); } 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); } } } diff --git a/src/main/java/im/conversations/android/database/model/AvatarBase.java b/src/main/java/im/conversations/android/database/model/AvatarBase.java new file mode 100644 index 000000000..d88917ae4 --- /dev/null +++ b/src/main/java/im/conversations/android/database/model/AvatarBase.java @@ -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; + } +} diff --git a/src/main/java/im/conversations/android/database/model/AvatarExternal.java b/src/main/java/im/conversations/android/database/model/AvatarExternal.java new file mode 100644 index 000000000..97ec32dcc --- /dev/null +++ b/src/main/java/im/conversations/android/database/model/AvatarExternal.java @@ -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()); + } +} diff --git a/src/main/java/im/conversations/android/database/model/AvatarThumbnail.java b/src/main/java/im/conversations/android/database/model/AvatarThumbnail.java new file mode 100644 index 000000000..401684d11 --- /dev/null +++ b/src/main/java/im/conversations/android/database/model/AvatarThumbnail.java @@ -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()); + } +} diff --git a/src/main/java/im/conversations/android/xmpp/IqErrorException.java b/src/main/java/im/conversations/android/xmpp/IqErrorException.java new file mode 100644 index 000000000..9b7ae887c --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/IqErrorException.java @@ -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(); + } +} diff --git a/src/main/java/im/conversations/android/xmpp/Managers.java b/src/main/java/im/conversations/android/xmpp/Managers.java index 117aa1742..82a86a678 100644 --- a/src/main/java/im/conversations/android/xmpp/Managers.java +++ b/src/main/java/im/conversations/android/xmpp/Managers.java @@ -4,11 +4,15 @@ import android.content.Context; import com.google.common.collect.ClassToInstanceMap; import com.google.common.collect.ImmutableClassToInstanceMap; 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.BookmarkManager; import im.conversations.android.xmpp.manager.CarbonsManager; 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.PubSubManager; import im.conversations.android.xmpp.manager.RosterManager; public final class Managers { @@ -18,11 +22,15 @@ public final class Managers { public static ClassToInstanceMap initialize( final Context context, final XmppConnection connection) { return new ImmutableClassToInstanceMap.Builder() + .put(AvatarManager.class, new AvatarManager(context, connection)) + .put(AxolotlManager.class, new AxolotlManager(context, connection)) .put(BlockingManager.class, new BlockingManager(context, connection)) .put(BookmarkManager.class, new BookmarkManager(context, connection)) .put(CarbonsManager.class, new CarbonsManager(context, connection)) .put(DiscoManager.class, new DiscoManager(context, connection)) + .put(NickManager.class, new NickManager(context, connection)) .put(PresenceManager.class, new PresenceManager(context, connection)) + .put(PubSubManager.class, new PubSubManager(context, connection)) .put(RosterManager.class, new RosterManager(context, connection)) .build(); } diff --git a/src/main/java/im/conversations/android/xmpp/XmppConnection.java b/src/main/java/im/conversations/android/xmpp/XmppConnection.java index e3735ea17..413706a8c 100644 --- a/src/main/java/im/conversations/android/xmpp/XmppConnection.java +++ b/src/main/java/im/conversations/android/xmpp/XmppConnection.java @@ -2019,8 +2019,7 @@ public class XmppConnection implements Runnable { } else if (type == Iq.Type.TIMEOUT) { future.setException(new TimeoutException()); } else { - // TODO some sort of IqErrorException - future.setException(new IOException()); + future.setException(new IqErrorException(result)); } }); return future; diff --git a/src/main/java/im/conversations/android/xmpp/axolotl/AxolotlAddress.java b/src/main/java/im/conversations/android/xmpp/axolotl/AxolotlAddress.java new file mode 100644 index 000000000..c4fccf396 --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/axolotl/AxolotlAddress.java @@ -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())); + } +} diff --git a/src/main/java/im/conversations/android/xmpp/manager/AbstractManager.java b/src/main/java/im/conversations/android/xmpp/manager/AbstractManager.java index 207602334..9bde1e7a6 100644 --- a/src/main/java/im/conversations/android/xmpp/manager/AbstractManager.java +++ b/src/main/java/im/conversations/android/xmpp/manager/AbstractManager.java @@ -2,9 +2,13 @@ package im.conversations.android.xmpp.manager; import android.content.Context; import im.conversations.android.xmpp.XmppConnection; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; public class AbstractManager extends XmppConnection.Delegate { + protected static final Executor IO_EXECUTOR = Executors.newSingleThreadExecutor(); + protected AbstractManager(final Context context, final XmppConnection connection) { super(context, connection); } diff --git a/src/main/java/im/conversations/android/xmpp/manager/AvatarManager.java b/src/main/java/im/conversations/android/xmpp/manager/AvatarManager.java new file mode 100644 index 000000000..c18f64fbf --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/manager/AvatarManager.java @@ -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> 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 getAvatar(final Jid address, final String id) { + final var fetch = new Fetch(address, id); + final SettableFuture 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 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 fetchAvatar(final Jid address, final String id) { + return Futures.transform( + getManager(PubSubManager.class).fetchItem(address, id, Data.class), + Data::asBytes, + MoreExecutors.directExecutor()); + } + + private ListenableFuture 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; + } + } +} diff --git a/src/main/java/im/conversations/android/xmpp/manager/AxolotlManager.java b/src/main/java/im/conversations/android/xmpp/manager/AxolotlManager.java new file mode 100644 index 000000000..ff337833f --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/manager/AxolotlManager.java @@ -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> 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 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 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 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 publishBundleAndDeviceId() { + final ListenableFuture bundleFuture = publishBundle(); + return Futures.transformAsync( + bundleFuture, ignored -> publishDeviceId(), MoreExecutors.directExecutor()); + } + + private ListenableFuture 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() + .addAll(currentDeviceIds) + .add(myDeviceId) + .build(); + return publishDeviceIds(deviceIds); + } + }, + MoreExecutors.directExecutor()); + } + + private ListenableFuture publishDeviceIds(final Collection deviceIds) { + final var deviceList = new DeviceList(); + deviceList.setDeviceIds(deviceIds); + return getManager(PubSubManager.class) + .publishSingleton(getAccount().address, deviceList, Namespace.AXOLOTL_DEVICE_LIST); + } + + private ListenableFuture publishBundle() { + final ListenableFuture 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); + } + } +} diff --git a/src/main/java/im/conversations/android/xmpp/manager/BookmarkManager.java b/src/main/java/im/conversations/android/xmpp/manager/BookmarkManager.java index 615ed5bcb..e9a059dfb 100644 --- a/src/main/java/im/conversations/android/xmpp/manager/BookmarkManager.java +++ b/src/main/java/im/conversations/android/xmpp/manager/BookmarkManager.java @@ -1,12 +1,74 @@ package im.conversations.android.xmpp.manager; 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.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 { + + private static final Logger LOGGER = LoggerFactory.getLogger(BookmarkManager.class); + public BookmarkManager(Context context, XmppConnection 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 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 items) { + getDatabase().bookmarkDao().updateItems(getAccount(), items); + } + + private void deleteItems(Collection retractions) { + final Collection 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); + } + } } diff --git a/src/main/java/im/conversations/android/xmpp/manager/DiscoManager.java b/src/main/java/im/conversations/android/xmpp/manager/DiscoManager.java index ab3f739a3..46f0f81c7 100644 --- a/src/main/java/im/conversations/android/xmpp/manager/DiscoManager.java +++ b/src/main/java/im/conversations/android/xmpp/manager/DiscoManager.java @@ -73,7 +73,11 @@ public class DiscoManager extends AbstractManager { Collections.singleton(Namespace.VERSION); private static final Collection 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) { super(context, connection); @@ -240,6 +244,10 @@ public class DiscoManager extends AbstractManager { 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) { return hasFeature(getAccount().address.getDomain(), feature); } diff --git a/src/main/java/im/conversations/android/xmpp/manager/NickManager.java b/src/main/java/im/conversations/android/xmpp/manager/NickManager.java new file mode 100644 index 000000000..64a640f3b --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/manager/NickManager.java @@ -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); + } +} diff --git a/src/main/java/im/conversations/android/xmpp/manager/PubSubManager.java b/src/main/java/im/conversations/android/xmpp/manager/PubSubManager.java new file mode 100644 index 000000000..7ca455320 --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/manager/PubSubManager.java @@ -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 ListenableFuture> fetchItems( + final Jid address, final Class 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 ListenableFuture> fetchItems( + final Jid address, final String node, final Class 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 ListenableFuture fetchItem( + final Jid address, final String itemId, final Class 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 ListenableFuture fetchItem( + final Jid address, final String node, final String itemId, final Class 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 ListenableFuture fetchMostRecentItem( + final Jid address, final String node, final Class 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 publishSingleton(Jid address, Extension item) { + final var id = ExtensionFactory.id(item.getClass()); + return publish(address, item, SINGLETON_ITEM_ID, id.namespace); + } + + public ListenableFuture publishSingleton(Jid address, Extension item, final String node) { + return publish(address, item, SINGLETON_ITEM_ID, node); + } + + public ListenableFuture publish(Jid address, Extension item, final String itemId) { + final var id = ExtensionFactory.id(item.getClass()); + return publish(address, item, itemId, id.namespace); + } + + public ListenableFuture 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()); + } +} diff --git a/src/main/java/im/conversations/android/xmpp/model/ByteContent.java b/src/main/java/im/conversations/android/xmpp/model/ByteContent.java new file mode 100644 index 000000000..a09c8f38d --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/ByteContent.java @@ -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); +} diff --git a/src/main/java/im/conversations/android/xmpp/model/avatar/Data.java b/src/main/java/im/conversations/android/xmpp/model/avatar/Data.java new file mode 100644 index 000000000..b661bca3a --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/avatar/Data.java @@ -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); + } +} diff --git a/src/main/java/im/conversations/android/xmpp/model/avatar/Info.java b/src/main/java/im/conversations/android/xmpp/model/avatar/Info.java new file mode 100644 index 000000000..f544af72f --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/avatar/Info.java @@ -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"); + } +} diff --git a/src/main/java/im/conversations/android/xmpp/model/avatar/Metadata.java b/src/main/java/im/conversations/android/xmpp/model/avatar/Metadata.java new file mode 100644 index 000000000..400f98957 --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/avatar/Metadata.java @@ -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); + } +} diff --git a/src/main/java/im/conversations/android/xmpp/model/axolotl/Bundle.java b/src/main/java/im/conversations/android/xmpp/model/axolotl/Bundle.java new file mode 100644 index 000000000..ce0dd5da7 --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/axolotl/Bundle.java @@ -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 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 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()); + } + } +} diff --git a/src/main/java/im/conversations/android/xmpp/model/axolotl/Device.java b/src/main/java/im/conversations/android/xmpp/model/axolotl/Device.java new file mode 100644 index 000000000..0ad10d702 --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/axolotl/Device.java @@ -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); + } +} diff --git a/src/main/java/im/conversations/android/xmpp/model/axolotl/DeviceList.java b/src/main/java/im/conversations/android/xmpp/model/axolotl/DeviceList.java new file mode 100644 index 000000000..ec4fce469 --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/axolotl/DeviceList.java @@ -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 getDevices() { + return this.getExtensions(Device.class); + } + + public Set getDeviceIds() { + return ImmutableSet.copyOf( + Collections2.filter( + Collections2.transform(getDevices(), Device::getDeviceId), + Objects::nonNull)); + } + + public void setDeviceIds(Collection deviceIds) { + for (final Integer deviceId : deviceIds) { + final var device = this.addExtension(new Device()); + device.setDeviceId(deviceId); + } + } +} diff --git a/src/main/java/im/conversations/android/xmpp/model/axolotl/ECPublicKeyContent.java b/src/main/java/im/conversations/android/xmpp/model/axolotl/ECPublicKeyContent.java new file mode 100644 index 000000000..2008fb017 --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/axolotl/ECPublicKeyContent.java @@ -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()); + } +} diff --git a/src/main/java/im/conversations/android/xmpp/model/axolotl/IdentityKey.java b/src/main/java/im/conversations/android/xmpp/model/axolotl/IdentityKey.java new file mode 100644 index 000000000..f48fcbd7c --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/axolotl/IdentityKey.java @@ -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); + } +} diff --git a/src/main/java/im/conversations/android/xmpp/model/axolotl/PreKey.java b/src/main/java/im/conversations/android/xmpp/model/axolotl/PreKey.java new file mode 100644 index 000000000..b3c0eb35c --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/axolotl/PreKey.java @@ -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); + } +} diff --git a/src/main/java/im/conversations/android/xmpp/model/axolotl/PreKeys.java b/src/main/java/im/conversations/android/xmpp/model/axolotl/PreKeys.java new file mode 100644 index 000000000..3613b8aa8 --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/axolotl/PreKeys.java @@ -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); + } +} diff --git a/src/main/java/im/conversations/android/xmpp/model/axolotl/SignedPreKey.java b/src/main/java/im/conversations/android/xmpp/model/axolotl/SignedPreKey.java new file mode 100644 index 000000000..2db2c3e35 --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/axolotl/SignedPreKey.java @@ -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")); + } +} diff --git a/src/main/java/im/conversations/android/xmpp/model/axolotl/SignedPreKeySignature.java b/src/main/java/im/conversations/android/xmpp/model/axolotl/SignedPreKeySignature.java new file mode 100644 index 000000000..5051cb1b1 --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/axolotl/SignedPreKeySignature.java @@ -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); + } +} diff --git a/src/main/java/im/conversations/android/xmpp/model/axolotl/package-info.java b/src/main/java/im/conversations/android/xmpp/model/axolotl/package-info.java new file mode 100644 index 000000000..5019608d3 --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/axolotl/package-info.java @@ -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; diff --git a/src/main/java/im/conversations/android/xmpp/model/bookmark/Conference.java b/src/main/java/im/conversations/android/xmpp/model/bookmark/Conference.java new file mode 100644 index 000000000..97caf1371 --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/bookmark/Conference.java @@ -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"); + } +} diff --git a/src/main/java/im/conversations/android/xmpp/model/bookmark/package-info.java b/src/main/java/im/conversations/android/xmpp/model/bookmark/package-info.java new file mode 100644 index 000000000..cfd7bde90 --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/bookmark/package-info.java @@ -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; diff --git a/src/main/java/im/conversations/android/xmpp/model/error/Condition.java b/src/main/java/im/conversations/android/xmpp/model/error/Condition.java index 952a87594..8b6c8b73d 100644 --- a/src/main/java/im/conversations/android/xmpp/model/error/Condition.java +++ b/src/main/java/im/conversations/android/xmpp/model/error/Condition.java @@ -6,7 +6,7 @@ import im.conversations.android.xmpp.model.Extension; public abstract class Condition extends Extension { - public Condition(Class clazz) { + private Condition(Class clazz) { super(clazz); } diff --git a/src/main/java/im/conversations/android/xmpp/model/error/Error.java b/src/main/java/im/conversations/android/xmpp/model/error/Error.java index 73426c27c..4df78fe00 100644 --- a/src/main/java/im/conversations/android/xmpp/model/error/Error.java +++ b/src/main/java/im/conversations/android/xmpp/model/error/Error.java @@ -18,4 +18,8 @@ public class Error extends Extension { public void setCondition(final Condition condition) { this.addExtension(condition); } + + public Text getText() { + return this.getExtension(Text.class); + } } diff --git a/src/main/java/im/conversations/android/xmpp/model/error/Text.java b/src/main/java/im/conversations/android/xmpp/model/error/Text.java new file mode 100644 index 000000000..478b1f5cd --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/error/Text.java @@ -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); + } +} diff --git a/src/main/java/im/conversations/android/xmpp/model/nick/Nick.java b/src/main/java/im/conversations/android/xmpp/model/nick/Nick.java new file mode 100644 index 000000000..e9a985128 --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/nick/Nick.java @@ -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); + } +} diff --git a/src/main/java/im/conversations/android/xmpp/model/pubsub/Item.java b/src/main/java/im/conversations/android/xmpp/model/pubsub/Item.java new file mode 100644 index 000000000..dbf2c3c23 --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/pubsub/Item.java @@ -0,0 +1,10 @@ +package im.conversations.android.xmpp.model.pubsub; + +import im.conversations.android.xmpp.model.Extension; + +public interface Item { + + T getExtension(final Class clazz); + + String getId(); +} diff --git a/src/main/java/im/conversations/android/xmpp/model/pubsub/Items.java b/src/main/java/im/conversations/android/xmpp/model/pubsub/Items.java new file mode 100644 index 000000000..ceb1931ca --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/pubsub/Items.java @@ -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 getItems(); + + String getNode(); + + Collection getRetractions(); + + default Map getItemMap(final Class clazz) { + final ImmutableMap.Builder 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 getItemOrThrow(final String id, final Class 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 getFirstItem(final Class clazz) { + final var map = getItemMap(clazz); + return Iterables.getFirst(map.values(), null); + } + + default T getOnlyItem(final Class clazz) { + final var map = getItemMap(clazz); + return Iterables.getOnlyElement(map.values()); + } +} diff --git a/src/main/java/im/conversations/android/xmpp/model/pubsub/PubSub.java b/src/main/java/im/conversations/android/xmpp/model/pubsub/PubSub.java new file mode 100644 index 000000000..a4fc1ee8e --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/pubsub/PubSub.java @@ -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 getItems() { + return this.getExtensions(Item.class); + } + + public Collection 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); + } + } +} diff --git a/src/main/java/im/conversations/android/xmpp/model/pubsub/event/Event.java b/src/main/java/im/conversations/android/xmpp/model/pubsub/event/Event.java new file mode 100644 index 000000000..1e180c460 --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/pubsub/event/Event.java @@ -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 getItems() { + return this.getExtensions(Item.class); + } + + public Collection 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"); + } + } +} diff --git a/src/main/java/im/conversations/android/xmpp/model/pubsub/event/Purge.java b/src/main/java/im/conversations/android/xmpp/model/pubsub/event/Purge.java new file mode 100644 index 000000000..64550e0b7 --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/pubsub/event/Purge.java @@ -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"); + } +} diff --git a/src/main/java/im/conversations/android/xmpp/model/pubsub/event/Retract.java b/src/main/java/im/conversations/android/xmpp/model/pubsub/event/Retract.java new file mode 100644 index 000000000..139a49522 --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/pubsub/event/Retract.java @@ -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"); + } +} diff --git a/src/main/java/im/conversations/android/xmpp/model/pubsub/event/package-info.java b/src/main/java/im/conversations/android/xmpp/model/pubsub/event/package-info.java new file mode 100644 index 000000000..39e97cae1 --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/pubsub/event/package-info.java @@ -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; diff --git a/src/main/java/im/conversations/android/xmpp/model/pubsub/package-info.java b/src/main/java/im/conversations/android/xmpp/model/pubsub/package-info.java new file mode 100644 index 000000000..1309217b3 --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/pubsub/package-info.java @@ -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; diff --git a/src/main/java/im/conversations/android/xmpp/processor/BindProcessor.java b/src/main/java/im/conversations/android/xmpp/processor/BindProcessor.java index 4058caf25..71f474f80 100644 --- a/src/main/java/im/conversations/android/xmpp/processor/BindProcessor.java +++ b/src/main/java/im/conversations/android/xmpp/processor/BindProcessor.java @@ -5,6 +5,7 @@ import eu.siacs.conversations.xml.Namespace; import eu.siacs.conversations.xmpp.Jid; import im.conversations.android.xmpp.Entity; 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.BookmarkManager; import im.conversations.android.xmpp.manager.DiscoManager; @@ -47,6 +48,8 @@ public class BindProcessor extends XmppConnection.Delegate implements Consumer\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " JC\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " JC\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + ""; + 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()); + } +}