diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/StorageHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/StorageHelper.java index d01bf44b..93015188 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/StorageHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/StorageHelper.java @@ -4,13 +4,16 @@ import org.asamk.signal.manager.api.GroupIdV1; import org.asamk.signal.manager.api.GroupIdV2; import org.asamk.signal.manager.api.Pair; import org.asamk.signal.manager.api.Profile; +import org.asamk.signal.manager.api.StickerPackId; import org.asamk.signal.manager.internal.SignalDependencies; import org.asamk.signal.manager.storage.SignalAccount; import org.asamk.signal.manager.storage.recipients.RecipientId; +import org.asamk.signal.manager.storage.stickers.StickerPack; import org.asamk.signal.manager.syncStorage.AccountRecordProcessor; import org.asamk.signal.manager.syncStorage.ContactRecordProcessor; import org.asamk.signal.manager.syncStorage.GroupV1RecordProcessor; import org.asamk.signal.manager.syncStorage.GroupV2RecordProcessor; +import org.asamk.signal.manager.syncStorage.StickerPackRecordProcessor; import org.asamk.signal.manager.syncStorage.StorageSyncModels; import org.asamk.signal.manager.syncStorage.StorageSyncValidations; import org.asamk.signal.manager.syncStorage.WriteOperationResult; @@ -52,7 +55,8 @@ public class StorageHelper { private static final List KNOWN_TYPES = List.of(ManifestRecord.Identifier.Type.CONTACT.getValue(), ManifestRecord.Identifier.Type.GROUPV1.getValue(), ManifestRecord.Identifier.Type.GROUPV2.getValue(), - ManifestRecord.Identifier.Type.ACCOUNT.getValue()); + ManifestRecord.Identifier.Type.ACCOUNT.getValue(), + ManifestRecord.Identifier.Type.STICKER_PACK.getValue()); private final SignalAccount account; private final SignalDependencies dependencies; @@ -120,6 +124,7 @@ public class StorageHelper { logger.trace("Adding missing storageIds to local data"); account.getRecipientStore().setMissingStorageIds(); account.getGroupStore().setMissingStorageIds(); + account.getStickerStore().setMissingStorageIds(); var needsMultiDeviceSync = false; @@ -222,11 +227,14 @@ public class StorageHelper { final var updated = account.getRecipientStore() .removeStorageIdsFromLocalOnlyUnregisteredRecipients(connection, oldUnregisteredLocalOnlyIds); + final var updatedStickers = account.getStickerStore() + .removeStorageIdsFromLocalOnlyDeletedStickerPacks(connection, oldUnregisteredLocalOnlyIds); - if (updated > 0) { + if (updated > 0 || updatedStickers > 0) { logger.warn( - "Found {} records that were deleted remotely but only marked unregistered locally. Removed those from local store.", - updated); + "Found {} recipients and {} sticker packs that were deleted remotely but only marked deleted locally. Removed those from local store.", + updated, + updatedStickers); } } @@ -366,6 +374,7 @@ public class StorageHelper { final Map newContactStorageIds; final Map newGroupV1StorageIds; final Map newGroupV2StorageIds; + final Map newStickerPackStorageIds; try (final var connection = account.getAccountDatabase().getConnection()) { connection.setAutoCommit(false); @@ -412,6 +421,19 @@ public class StorageHelper { new StorageRecord.Builder().groupV2(record).build())); } + final var stickerPacks = account.getStickerStore() + .getStickerPacks(connection) + .stream() + .filter(pack -> pack.isInstalled() || pack.deletedTimestamp() > 0) + .toList(); + newStickerPackStorageIds = generateStickerPackStorageIds(stickerPacks); + for (final var stickerPack : stickerPacks) { + final var storageId = newStickerPackStorageIds.get(stickerPack.packId()); + final var record = StorageSyncModels.localToRemoteRecord(stickerPack); + newStorageRecords.add(new SignalStorageRecord(storageId, + new StorageRecord.Builder().stickerPack(record).build())); + } + connection.commit(); } catch (SQLException e) { throw new RuntimeException("Failed to sync remote storage", e); @@ -462,6 +484,7 @@ public class StorageHelper { connection.setAutoCommit(false); account.getRecipientStore().updateStorageIds(connection, newContactStorageIds); account.getGroupStore().updateStorageIds(connection, newGroupV1StorageIds, newGroupV2StorageIds); + account.getStickerStore().updateStorageIds(connection, newStickerPackStorageIds); // delete all unknown storage ids account.getUnknownStorageIdStore().deleteAllUnknownStorageIds(connection); @@ -494,6 +517,14 @@ public class StorageHelper { _ -> StorageId.forGroupV2(KeyUtils.createRawStorageId()))); } + private Map generateStickerPackStorageIds( + final List stickerPacks + ) { + return stickerPacks.stream() + .collect(Collectors.toMap(stickerPack -> stickerPack.packId(), + _ -> StorageId.forStickerPack(KeyUtils.createRawStorageId()))); + } + private void storeManifestLocally( final SignalStorageManifest remoteManifest ) { @@ -533,6 +564,7 @@ public class StorageHelper { storageIds.addAll(account.getUnknownStorageIdStore().getUnknownStorageIds(connection)); storageIds.addAll(account.getGroupStore().getStorageIds(connection)); storageIds.addAll(account.getRecipientStore().getStorageIds(connection)); + storageIds.addAll(account.getStickerStore().getStorageIds(connection)); storageIds.add(account.getRecipientStore().getSelfStorageId(connection)); return storageIds; } @@ -581,6 +613,14 @@ public class StorageHelper { account.getUsernameLink()); yield new SignalStorageRecord(storageId, new StorageRecord.Builder().account(record).build()); } + case ManifestRecord.Identifier.Type.STICKER_PACK -> { + final var stickerPack = account.getStickerStore().getStickerPack(connection, storageId); + if (stickerPack == null) { + throw new AssertionError("Missing local sticker pack model for storage id: " + storageId); + } + final var record = StorageSyncModels.localToRemoteRecord(stickerPack); + yield new SignalStorageRecord(storageId, new StorageRecord.Builder().stickerPack(record).build()); + } case null, default -> { throw new AssertionError("Got unknown local storage record type: " + storageId); } @@ -646,6 +686,7 @@ public class StorageHelper { final var groupV1RecordProcessor = new GroupV1RecordProcessor(account, connection); final var groupV2RecordProcessor = new GroupV2RecordProcessor(account, connection); final var contactRecordProcessor = new ContactRecordProcessor(account, connection, context.getJobExecutor()); + final var stickerPackRecordProcessor = new StickerPackRecordProcessor(account, connection); for (final var record : records) { if (record.getProto().account != null) { @@ -664,6 +705,10 @@ public class StorageHelper { logger.debug("Reading record {} of type contact", record.getId()); contactRecordProcessor.process(StorageRecordConvertersKt.toSignalContactRecord(record.getProto().contact, record.getId())); + } else if (record.getProto().stickerPack != null) { + logger.debug("Reading record {} of type stickerPack", record.getId()); + stickerPackRecordProcessor.process(StorageRecordConvertersKt.toSignalStickerPackRecord(record.getProto().stickerPack, + record.getId())); } else { unknownRecords.add(record.getId()); } @@ -672,6 +717,7 @@ public class StorageHelper { processedRecords.addAll(groupV1RecordProcessor.getUpdatedStorageIds()); processedRecords.addAll(groupV2RecordProcessor.getUpdatedStorageIds()); processedRecords.addAll(contactRecordProcessor.getUpdatedStorageIds()); + processedRecords.addAll(stickerPackRecordProcessor.getUpdatedStorageIds()); return new Pair<>(unknownRecords, processedRecords); } diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/AccountDatabase.java b/lib/src/main/java/org/asamk/signal/manager/storage/AccountDatabase.java index 8cc5e74a..43f00dd0 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/AccountDatabase.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/AccountDatabase.java @@ -33,7 +33,7 @@ import java.util.UUID; public class AccountDatabase extends Database { private static final Logger logger = LoggerFactory.getLogger(AccountDatabase.class); - private static final long DATABASE_VERSION = 28; + private static final long DATABASE_VERSION = 29; private AccountDatabase(final HikariDataSource dataSource) { super(logger, DATABASE_VERSION, dataSource); @@ -623,6 +623,18 @@ public class AccountDatabase extends Database { """); } } + if (oldVersion < 29) { + logger.debug("Updating database: Adding sticker storage sync columns"); + try (final var statement = connection.createStatement()) { + statement.executeUpdate(""" + ALTER TABLE sticker ADD COLUMN position INTEGER NOT NULL DEFAULT 0; + ALTER TABLE sticker ADD COLUMN deleted_timestamp INTEGER NOT NULL DEFAULT 0; + ALTER TABLE sticker ADD COLUMN storage_id BLOB; + ALTER TABLE sticker ADD COLUMN storage_record BLOB; + CREATE UNIQUE INDEX sticker_storage_id_index ON sticker (storage_id); + """); + } + } } private static void createUuidMappingTable( diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/stickers/StickerPack.java b/lib/src/main/java/org/asamk/signal/manager/storage/stickers/StickerPack.java index a7e1adc0..1322bf64 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/stickers/StickerPack.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/stickers/StickerPack.java @@ -1,10 +1,29 @@ package org.asamk.signal.manager.storage.stickers; import org.asamk.signal.manager.api.StickerPackId; +import org.whispersystems.signalservice.api.storage.StorageId; -public record StickerPack(long internalId, StickerPackId packId, byte[] packKey, boolean isInstalled) { +public record StickerPack( + long internalId, + StickerPackId packId, + byte[] packKey, + boolean isInstalled, + int position, + long deletedTimestamp, + StorageId storageId, + byte[] storageRecord +) { + + public StickerPack( + final long internalId, + final StickerPackId packId, + final byte[] packKey, + final boolean isInstalled + ) { + this(internalId, packId, packKey, isInstalled, 0, 0, null, null); + } public StickerPack(final StickerPackId packId, final byte[] packKey) { - this(-1, packId, packKey, false); + this(-1, packId, packKey, false, 0, 0, null, null); } } diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/stickers/StickerStore.java b/lib/src/main/java/org/asamk/signal/manager/storage/stickers/StickerStore.java index 32223424..c5289cd1 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/stickers/StickerStore.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/stickers/StickerStore.java @@ -3,14 +3,19 @@ package org.asamk.signal.manager.storage.stickers; import org.asamk.signal.manager.api.StickerPackId; import org.asamk.signal.manager.storage.Database; import org.asamk.signal.manager.storage.Utils; +import org.asamk.signal.manager.util.KeyUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.whispersystems.signalservice.api.storage.SignalStickerPackRecord; +import org.whispersystems.signalservice.api.storage.StorageId; import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Types; import java.util.Collection; import java.util.List; +import java.util.Map; public class StickerStore { @@ -27,7 +32,11 @@ public class StickerStore { _id INTEGER PRIMARY KEY, pack_id BLOB UNIQUE NOT NULL, pack_key BLOB NOT NULL, - installed INTEGER NOT NULL DEFAULT FALSE + installed INTEGER NOT NULL DEFAULT FALSE, + position INTEGER NOT NULL DEFAULT 0, + deleted_timestamp INTEGER NOT NULL DEFAULT 0, + storage_id BLOB UNIQUE, + storage_record BLOB ) STRICT; """); } @@ -38,55 +47,103 @@ public class StickerStore { } public List getStickerPacks() { - final var sql = ( - """ - SELECT s._id, s.pack_id, s.pack_key, s.installed - FROM %s s - """ - ).formatted(TABLE_STICKER); try (final var connection = database.getConnection()) { - try (final var statement = connection.prepareStatement(sql)) { - try (var result = Utils.executeQueryForStream(statement, this::getStickerPackFromResultSet)) { - return result.toList(); - } - } + return getStickerPacks(connection); } catch (SQLException e) { throw new RuntimeException("Failed read from sticker store", e); } } - public StickerPack getStickerPack(StickerPackId packId) { + public List getStickerPacks(final Connection connection) throws SQLException { final var sql = ( """ - SELECT s._id, s.pack_id, s.pack_key, s.installed + SELECT s._id, s.pack_id, s.pack_key, s.installed, s.position, s.deleted_timestamp, s.storage_id, s.storage_record + FROM %s s + """ + ).formatted(TABLE_STICKER); + try (final var statement = connection.prepareStatement(sql)) { + try (var result = Utils.executeQueryForStream(statement, this::getStickerPackFromResultSet)) { + return result.toList(); + } + } + } + + public StickerPack getStickerPack(StickerPackId packId) { + try (final var connection = database.getConnection()) { + return getStickerPack(connection, packId); + } catch (SQLException e) { + throw new RuntimeException("Failed read from sticker store", e); + } + } + + public StickerPack getStickerPack(Connection connection, StickerPackId packId) throws SQLException { + final var sql = ( + """ + SELECT s._id, s.pack_id, s.pack_key, s.installed, s.position, s.deleted_timestamp, s.storage_id, s.storage_record FROM %s s WHERE s.pack_id = ? """ ).formatted(TABLE_STICKER); - try (final var connection = database.getConnection()) { - try (final var statement = connection.prepareStatement(sql)) { - statement.setBytes(1, packId.serialize()); - return Utils.executeQueryForOptional(statement, this::getStickerPackFromResultSet).orElse(null); - } - } catch (SQLException e) { - throw new RuntimeException("Failed read from sticker store", e); + try (final var statement = connection.prepareStatement(sql)) { + statement.setBytes(1, packId.serialize()); + return Utils.executeQueryForOptional(statement, this::getStickerPackFromResultSet).orElse(null); + } + } + + public StickerPack getStickerPack(Connection connection, StorageId storageId) throws SQLException { + final var sql = ( + """ + SELECT s._id, s.pack_id, s.pack_key, s.installed, s.position, s.deleted_timestamp, s.storage_id, s.storage_record + FROM %s s + WHERE s.storage_id = ? + """ + ).formatted(TABLE_STICKER); + try (final var statement = connection.prepareStatement(sql)) { + statement.setBytes(1, storageId.getRaw()); + return Utils.executeQueryForOptional(statement, this::getStickerPackFromResultSet).orElse(null); } } public void addStickerPack(StickerPack stickerPack) { final var sql = ( """ - INSERT INTO %s (pack_id, pack_key, installed) - VALUES (?, ?, ?) + INSERT INTO %s (pack_id, pack_key, installed, position, deleted_timestamp, storage_id, storage_record) + VALUES (?, ?, ?, ?, ?, ?, ?) """ ).formatted(TABLE_STICKER); try (final var connection = database.getConnection()) { + connection.setAutoCommit(false); + var storageId = stickerPack.storageId(); + if (storageId == null && (stickerPack.isInstalled() || stickerPack.deletedTimestamp() > 0)) { + storageId = StorageId.forStickerPack(KeyUtils.createRawStorageId()); + } + + final var position = stickerPack.isInstalled() ? Math.max(stickerPack.position(), + getNextPosition(connection)) : 0; + var deletedTimestamp = stickerPack.deletedTimestamp(); + if (!stickerPack.isInstalled() && deletedTimestamp == 0 && storageId != null) { + deletedTimestamp = System.currentTimeMillis(); + } + try (final var statement = connection.prepareStatement(sql)) { statement.setBytes(1, stickerPack.packId().serialize()); statement.setBytes(2, stickerPack.packKey()); statement.setBoolean(3, stickerPack.isInstalled()); + statement.setInt(4, position); + statement.setLong(5, deletedTimestamp); + if (storageId == null) { + statement.setNull(6, Types.BLOB); + } else { + statement.setBytes(6, storageId.getRaw()); + } + if (stickerPack.storageRecord() == null) { + statement.setNull(7, Types.BLOB); + } else { + statement.setBytes(7, stickerPack.storageRecord()); + } statement.executeUpdate(); } + connection.commit(); } catch (SQLException e) { throw new RuntimeException("Failed update sticker store", e); } @@ -96,28 +153,279 @@ public class StickerStore { final var sql = ( """ UPDATE %s - SET installed = ? + SET installed = ?, position = ?, deleted_timestamp = ?, storage_id = ? WHERE pack_id = ? """ ).formatted(TABLE_STICKER); try (final var connection = database.getConnection()) { + connection.setAutoCommit(false); + final var existing = getStickerPack(connection, stickerPackId); + if (existing == null || existing.isInstalled() == installed) { + connection.commit(); + return; + } + + final var newStorageId = StorageId.forStickerPack(KeyUtils.createRawStorageId()); + final var position = installed ? getNextPosition(connection) : 0; + final var deletedTimestamp = installed ? 0 : System.currentTimeMillis(); + try (final var statement = connection.prepareStatement(sql)) { - statement.setBytes(1, stickerPackId.serialize()); - statement.setBoolean(2, installed); + statement.setBoolean(1, installed); + statement.setInt(2, position); + statement.setLong(3, deletedTimestamp); + statement.setBytes(4, newStorageId.getRaw()); + statement.setBytes(5, stickerPackId.serialize()); statement.executeUpdate(); } + connection.commit(); } catch (SQLException e) { throw new RuntimeException("Failed update sticker store", e); } } + public List getStorageIds(final Connection connection) throws SQLException { + final var sql = ( + """ + SELECT s.storage_id + FROM %s s + WHERE s.storage_id IS NOT NULL + """ + ).formatted(TABLE_STICKER); + try (final var statement = connection.prepareStatement(sql)) { + return Utils.executeQueryForStream(statement, this::getStorageIdFromResultSet).toList(); + } + } + + public void updateStorageId( + final Connection connection, + final StickerPackId packId, + final StorageId storageId + ) throws SQLException { + final var sql = ( + """ + UPDATE %s + SET storage_id = ? + WHERE pack_id = ? + """ + ).formatted(TABLE_STICKER); + try (final var statement = connection.prepareStatement(sql)) { + statement.setBytes(1, storageId.getRaw()); + statement.setBytes(2, packId.serialize()); + statement.executeUpdate(); + } + } + + public void updateStorageIds( + final Connection connection, + final Map storageIdMap + ) throws SQLException { + final var sql = ( + """ + UPDATE %s + SET storage_id = ? + WHERE pack_id = ? + """ + ).formatted(TABLE_STICKER); + try (final var statement = connection.prepareStatement(sql)) { + for (final var entry : storageIdMap.entrySet()) { + statement.setBytes(1, entry.getValue().getRaw()); + statement.setBytes(2, entry.getKey().serialize()); + statement.executeUpdate(); + } + } + } + + public StorageId getStorageId(final Connection connection, final StickerPackId packId) throws SQLException { + final var sql = ( + """ + SELECT s.storage_id + FROM %s s + WHERE s.pack_id = ? AND s.storage_id IS NOT NULL + """ + ).formatted(TABLE_STICKER); + try (final var statement = connection.prepareStatement(sql)) { + statement.setBytes(1, packId.serialize()); + final var storageId = Utils.executeQueryForOptional(statement, this::getStorageIdFromResultSet); + if (storageId.isPresent()) { + return storageId.get(); + } + } + + final var newStorageId = StorageId.forStickerPack(KeyUtils.createRawStorageId()); + updateStorageId(connection, packId, newStorageId); + return newStorageId; + } + + public void storeStorageRecord( + final Connection connection, + final StickerPackId packId, + final StorageId storageId, + final byte[] storageRecord + ) throws SQLException { + final var clearSql = ( + """ + UPDATE %s + SET storage_id = NULL + WHERE storage_id = ? + """ + ).formatted(TABLE_STICKER); + try (final var statement = connection.prepareStatement(clearSql)) { + statement.setBytes(1, storageId.getRaw()); + statement.executeUpdate(); + } + + final var updateSql = ( + """ + UPDATE %s + SET storage_id = ?, storage_record = ? + WHERE pack_id = ? + """ + ).formatted(TABLE_STICKER); + try (final var statement = connection.prepareStatement(updateSql)) { + statement.setBytes(1, storageId.getRaw()); + if (storageRecord == null) { + statement.setNull(2, Types.BLOB); + } else { + statement.setBytes(2, storageRecord); + } + statement.setBytes(3, packId.serialize()); + statement.executeUpdate(); + } + } + + public void setMissingStorageIds() { + final var selectSql = ( + """ + SELECT s.pack_id + FROM %s s + WHERE s.storage_id IS NULL AND (s.installed = TRUE OR s.deleted_timestamp > 0) + """ + ).formatted(TABLE_STICKER); + final var updateSql = ( + """ + UPDATE %s + SET storage_id = ? + WHERE pack_id = ? + """ + ).formatted(TABLE_STICKER); + + try (final var connection = database.getConnection()) { + connection.setAutoCommit(false); + try (final var selectStatement = connection.prepareStatement(selectSql)) { + final var packIds = Utils.executeQueryForStream(selectStatement, + resultSet -> StickerPackId.deserialize(resultSet.getBytes("pack_id"))).toList(); + try (final var updateStatement = connection.prepareStatement(updateSql)) { + for (final var packId : packIds) { + updateStatement.setBytes(1, KeyUtils.createRawStorageId()); + updateStatement.setBytes(2, packId.serialize()); + updateStatement.executeUpdate(); + } + } + } + connection.commit(); + } catch (SQLException e) { + throw new RuntimeException("Failed update sticker store", e); + } + } + + public int removeStorageIdsFromLocalOnlyDeletedStickerPacks( + final Connection connection, + final Collection storageIds + ) throws SQLException { + final var sql = ( + """ + UPDATE %s + SET storage_id = NULL + WHERE storage_id = ? AND installed = FALSE AND deleted_timestamp > 0 + """ + ).formatted(TABLE_STICKER); + var count = 0; + try (final var statement = connection.prepareStatement(sql)) { + for (final var storageId : storageIds) { + statement.setBytes(1, storageId.getRaw()); + count += statement.executeUpdate(); + } + } + return count; + } + + public void upsertFromStorageSync( + final Connection connection, + final SignalStickerPackRecord record + ) throws SQLException { + final var remote = record.getProto(); + final var packId = StickerPackId.deserialize(remote.packId.toByteArray()); + final var deleted = remote.deletedAtTimestamp > 0; + final var packKey = remote.packKey.toByteArray(); + final var storageRecord = remote.encode(); + + final var current = getStickerPack(connection, packId); + + if (current == null) { + final var insertSql = ( + """ + INSERT INTO %s (pack_id, pack_key, installed, position, deleted_timestamp, storage_id, storage_record) + VALUES (?, ?, ?, ?, ?, ?, ?) + """ + ).formatted(TABLE_STICKER); + try (final var statement = connection.prepareStatement(insertSql)) { + statement.setBytes(1, packId.serialize()); + statement.setBytes(2, packKey); + statement.setBoolean(3, !deleted); + statement.setInt(4, deleted ? 0 : remote.position); + statement.setLong(5, remote.deletedAtTimestamp); + statement.setBytes(6, record.getId().getRaw()); + statement.setBytes(7, storageRecord); + statement.executeUpdate(); + } + return; + } + + if (packKey.length > 0) { + final var updateSql = ( + """ + UPDATE %s + SET pack_key = ?, installed = ?, position = ?, deleted_timestamp = ?, storage_id = ?, storage_record = ? + WHERE pack_id = ? + """ + ).formatted(TABLE_STICKER); + try (final var statement = connection.prepareStatement(updateSql)) { + statement.setBytes(1, packKey); + statement.setBoolean(2, !deleted); + statement.setInt(3, deleted ? 0 : remote.position); + statement.setLong(4, remote.deletedAtTimestamp); + statement.setBytes(5, record.getId().getRaw()); + statement.setBytes(6, storageRecord); + statement.setBytes(7, packId.serialize()); + statement.executeUpdate(); + } + } else { + final var updateSql = ( + """ + UPDATE %s + SET installed = ?, position = ?, deleted_timestamp = ?, storage_id = ?, storage_record = ? + WHERE pack_id = ? + """ + ).formatted(TABLE_STICKER); + try (final var statement = connection.prepareStatement(updateSql)) { + statement.setBoolean(1, !deleted); + statement.setInt(2, deleted ? 0 : remote.position); + statement.setLong(3, remote.deletedAtTimestamp); + statement.setBytes(4, record.getId().getRaw()); + statement.setBytes(5, storageRecord); + statement.setBytes(6, packId.serialize()); + statement.executeUpdate(); + } + } + } + void addLegacyStickers(Collection stickerPacks) { logger.debug("Migrating legacy stickers to database"); long start = System.nanoTime(); final var sql = ( """ - INSERT INTO %s (pack_id, pack_key, installed) - VALUES (?, ?, ?) + INSERT INTO %s (pack_id, pack_key, installed, position, deleted_timestamp, storage_id, storage_record) + VALUES (?, ?, ?, ?, ?, ?, ?) """ ).formatted(TABLE_STICKER); try (final var connection = database.getConnection()) { @@ -125,11 +433,23 @@ public class StickerStore { try (final var statement = connection.prepareStatement("DELETE FROM %s".formatted(TABLE_STICKER))) { statement.executeUpdate(); } + var installedPosition = 0; try (final var statement = connection.prepareStatement(sql)) { for (final var sticker : stickerPacks) { + final var storageId = sticker.isInstalled() + ? StorageId.forStickerPack(KeyUtils.createRawStorageId()) + : null; statement.setBytes(1, sticker.packId().serialize()); statement.setBytes(2, sticker.packKey()); statement.setBoolean(3, sticker.isInstalled()); + statement.setInt(4, sticker.isInstalled() ? installedPosition++ : 0); + statement.setLong(5, 0); + if (storageId == null) { + statement.setNull(6, Types.BLOB); + } else { + statement.setBytes(6, storageId.getRaw()); + } + statement.setNull(7, Types.BLOB); statement.executeUpdate(); } } @@ -145,6 +465,36 @@ public class StickerStore { final var packId = resultSet.getBytes("pack_id"); final var packKey = resultSet.getBytes("pack_key"); final var installed = resultSet.getBoolean("installed"); - return new StickerPack(internalId, StickerPackId.deserialize(packId), packKey, installed); + final var position = resultSet.getInt("position"); + final var deletedTimestamp = resultSet.getLong("deleted_timestamp"); + final var storageIdBytes = resultSet.getBytes("storage_id"); + final var storageId = storageIdBytes == null ? null : StorageId.forStickerPack(storageIdBytes); + final var storageRecord = resultSet.getBytes("storage_record"); + return new StickerPack(internalId, + StickerPackId.deserialize(packId), + packKey, + installed, + position, + deletedTimestamp, + storageId, + storageRecord); + } + + private StorageId getStorageIdFromResultSet(final ResultSet resultSet) throws SQLException { + final var storageId = resultSet.getBytes("storage_id"); + return StorageId.forStickerPack(storageId); + } + + private int getNextPosition(final Connection connection) throws SQLException { + final var sql = ( + """ + SELECT IFNULL(MAX(position) + 1, 0) AS next_position + FROM %s + WHERE installed = TRUE + """ + ).formatted(TABLE_STICKER); + try (final var statement = connection.prepareStatement(sql)) { + return Utils.executeQuerySingleRow(statement, resultSet -> resultSet.getInt("next_position")); + } } } diff --git a/lib/src/main/java/org/asamk/signal/manager/syncStorage/StickerPackRecordProcessor.java b/lib/src/main/java/org/asamk/signal/manager/syncStorage/StickerPackRecordProcessor.java new file mode 100644 index 00000000..8f1b3a29 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/syncStorage/StickerPackRecordProcessor.java @@ -0,0 +1,86 @@ +package org.asamk.signal.manager.syncStorage; + +import org.asamk.signal.manager.api.StickerPackId; +import org.asamk.signal.manager.storage.SignalAccount; +import org.asamk.signal.manager.util.KeyUtils; +import org.whispersystems.signalservice.api.storage.SignalStickerPackRecord; +import org.whispersystems.signalservice.api.storage.StorageId; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.Optional; + +public class StickerPackRecordProcessor extends DefaultStorageRecordProcessor { + + private static final int PACK_ID_LENGTH = 16; + private static final int PACK_KEY_LENGTH = 32; + + private final SignalAccount account; + private final Connection connection; + + public StickerPackRecordProcessor(final SignalAccount account, final Connection connection) { + this.account = account; + this.connection = connection; + } + + @Override + public int compare(final SignalStickerPackRecord lhs, final SignalStickerPackRecord rhs) { + return lhs.getProto().packId.equals(rhs.getProto().packId) ? 0 : 1; + } + + @Override + protected boolean isInvalid(final SignalStickerPackRecord remote) { + return remote.getProto().packId.size() != PACK_ID_LENGTH || ( + remote.getProto().deletedAtTimestamp == 0 + && remote.getProto().packKey.size() != PACK_KEY_LENGTH + ); + } + + @Override + protected Optional getMatching(final SignalStickerPackRecord remote) throws SQLException { + final var packId = StickerPackId.deserialize(remote.getProto().packId.toByteArray()); + final var local = account.getStickerStore().getStickerPack(connection, packId); + + if (local == null || (!local.isInstalled() && local.deletedTimestamp() == 0)) { + return Optional.empty(); + } + + final StorageId storageId; + if (local.storageId() != null) { + storageId = local.storageId(); + } else { + storageId = StorageId.forStickerPack(KeyUtils.createRawStorageId()); + account.getStickerStore().updateStorageId(connection, packId, storageId); + } + + return Optional.of(new SignalStickerPackRecord(storageId, StorageSyncModels.localToRemoteRecord(local))); + } + + @Override + protected SignalStickerPackRecord merge( + final SignalStickerPackRecord remoteRecord, + final SignalStickerPackRecord localRecord + ) { + final var remote = remoteRecord.getProto(); + final var local = localRecord.getProto(); + + final var isRemoteDeleted = remote.deletedAtTimestamp > 0; + final var isLocalDeleted = local.deletedAtTimestamp > 0; + + if (isRemoteDeleted && isLocalDeleted && local.deletedAtTimestamp > remote.deletedAtTimestamp) { + return localRecord; + } + + return remoteRecord; + } + + @Override + protected void insertLocal(final SignalStickerPackRecord record) throws SQLException { + account.getStickerStore().upsertFromStorageSync(connection, record); + } + + @Override + protected void updateLocal(final StorageRecordUpdate update) throws SQLException { + account.getStickerStore().upsertFromStorageSync(connection, update.newRecord()); + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/syncStorage/StorageSyncModels.java b/lib/src/main/java/org/asamk/signal/manager/syncStorage/StorageSyncModels.java index 3811b548..a78378cb 100644 --- a/lib/src/main/java/org/asamk/signal/manager/syncStorage/StorageSyncModels.java +++ b/lib/src/main/java/org/asamk/signal/manager/syncStorage/StorageSyncModels.java @@ -8,6 +8,7 @@ import org.asamk.signal.manager.storage.groups.GroupInfoV1; import org.asamk.signal.manager.storage.groups.GroupInfoV2; import org.asamk.signal.manager.storage.identities.IdentityInfo; import org.asamk.signal.manager.storage.recipients.Recipient; +import org.asamk.signal.manager.storage.stickers.StickerPack; import org.signal.core.models.ServiceId.ACI; import org.signal.core.models.ServiceId.PNI; import org.signal.core.util.UuidUtil; @@ -16,12 +17,14 @@ import org.whispersystems.signalservice.api.storage.SignalAccountRecord; import org.whispersystems.signalservice.api.storage.SignalContactRecord; import org.whispersystems.signalservice.api.storage.SignalGroupV1Record; import org.whispersystems.signalservice.api.storage.SignalGroupV2Record; +import org.whispersystems.signalservice.api.storage.SignalStickerPackRecord; import org.whispersystems.signalservice.internal.storage.protos.AccountRecord; import org.whispersystems.signalservice.internal.storage.protos.AccountRecord.UsernameLink; import org.whispersystems.signalservice.internal.storage.protos.ContactRecord; import org.whispersystems.signalservice.internal.storage.protos.ContactRecord.IdentityState; import org.whispersystems.signalservice.internal.storage.protos.GroupV1Record; import org.whispersystems.signalservice.internal.storage.protos.GroupV2Record; +import org.whispersystems.signalservice.internal.storage.protos.StickerPackRecord; import java.sql.Connection; import java.sql.SQLException; @@ -162,6 +165,23 @@ public final class StorageSyncModels { return builder.build(); } + public static StickerPackRecord localToRemoteRecord(StickerPack stickerPack) { + final var builder = SignalStickerPackRecord.Companion.newBuilder(stickerPack.storageRecord()); + builder.packId(ByteString.of(stickerPack.packId().serialize())); + + if (stickerPack.deletedTimestamp() > 0) { + builder.packKey(ByteString.EMPTY); + builder.position(0); + builder.deletedAtTimestamp(stickerPack.deletedTimestamp()); + } else { + builder.packKey(ByteString.of(stickerPack.packKey())); + builder.position(stickerPack.position()); + builder.deletedAtTimestamp(0); + } + + return builder.build(); + } + public static TrustLevel remoteToLocal(IdentityState identityState) { return switch (identityState) { case DEFAULT -> TrustLevel.TRUSTED_UNVERIFIED; diff --git a/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json b/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json index 1d4c02cd..3d0b6ac4 100644 --- a/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json +++ b/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json @@ -9708,6 +9708,29 @@ } ] }, + { + "type": "org.whispersystems.signalservice.internal.storage.protos.StickerPackRecord", + "fields": [ + { + "name": "deletedAtTimestamp" + }, + { + "name": "packId" + }, + { + "name": "packKey" + }, + { + "name": "position" + } + ] + }, + { + "type": "org.whispersystems.signalservice.internal.storage.protos.StickerPackRecord$Builder" + }, + { + "type": "org.whispersystems.signalservice.internal.storage.protos.StickerPackRecord$Companion" + }, { "type": "org.whispersystems.signalservice.internal.storage.protos.StorageItem", "fields": [