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 8438cdba..d3236098 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 @@ -7,12 +7,14 @@ 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.notificationProfiles.NotificationProfile; 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.NotificationProfileRecordProcessor; import org.asamk.signal.manager.syncStorage.StickerPackRecordProcessor; import org.asamk.signal.manager.syncStorage.StorageSyncLoopDetector; import org.asamk.signal.manager.syncStorage.StorageSyncModels; @@ -58,7 +60,8 @@ public class StorageHelper { ManifestRecord.Identifier.Type.GROUPV1.getValue(), ManifestRecord.Identifier.Type.GROUPV2.getValue(), ManifestRecord.Identifier.Type.ACCOUNT.getValue(), - ManifestRecord.Identifier.Type.STICKER_PACK.getValue()); + ManifestRecord.Identifier.Type.STICKER_PACK.getValue(), + ManifestRecord.Identifier.Type.NOTIFICATION_PROFILE.getValue()); private final SignalAccount account; private final SignalDependencies dependencies; @@ -134,6 +137,7 @@ public class StorageHelper { account.getRecipientStore().setMissingStorageIds(); account.getGroupStore().setMissingStorageIds(); account.getStickerStore().setMissingStorageIds(); + account.getNotificationProfileStore().setMissingStorageIds(); var needsMultiDeviceSync = false; @@ -245,12 +249,15 @@ public class StorageHelper { oldUnregisteredLocalOnlyIds); final var updatedStickers = account.getStickerStore() .removeStorageIdsFromLocalOnlyDeletedStickerPacks(connection, oldUnregisteredLocalOnlyIds); + final var updatedNotificationProfiles = account.getNotificationProfileStore() + .removeLocalOnlyDeletedNotificationProfiles(connection, oldUnregisteredLocalOnlyIds); - if (updated > 0 || updatedStickers > 0) { + if (updated > 0 || updatedStickers > 0 || updatedNotificationProfiles > 0) { logger.warn( - "Found {} recipients and {} sticker packs that were deleted remotely but only marked deleted locally. Removed those from local store.", + "Found {} recipients, {} sticker packs and {} notification profiles that were deleted remotely but only marked deleted locally. Removed those from local store.", updated, - updatedStickers); + updatedStickers, + updatedNotificationProfiles); } } @@ -436,6 +443,7 @@ public class StorageHelper { final Map newGroupV1StorageIds; final Map newGroupV2StorageIds; final Map newStickerPackStorageIds; + final Map newNotificationProfileStorageIds; try (final var connection = account.getAccountDatabase().getConnection()) { connection.setAutoCommit(false); @@ -495,6 +503,16 @@ public class StorageHelper { new StorageRecord.Builder().stickerPack(record).build())); } + final var notificationProfiles = account.getNotificationProfileStore() + .getNotificationProfiles(connection); + newNotificationProfileStorageIds = generateNotificationProfileStorageIds(notificationProfiles); + for (final var notificationProfile : notificationProfiles) { + final var storageId = newNotificationProfileStorageIds.get(notificationProfile.internalId()); + final var record = StorageSyncModels.localToRemoteRecord(notificationProfile); + newStorageRecords.add(new SignalStorageRecord(storageId, + new StorageRecord.Builder().notificationProfile(record).build())); + } + connection.commit(); } catch (SQLException e) { throw new RuntimeException("Failed to sync remote storage", e); @@ -546,6 +564,7 @@ public class StorageHelper { account.getRecipientStore().updateStorageIds(connection, newContactStorageIds); account.getGroupStore().updateStorageIds(connection, newGroupV1StorageIds, newGroupV2StorageIds); account.getStickerStore().updateStorageIds(connection, newStickerPackStorageIds); + account.getNotificationProfileStore().updateStorageIds(connection, newNotificationProfileStorageIds); // delete all unknown storage ids account.getUnknownStorageIdStore().deleteAllUnknownStorageIds(connection); @@ -586,6 +605,14 @@ public class StorageHelper { _ -> StorageId.forStickerPack(KeyUtils.createRawStorageId()))); } + private Map generateNotificationProfileStorageIds( + final List notificationProfiles + ) { + return notificationProfiles.stream() + .collect(Collectors.toMap(NotificationProfile::internalId, + _ -> StorageId.forNotificationProfile(KeyUtils.createRawStorageId()))); + } + private void storeManifestLocally( final SignalStorageManifest remoteManifest ) { @@ -626,6 +653,7 @@ public class StorageHelper { storageIds.addAll(account.getGroupStore().getStorageIds(connection)); storageIds.addAll(account.getRecipientStore().getStorageIds(connection)); storageIds.addAll(account.getStickerStore().getStorageIds(connection)); + storageIds.addAll(account.getNotificationProfileStore().getStorageIds(connection)); storageIds.add(account.getRecipientStore().getSelfStorageId(connection)); return storageIds; } @@ -682,6 +710,16 @@ public class StorageHelper { final var record = StorageSyncModels.localToRemoteRecord(stickerPack); yield new SignalStorageRecord(storageId, new StorageRecord.Builder().stickerPack(record).build()); } + case ManifestRecord.Identifier.Type.NOTIFICATION_PROFILE -> { + final var notificationProfile = account.getNotificationProfileStore() + .getNotificationProfile(connection, storageId); + if (notificationProfile == null) { + throw new AssertionError("Missing local notification profile model for storage id: " + storageId); + } + final var record = StorageSyncModels.localToRemoteRecord(notificationProfile); + yield new SignalStorageRecord(storageId, + new StorageRecord.Builder().notificationProfile(record).build()); + } case null, default -> { throw new AssertionError("Got unknown local storage record type: " + storageId); } @@ -752,6 +790,7 @@ public class StorageHelper { context.getJobExecutor(), identityConflictsPendingRepair); final var stickerPackRecordProcessor = new StickerPackRecordProcessor(account, connection); + final var notificationProfileRecordProcessor = new NotificationProfileRecordProcessor(account, connection); final var contactRecords = records.stream() .filter(record -> record.getProto().contact != null) @@ -781,6 +820,11 @@ public class StorageHelper { logger.debug("Reading record {} of type stickerPack", record.getId()); stickerPackRecordProcessor.process(StorageRecordConvertersKt.toSignalStickerPackRecord(record.getProto().stickerPack, record.getId())); + } else if (record.getProto().notificationProfile != null) { + logger.debug("Reading record {} of type notificationProfile", record.getId()); + notificationProfileRecordProcessor.process(StorageRecordConvertersKt.toSignalNotificationProfileRecord( + record.getProto().notificationProfile, + record.getId())); } else { unknownRecords.add(record.getId()); } @@ -790,6 +834,7 @@ public class StorageHelper { processedRecords.addAll(groupV2RecordProcessor.getUpdatedStorageIds()); processedRecords.addAll(contactRecordProcessor.getUpdatedStorageIds()); processedRecords.addAll(stickerPackRecordProcessor.getUpdatedStorageIds()); + processedRecords.addAll(notificationProfileRecordProcessor.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 105422ab..13a0d685 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 @@ -6,6 +6,7 @@ import org.asamk.signal.manager.api.Pair; import org.asamk.signal.manager.storage.groups.GroupStore; import org.asamk.signal.manager.storage.identities.IdentityKeyStore; import org.asamk.signal.manager.storage.keyValue.KeyValueStore; +import org.asamk.signal.manager.storage.notificationProfiles.NotificationProfileStore; import org.asamk.signal.manager.storage.prekeys.KyberPreKeyStore; import org.asamk.signal.manager.storage.prekeys.PreKeyStore; import org.asamk.signal.manager.storage.prekeys.SignedPreKeyStore; @@ -33,7 +34,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 = 31; + private static final long DATABASE_VERSION = 32; private AccountDatabase(final HikariDataSource dataSource) { super(logger, DATABASE_VERSION, dataSource); @@ -59,6 +60,7 @@ public class AccountDatabase extends Database { KeyValueStore.createSql(connection); CdsiStore.createSql(connection); UnknownStorageIdStore.createSql(connection); + NotificationProfileStore.createSql(connection); } @Override @@ -653,6 +655,21 @@ public class AccountDatabase extends Database { """); } } + if (oldVersion < 32) { + logger.debug("Updating database: Creating notification profile table"); + try (final var statement = connection.createStatement()) { + statement.executeUpdate(""" + CREATE TABLE notification_profile ( + _id INTEGER PRIMARY KEY, + profile_id BLOB UNIQUE NOT NULL, + name TEXT NOT NULL, + deleted_timestamp INTEGER NOT NULL DEFAULT 0, + storage_id BLOB UNIQUE, + storage_record BLOB + ) STRICT; + """); + } + } } private static void createUuidMappingTable( diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java b/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java index 77511d6e..4c41c531 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java @@ -49,6 +49,7 @@ import org.asamk.signal.manager.storage.senderKeys.SenderKeyStore; import org.asamk.signal.manager.storage.sessions.LegacySessionStore; import org.asamk.signal.manager.storage.sessions.SessionStore; import org.asamk.signal.manager.storage.stickers.LegacyStickerStore; +import org.asamk.signal.manager.storage.notificationProfiles.NotificationProfileStore; import org.asamk.signal.manager.storage.stickers.StickerStore; import org.asamk.signal.manager.storage.threads.LegacyJsonThreadStore; import org.asamk.signal.manager.util.IOUtils; @@ -181,6 +182,7 @@ public class SignalAccount implements Closeable { private GroupStore groupStore; private RecipientStore recipientStore; private StickerStore stickerStore; + private NotificationProfileStore notificationProfileStore; private UnknownStorageIdStore unknownStorageIdStore; private ConfigurationStore configurationStore; private KeyValueStore keyValueStore; @@ -1303,6 +1305,11 @@ public class SignalAccount implements Closeable { return getOrCreate(() -> stickerStore, () -> stickerStore = new StickerStore(getAccountDatabase())); } + public NotificationProfileStore getNotificationProfileStore() { + return getOrCreate(() -> notificationProfileStore, + () -> notificationProfileStore = new NotificationProfileStore(getAccountDatabase())); + } + public SenderKeyStore getSenderKeyStore() { return getOrCreate(() -> senderKeyStore, () -> senderKeyStore = new SenderKeyStore(getAccountDatabase())); } diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/notificationProfiles/NotificationProfile.java b/lib/src/main/java/org/asamk/signal/manager/storage/notificationProfiles/NotificationProfile.java new file mode 100644 index 00000000..93e45ec3 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/storage/notificationProfiles/NotificationProfile.java @@ -0,0 +1,17 @@ +package org.asamk.signal.manager.storage.notificationProfiles; + +import org.whispersystems.signalservice.api.storage.StorageId; + +/** + * Local copy of a notification profile as synced via storage service. + * The full profile data is kept in the raw storage record, the remaining fields are + * denormalized for querying. + */ +public record NotificationProfile( + long internalId, + byte[] profileId, + String name, + long deletedTimestamp, + StorageId storageId, + byte[] storageRecord +) {} diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/notificationProfiles/NotificationProfileStore.java b/lib/src/main/java/org/asamk/signal/manager/storage/notificationProfiles/NotificationProfileStore.java new file mode 100644 index 00000000..b3ab1cec --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/storage/notificationProfiles/NotificationProfileStore.java @@ -0,0 +1,244 @@ +package org.asamk.signal.manager.storage.notificationProfiles; + +import org.asamk.signal.manager.storage.Database; +import org.asamk.signal.manager.storage.Utils; +import org.asamk.signal.manager.util.KeyUtils; +import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord; +import org.whispersystems.signalservice.api.storage.StorageId; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +public class NotificationProfileStore { + + private static final String TABLE_NOTIFICATION_PROFILE = "notification_profile"; + + private final Database database; + + public static void createSql(Connection connection) throws SQLException { + // When modifying the CREATE statement here, also add a migration in AccountDatabase.java + try (final var statement = connection.createStatement()) { + statement.executeUpdate(""" + CREATE TABLE notification_profile ( + _id INTEGER PRIMARY KEY, + profile_id BLOB UNIQUE NOT NULL, + name TEXT NOT NULL, + deleted_timestamp INTEGER NOT NULL DEFAULT 0, + storage_id BLOB UNIQUE, + storage_record BLOB + ) STRICT; + """); + } + } + + public NotificationProfileStore(final Database database) { + this.database = database; + } + + public List getNotificationProfiles() { + try (final var connection = database.getConnection()) { + return getNotificationProfiles(connection); + } catch (SQLException e) { + throw new RuntimeException("Failed read from notification profile store", e); + } + } + + public List getNotificationProfiles(final Connection connection) throws SQLException { + final var sql = ( + """ + SELECT n._id, n.profile_id, n.name, n.deleted_timestamp, n.storage_id, n.storage_record + FROM %s n + ORDER BY n._id + """ + ).formatted(TABLE_NOTIFICATION_PROFILE); + try (final var statement = connection.prepareStatement(sql)) { + return Utils.executeQueryForStream(statement, this::getNotificationProfileFromResultSet).toList(); + } + } + + public NotificationProfile getNotificationProfile(final Connection connection, final byte[] profileId) throws SQLException { + final var sql = ( + """ + SELECT n._id, n.profile_id, n.name, n.deleted_timestamp, n.storage_id, n.storage_record + FROM %s n + WHERE n.profile_id = ? + """ + ).formatted(TABLE_NOTIFICATION_PROFILE); + try (final var statement = connection.prepareStatement(sql)) { + statement.setBytes(1, profileId); + return Utils.executeQueryForOptional(statement, this::getNotificationProfileFromResultSet).orElse(null); + } + } + + public NotificationProfile getNotificationProfile(final Connection connection, final StorageId storageId) throws SQLException { + final var sql = ( + """ + SELECT n._id, n.profile_id, n.name, n.deleted_timestamp, n.storage_id, n.storage_record + FROM %s n + WHERE n.storage_id = ? + """ + ).formatted(TABLE_NOTIFICATION_PROFILE); + try (final var statement = connection.prepareStatement(sql)) { + statement.setBytes(1, storageId.getRaw()); + return Utils.executeQueryForOptional(statement, this::getNotificationProfileFromResultSet).orElse(null); + } + } + + public List getStorageIds(final Connection connection) throws SQLException { + final var sql = ( + """ + SELECT n.storage_id + FROM %s n + WHERE n.storage_id IS NOT NULL + """ + ).formatted(TABLE_NOTIFICATION_PROFILE); + try (final var statement = connection.prepareStatement(sql)) { + return Utils.executeQueryForStream(statement, this::getStorageIdFromResultSet).toList(); + } + } + + public void updateStorageId( + final Connection connection, + final byte[] profileId, + final StorageId storageId + ) throws SQLException { + final var sql = ( + """ + UPDATE %s + SET storage_id = ? + WHERE profile_id = ? + """ + ).formatted(TABLE_NOTIFICATION_PROFILE); + try (final var statement = connection.prepareStatement(sql)) { + statement.setBytes(1, storageId.getRaw()); + statement.setBytes(2, profileId); + statement.executeUpdate(); + } + } + + public void updateStorageIds( + final Connection connection, + final Map storageIdMap + ) throws SQLException { + final var sql = ( + """ + UPDATE %s + SET storage_id = ? + WHERE _id = ? + """ + ).formatted(TABLE_NOTIFICATION_PROFILE); + try (final var statement = connection.prepareStatement(sql)) { + for (final var entry : storageIdMap.entrySet()) { + statement.setBytes(1, entry.getValue().getRaw()); + statement.setLong(2, entry.getKey()); + statement.executeUpdate(); + } + } + } + + public void setMissingStorageIds() { + final var selectSql = ( + """ + SELECT n._id + FROM %s n + WHERE n.storage_id IS NULL + """ + ).formatted(TABLE_NOTIFICATION_PROFILE); + final var updateSql = ( + """ + UPDATE %s + SET storage_id = ? + WHERE _id = ? + """ + ).formatted(TABLE_NOTIFICATION_PROFILE); + + try (final var connection = database.getConnection()) { + connection.setAutoCommit(false); + try (final var selectStatement = connection.prepareStatement(selectSql)) { + final var ids = Utils.executeQueryForStream(selectStatement, Utils::getIdMapper).toList(); + try (final var updateStatement = connection.prepareStatement(updateSql)) { + for (final var id : ids) { + updateStatement.setBytes(1, KeyUtils.createRawStorageId()); + updateStatement.setLong(2, id); + updateStatement.executeUpdate(); + } + } + } + connection.commit(); + } catch (SQLException e) { + throw new RuntimeException("Failed update notification profile store", e); + } + } + + /** + * Remove profiles that were deleted remotely and are only kept locally as deletion tombstones. + */ + public int removeLocalOnlyDeletedNotificationProfiles( + final Connection connection, + final Collection storageIds + ) throws SQLException { + final var sql = ( + """ + DELETE FROM %s + WHERE storage_id = ? AND deleted_timestamp > 0 + """ + ).formatted(TABLE_NOTIFICATION_PROFILE); + 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 SignalNotificationProfileRecord record + ) throws SQLException { + final var remote = record.getProto(); + final var profileId = remote.id.toByteArray(); + final var storageRecord = remote.encode(); + + final var sql = ( + """ + INSERT INTO %s (profile_id, name, deleted_timestamp, storage_id, storage_record) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (profile_id) DO UPDATE SET + name = excluded.name, + deleted_timestamp = excluded.deleted_timestamp, + storage_id = excluded.storage_id, + storage_record = excluded.storage_record + """ + ).formatted(TABLE_NOTIFICATION_PROFILE); + try (final var statement = connection.prepareStatement(sql)) { + statement.setBytes(1, profileId); + statement.setString(2, remote.name); + statement.setLong(3, remote.deletedAtTimestampMs); + statement.setBytes(4, record.getId().getRaw()); + statement.setBytes(5, storageRecord); + statement.executeUpdate(); + } + } + + private NotificationProfile getNotificationProfileFromResultSet(ResultSet resultSet) throws SQLException { + final var internalId = resultSet.getLong("_id"); + final var profileId = resultSet.getBytes("profile_id"); + final var name = resultSet.getString("name"); + final var deletedTimestamp = resultSet.getLong("deleted_timestamp"); + final var storageIdBytes = resultSet.getBytes("storage_id"); + final var storageId = storageIdBytes == null ? null : StorageId.forNotificationProfile(storageIdBytes); + final var storageRecord = resultSet.getBytes("storage_record"); + return new NotificationProfile(internalId, profileId, name, deletedTimestamp, storageId, storageRecord); + } + + private StorageId getStorageIdFromResultSet(final ResultSet resultSet) throws SQLException { + final var storageId = resultSet.getBytes("storage_id"); + return StorageId.forNotificationProfile(storageId); + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/syncStorage/NotificationProfileRecordProcessor.java b/lib/src/main/java/org/asamk/signal/manager/syncStorage/NotificationProfileRecordProcessor.java new file mode 100644 index 00000000..ff9fbcae --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/syncStorage/NotificationProfileRecordProcessor.java @@ -0,0 +1,79 @@ +package org.asamk.signal.manager.syncStorage; + +import org.asamk.signal.manager.storage.SignalAccount; +import org.asamk.signal.manager.util.KeyUtils; +import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord; +import org.whispersystems.signalservice.api.storage.StorageId; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.Optional; + +public class NotificationProfileRecordProcessor extends DefaultStorageRecordProcessor { + + private final SignalAccount account; + private final Connection connection; + + public NotificationProfileRecordProcessor(final SignalAccount account, final Connection connection) { + this.account = account; + this.connection = connection; + } + + @Override + public int compare(final SignalNotificationProfileRecord lhs, final SignalNotificationProfileRecord rhs) { + return lhs.getProto().id.equals(rhs.getProto().id) ? 0 : 1; + } + + @Override + protected boolean isInvalid(final SignalNotificationProfileRecord remote) { + return remote.getProto().id.size() == 0; + } + + @Override + protected Optional getMatching(final SignalNotificationProfileRecord remote) throws SQLException { + final var profileId = remote.getProto().id.toByteArray(); + final var local = account.getNotificationProfileStore().getNotificationProfile(connection, profileId); + + if (local == null) { + return Optional.empty(); + } + + final StorageId storageId; + if (local.storageId() != null) { + storageId = local.storageId(); + } else { + storageId = StorageId.forNotificationProfile(KeyUtils.createRawStorageId()); + account.getNotificationProfileStore().updateStorageId(connection, profileId, storageId); + } + + return Optional.of(new SignalNotificationProfileRecord(storageId, StorageSyncModels.localToRemoteRecord(local))); + } + + @Override + protected SignalNotificationProfileRecord merge( + final SignalNotificationProfileRecord remoteRecord, + final SignalNotificationProfileRecord localRecord + ) { + final var remote = remoteRecord.getProto(); + final var local = localRecord.getProto(); + + // Profiles are only ever edited by the official clients, so remote wins. + // The one exception is an older local deletion, which must not be resurrected. + if (StickerPackRecordProcessor.shouldKeepLocalDeletion(remote.deletedAtTimestampMs, + local.deletedAtTimestampMs)) { + return localRecord; + } + + return remoteRecord; + } + + @Override + protected void insertLocal(final SignalNotificationProfileRecord record) throws SQLException { + account.getNotificationProfileStore().upsertFromStorageSync(connection, record); + } + + @Override + protected void updateLocal(final StorageRecordUpdate update) throws SQLException { + account.getNotificationProfileStore().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 d1a77361..ad56c497 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 @@ -7,6 +7,7 @@ import org.asamk.signal.manager.storage.configuration.ConfigurationStore; 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.notificationProfiles.NotificationProfile; import org.asamk.signal.manager.storage.recipients.Recipient; import org.asamk.signal.manager.storage.stickers.StickerPack; import org.signal.core.models.ServiceId.ACI; @@ -17,6 +18,7 @@ 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.SignalNotificationProfileRecord; import org.whispersystems.signalservice.api.storage.SignalStickerPackRecord; import org.whispersystems.signalservice.internal.storage.protos.AccountRecord; import org.whispersystems.signalservice.internal.storage.protos.AccountRecord.UsernameLink; @@ -185,6 +187,15 @@ public final class StorageSyncModels { return builder.build(); } + public static org.whispersystems.signalservice.internal.storage.protos.NotificationProfile localToRemoteRecord( + NotificationProfile notificationProfile + ) { + // Notification profiles are never edited locally, the stored record is the source of truth. + final var builder = SignalNotificationProfileRecord.Companion.newBuilder(notificationProfile.storageRecord()); + builder.id(ByteString.of(notificationProfile.profileId())); + return builder.build(); + } + public static TrustLevel remoteToLocal(IdentityState identityState) { return switch (identityState) { case DEFAULT -> TrustLevel.TRUSTED_UNVERIFIED; diff --git a/lib/src/test/java/org/asamk/signal/manager/storage/notificationProfiles/NotificationProfileStoreTest.java b/lib/src/test/java/org/asamk/signal/manager/storage/notificationProfiles/NotificationProfileStoreTest.java new file mode 100644 index 00000000..87772aa7 --- /dev/null +++ b/lib/src/test/java/org/asamk/signal/manager/storage/notificationProfiles/NotificationProfileStoreTest.java @@ -0,0 +1,117 @@ +package org.asamk.signal.manager.storage.notificationProfiles; + +import org.asamk.signal.manager.storage.AccountDatabase; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord; +import org.whispersystems.signalservice.api.storage.StorageId; +import org.whispersystems.signalservice.internal.storage.protos.NotificationProfile; + +import java.io.File; +import java.util.List; + +import okio.ByteString; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +class NotificationProfileStoreTest { + + @TempDir + File tempDir; + + private AccountDatabase database; + private NotificationProfileStore store; + + @BeforeEach + void setUp() throws Exception { + database = AccountDatabase.init(new File(tempDir, "account.db")); + store = new NotificationProfileStore(database); + } + + @AfterEach + void tearDown() { + database.close(); + } + + private static SignalNotificationProfileRecord record(byte[] id, String name, long deletedAt, byte[] storageId) { + return new SignalNotificationProfileRecord(StorageId.forNotificationProfile(storageId), + new NotificationProfile.Builder().id(ByteString.of(id)) + .name(name) + .emoji("🌙") + .deletedAtTimestampMs(deletedAt) + .build()); + } + + private static byte[] bytes(int seed) { + final var result = new byte[16]; + result[0] = (byte) seed; + return result; + } + + @Test + void upsertInsertsAndUpdates() throws Exception { + final var id = bytes(1); + try (final var connection = database.getConnection()) { + store.upsertFromStorageSync(connection, record(id, "Sleep", 0, bytes(10))); + + final var inserted = store.getNotificationProfile(connection, id); + assertNotNull(inserted); + assertEquals("Sleep", inserted.name()); + assertEquals(0, inserted.deletedTimestamp()); + assertArrayEquals(bytes(10), inserted.storageId().getRaw()); + assertEquals("Sleep", NotificationProfile.ADAPTER.decode(inserted.storageRecord()).name); + + store.upsertFromStorageSync(connection, record(id, "Sleep 2", 42, bytes(11))); + + final var all = store.getNotificationProfiles(connection); + assertEquals(1, all.size()); + final var updated = all.getFirst(); + assertEquals(inserted.internalId(), updated.internalId()); + assertEquals("Sleep 2", updated.name()); + assertEquals(42, updated.deletedTimestamp()); + assertArrayEquals(bytes(11), updated.storageId().getRaw()); + + assertNotNull(store.getNotificationProfile(connection, StorageId.forNotificationProfile(bytes(11)))); + assertNull(store.getNotificationProfile(connection, StorageId.forNotificationProfile(bytes(10)))); + assertEquals(List.of(StorageId.forNotificationProfile(bytes(11))), store.getStorageIds(connection)); + } + } + + @Test + void removesOnlyDeletedLocalOnlyProfiles() throws Exception { + try (final var connection = database.getConnection()) { + store.upsertFromStorageSync(connection, record(bytes(1), "Active", 0, bytes(10))); + store.upsertFromStorageSync(connection, record(bytes(2), "Deleted", 42, bytes(11))); + + final var removed = store.removeLocalOnlyDeletedNotificationProfiles(connection, + List.of(StorageId.forNotificationProfile(bytes(10)), StorageId.forNotificationProfile(bytes(11)))); + + assertEquals(1, removed); + assertNotNull(store.getNotificationProfile(connection, bytes(1))); + assertNull(store.getNotificationProfile(connection, bytes(2))); + } + } + + @Test + void updatesStorageIds() throws Exception { + try (final var connection = database.getConnection()) { + store.upsertFromStorageSync(connection, record(bytes(1), "A", 0, bytes(10))); + store.upsertFromStorageSync(connection, record(bytes(2), "B", 0, bytes(11))); + final var profiles = store.getNotificationProfiles(connection); + + store.updateStorageIds(connection, + java.util.Map.of(profiles.get(0).internalId(), + StorageId.forNotificationProfile(bytes(20)), + profiles.get(1).internalId(), + StorageId.forNotificationProfile(bytes(21)))); + + assertArrayEquals(bytes(20), store.getNotificationProfile(connection, bytes(1)).storageId().getRaw()); + assertArrayEquals(bytes(21), store.getNotificationProfile(connection, bytes(2)).storageId().getRaw()); + } + } +} diff --git a/lib/src/test/java/org/asamk/signal/manager/syncStorage/StorageRecordProcessorTest.java b/lib/src/test/java/org/asamk/signal/manager/syncStorage/StorageRecordProcessorTest.java index 1ce68a07..7b3e7719 100644 --- a/lib/src/test/java/org/asamk/signal/manager/syncStorage/StorageRecordProcessorTest.java +++ b/lib/src/test/java/org/asamk/signal/manager/syncStorage/StorageRecordProcessorTest.java @@ -3,9 +3,15 @@ package org.asamk.signal.manager.syncStorage; import org.junit.jupiter.api.Test; import org.signal.core.models.ServiceId.ACI; import org.signal.core.models.ServiceId.PNI; +import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord; +import org.whispersystems.signalservice.api.storage.StorageId; +import org.whispersystems.signalservice.internal.storage.protos.NotificationProfile; import java.util.UUID; +import okio.ByteString; + +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -30,6 +36,29 @@ class StorageRecordProcessorTest { assertFalse(ContactRecordProcessor.shouldUseRemoteIdentityKey(false, false, 33, 33, 0, false)); } + @Test + void notificationProfileMergePrefersRemoteExceptOlderLocalDeletion() { + final var id = ByteString.of(new byte[]{1, 2, 3}); + final var remote = new SignalNotificationProfileRecord(StorageId.forNotificationProfile(new byte[16]), + new NotificationProfile.Builder().id(id).name("Remote").deletedAtTimestampMs(0).build()); + final var local = new SignalNotificationProfileRecord(StorageId.forNotificationProfile(new byte[16]), + new NotificationProfile.Builder().id(id).name("Local").deletedAtTimestampMs(0).build()); + final var processor = new NotificationProfileRecordProcessor(null, null); + + assertEquals(remote, processor.merge(remote, local)); + + final var remoteDeleted = new SignalNotificationProfileRecord(remote.getId(), + remote.getProto().newBuilder().deletedAtTimestampMs(200).build()); + final var localDeleted = new SignalNotificationProfileRecord(local.getId(), + local.getProto().newBuilder().deletedAtTimestampMs(100).build()); + assertEquals(localDeleted, processor.merge(remoteDeleted, localDeleted)); + assertEquals(remoteDeleted, processor.merge(remoteDeleted, local)); + + assertTrue(processor.isInvalid(new SignalNotificationProfileRecord(remote.getId(), + new NotificationProfile.Builder().name("No id").build()))); + assertFalse(processor.isInvalid(remote)); + } + @Test void keepsOlderLocalStickerDeletion() { assertTrue(StickerPackRecordProcessor.shouldKeepLocalDeletion(200, 100)); 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 1123d24f..eec31fa1 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 @@ -9751,6 +9751,29 @@ { "type": "org.whispersystems.signalservice.internal.storage.protos.AccountRecord$NotificationProfileManualOverride" }, + { + "type": "org.whispersystems.signalservice.internal.storage.protos.AccountRecord$NotificationProfileManualOverride$Builder" + }, + { + "type": "org.whispersystems.signalservice.internal.storage.protos.AccountRecord$NotificationProfileManualOverride$Companion" + }, + { + "type": "org.whispersystems.signalservice.internal.storage.protos.AccountRecord$NotificationProfileManualOverride$ManuallyEnabled", + "fields": [ + { + "name": "endAtTimestampMs" + }, + { + "name": "id" + } + ] + }, + { + "type": "org.whispersystems.signalservice.internal.storage.protos.AccountRecord$NotificationProfileManualOverride$ManuallyEnabled$Builder" + }, + { + "type": "org.whispersystems.signalservice.internal.storage.protos.AccountRecord$NotificationProfileManualOverride$ManuallyEnabled$Companion" + }, { "type": "org.whispersystems.signalservice.internal.storage.protos.AccountRecord$PhoneNumberSharingMode" }, @@ -9884,6 +9907,59 @@ } ] }, + { + "type": "org.whispersystems.signalservice.internal.storage.protos.NotificationProfile", + "fields": [ + { + "name": "allowAllCalls" + }, + { + "name": "allowAllMentions" + }, + { + "name": "allowedMembers" + }, + { + "name": "color" + }, + { + "name": "createdAtMs" + }, + { + "name": "deletedAtTimestampMs" + }, + { + "name": "emoji" + }, + { + "name": "id" + }, + { + "name": "name" + }, + { + "name": "scheduleDaysEnabled" + }, + { + "name": "scheduleEnabled" + }, + { + "name": "scheduleEndTime" + }, + { + "name": "scheduleStartTime" + } + ] + }, + { + "type": "org.whispersystems.signalservice.internal.storage.protos.NotificationProfile$Builder" + }, + { + "type": "org.whispersystems.signalservice.internal.storage.protos.NotificationProfile$Companion" + }, + { + "type": "org.whispersystems.signalservice.internal.storage.protos.NotificationProfile$DayOfWeek" + }, { "type": "org.whispersystems.signalservice.internal.storage.protos.OptionalBool" }, @@ -9899,6 +9975,46 @@ } ] }, + { + "type": "org.whispersystems.signalservice.internal.storage.protos.Recipient", + "fields": [ + { + "name": "contact" + }, + { + "name": "groupMasterKey" + }, + { + "name": "legacyGroupId" + } + ] + }, + { + "type": "org.whispersystems.signalservice.internal.storage.protos.Recipient$Builder" + }, + { + "type": "org.whispersystems.signalservice.internal.storage.protos.Recipient$Companion" + }, + { + "type": "org.whispersystems.signalservice.internal.storage.protos.Recipient$Contact", + "fields": [ + { + "name": "e164" + }, + { + "name": "serviceId" + }, + { + "name": "serviceIdBinary" + } + ] + }, + { + "type": "org.whispersystems.signalservice.internal.storage.protos.Recipient$Contact$Builder" + }, + { + "type": "org.whispersystems.signalservice.internal.storage.protos.Recipient$Contact$Companion" + }, { "type": "org.whispersystems.signalservice.internal.storage.protos.StickerPackRecord", "fields": [